feat(gantt): add dependency arrow data builder

This commit is contained in:
kolaente
2026-03-02 09:06:46 +01:00
parent 1358c87e98
commit 73ced5b7d2
2 changed files with 203 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
import {describe, expect, it} from 'vitest'
import {buildRelationArrows, type GanttArrow, type GanttBarPosition} from './ganttRelationArrows'
import type {ITask} from '@/modelTypes/ITask'
function makeTask(id: number, overrides: Partial<ITask> = {}): ITask {
return {
id,
title: `Task ${id}`,
relatedTasks: {},
...overrides,
} as ITask
}
describe('buildRelationArrows', () => {
it('returns empty array when no dependency relations exist', () => {
const tasks = new Map<number, ITask>([
[1, makeTask(1)],
[2, makeTask(2)],
])
const positions = new Map<number, GanttBarPosition>([
[1, {x: 0, y: 20, width: 100, rowIndex: 0}],
[2, {x: 50, y: 60, width: 100, rowIndex: 1}],
])
const result = buildRelationArrows(tasks, positions, new Map())
expect(result).toHaveLength(0)
})
it('creates arrow for blocking relation', () => {
const tasks = new Map<number, ITask>([
[1, makeTask(1, {relatedTasks: {blocking: [makeTask(2)]}})],
[2, makeTask(2, {relatedTasks: {blocked: [makeTask(1)]}})],
])
const positions = new Map<number, GanttBarPosition>([
[1, {x: 0, y: 20, width: 100, rowIndex: 0}],
[2, {x: 150, y: 60, width: 100, rowIndex: 1}],
])
const result = buildRelationArrows(tasks, positions, new Map())
expect(result).toHaveLength(1)
expect(result[0].fromTaskId).toBe(1)
expect(result[0].toTaskId).toBe(2)
expect(result[0].startX).toBe(100) // x + width
expect(result[0].endX).toBe(150) // target x
expect(result[0].color).toBe('var(--danger)')
expect(result[0].relationKind).toBe('blocking')
})
it('creates arrow for precedes relation', () => {
const tasks = new Map<number, ITask>([
[1, makeTask(1, {relatedTasks: {precedes: [makeTask(2)]}})],
[2, makeTask(2, {relatedTasks: {follows: [makeTask(1)]}})],
])
const positions = new Map<number, GanttBarPosition>([
[1, {x: 0, y: 20, width: 100, rowIndex: 0}],
[2, {x: 150, y: 60, width: 100, rowIndex: 1}],
])
const result = buildRelationArrows(tasks, positions, new Map())
expect(result).toHaveLength(1)
expect(result[0].relationKind).toBe('precedes')
expect(result[0].color).toBe('var(--grey-500)')
})
it('skips arrows when target task is not visible', () => {
const tasks = new Map<number, ITask>([
[1, makeTask(1, {relatedTasks: {blocking: [makeTask(99)]}})],
])
const positions = new Map<number, GanttBarPosition>([
[1, {x: 0, y: 20, width: 100, rowIndex: 0}],
])
const result = buildRelationArrows(tasks, positions, new Map())
expect(result).toHaveLength(0)
})
it('re-routes arrows to parent when child is collapsed', () => {
const tasks = new Map<number, ITask>([
[1, makeTask(1, {relatedTasks: {blocking: [makeTask(3)]}})],
[2, makeTask(2)], // parent of task 3
[3, makeTask(3, {relatedTasks: {blocked: [makeTask(1)]}})],
])
const positions = new Map<number, GanttBarPosition>([
[1, {x: 0, y: 20, width: 100, rowIndex: 0}],
[2, {x: 50, y: 60, width: 200, rowIndex: 1}],
// task 3 has no position (collapsed)
])
const hiddenToAncestor = new Map<number, number>([
[3, 2],
])
const result = buildRelationArrows(tasks, positions, hiddenToAncestor)
expect(result).toHaveLength(1)
expect(result[0].toTaskId).toBe(2) // re-routed to parent
expect(result[0].endX).toBe(50) // parent's x
})
it('deduplicates bidirectional relations', () => {
const tasks = new Map<number, ITask>([
[1, makeTask(1, {relatedTasks: {blocking: [makeTask(2)]}})],
[2, makeTask(2, {relatedTasks: {blocked: [makeTask(1)]}})],
])
const positions = new Map<number, GanttBarPosition>([
[1, {x: 0, y: 20, width: 100, rowIndex: 0}],
[2, {x: 150, y: 60, width: 100, rowIndex: 1}],
])
const result = buildRelationArrows(tasks, positions, new Map())
// Only one arrow, not two
expect(result).toHaveLength(1)
})
})

View File

@@ -0,0 +1,86 @@
import type {ITask} from '@/modelTypes/ITask'
export interface GanttBarPosition {
x: number // left edge x position
y: number // vertical center y position
width: number // bar width in pixels
rowIndex: number
}
export interface GanttArrow {
fromTaskId: number
toTaskId: number
startX: number
startY: number
endX: number
endY: number
color: string
relationKind: 'blocking' | 'precedes'
}
const ARROW_COLORS: Record<string, string> = {
blocking: 'var(--danger)',
precedes: 'var(--grey-500)',
}
/**
* Builds arrow data for dependency relations between visible Gantt tasks.
* Only processes `blocking` and `precedes` directions to avoid duplicates.
*/
export function buildRelationArrows(
tasks: Map<number, ITask>,
positions: Map<number, GanttBarPosition>,
hiddenToAncestor: Map<number, number>,
): GanttArrow[] {
const arrows: GanttArrow[] = []
const seen = new Set<string>()
for (const [taskId, task] of tasks) {
const sourceKinds = ['blocking', 'precedes'] as const
for (const kind of sourceKinds) {
const relatedTasks = task.relatedTasks?.[kind] ?? []
for (const related of relatedTasks) {
let fromId = taskId
let toId = related.id
// Re-route hidden tasks to their visible ancestor
if (hiddenToAncestor.has(fromId)) {
fromId = hiddenToAncestor.get(fromId)!
}
if (hiddenToAncestor.has(toId)) {
toId = hiddenToAncestor.get(toId)!
}
// Skip if either end is not visible
if (!positions.has(fromId) || !positions.has(toId)) continue
// Skip self-arrows (can happen after re-routing)
if (fromId === toId) continue
// Deduplicate
const key = `${Math.min(fromId, toId)}-${Math.max(fromId, toId)}-${kind}`
if (seen.has(key)) continue
seen.add(key)
const fromPos = positions.get(fromId)!
const toPos = positions.get(toId)!
arrows.push({
fromTaskId: fromId,
toTaskId: toId,
startX: fromPos.x + fromPos.width,
startY: fromPos.y,
endX: toPos.x,
endY: toPos.y,
color: ARROW_COLORS[kind],
relationKind: kind,
})
}
}
}
return arrows
}