mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-03-11 17:48:44 -05:00
feat(gantt): add dependency arrow data builder
This commit is contained in:
117
frontend/src/helpers/ganttRelationArrows.spec.ts
Normal file
117
frontend/src/helpers/ganttRelationArrows.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
||||
86
frontend/src/helpers/ganttRelationArrows.ts
Normal file
86
frontend/src/helpers/ganttRelationArrows.ts
Normal 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user