Tree helpers
Signatures for `buildTaskTree`, `rollUpTasks`, `moveTask` and the other exported tree helpers
buildTaskTree, collectSubtreeIds and rollUpTasks normalize the parentId links, walk a
subtree and recompute each parent as a summary row. TaskTree is the normalized tree they share.
sortTasksBySequence, validateMove and moveTask sort tasks into row order, check a move and
apply it. All six are exported from the package root and run without React or the DOM.
import {
buildTaskTree,
collectSubtreeIds,
moveTask,
rollUpTasks,
sortTasksBySequence,
validateMove,
type GanttMoveOptions,
type GanttMoveRejection,
type GanttTaskMove,
type GanttTaskMoveChange,
type TaskTree,
} from '@jaeungkim/gantt-chart';The hierarchy on screen is described in Task list and hierarchy and the row drag gesture in Reordering rows. Headless core covers running these helpers outside React.
TaskTree
/**
* A normalized tree built from parentId
*
* Orphans (a parent id that is not in the data), self-references and cyclic chains all get
* their parent link cut and become roots. The parentOf/childIds that come out are therefore
* always acyclic, so the functions below - and the render - can walk up or down without
* risking an infinite loop.
*/
export interface TaskTree {
/** parent id -> child ids (in input order) */
childIds: Map<string, string[]>;
/** task id -> normalized parent id (null for a root, an orphan or a cycle) */
parentOf: Map<string, string | null>;
/** task id -> depth from the root */
depthOf: Map<string, number>;
/** Root ids, in input order - the sibling list childIds has no key for */
rootIds: string[];
}| Field | Type | Contents |
|---|---|---|
childIds | Map<string, string[]> | Parent id to its child ids, in input order. A key exists only for a parent with at least one child, so childIds.has(leafId) is false. There is no null key. The root-level sibling list is rootIds instead. |
parentOf | Map<string, string | null> | One entry for every input task. null means a root, an orphan, a self-reference or a member of a cycle. |
depthOf | Map<string, number> | Steps from the root. A root is 0. There is no cap. |
rootIds | string[] | Every id whose parentOf is null, in input order. |
The tree is not sorted. childIds.get(parent) and rootIds follow the order of the input array.
buildTaskTree
export function buildTaskTree(tasks: TaskNode[]): TaskTreeTaskNode is Pick<Task, "id" | "parentId">. The type is structural, so any object with
{ id: string; parentId: string | null } fits, including a Task or a TaskTransformed.
See Task and task types.
Parent resolution
Each task's parentId resolves to a parent id or null. A task that resolves to null is a root.
| Input | Resolved parent |
|---|---|
parentId is null, undefined or "" | null |
parentId === task.id (self-reference) | null |
parentId names an id that is not in tasks (orphan) | null |
The raw parentId chain above that parent revisits an id already seen (cycle) | null |
| Anything else | parentId |
A cut link is not reported by a callback, a console warning or a field on TaskTree. The cycle
walk records every id it passes, so it ends within n steps for any input.
The cycle check reads each ancestor's raw parentId, not the resolved parentOf. A task whose
ancestor chain passes through a cycle is therefore a root as well. With a and b pointing at
each other and c pointing at a, all three get parentOf === null and depth 0.
parentId is a required field of Task with type string | null, so it accepts null but may
not be omitted.
Duplicate ids
An id that appears twice in tasks is not deduplicated and not reported. The internal lookup
keeps the last occurrence, but each pass runs once per array element. The id is written to
parentOf and depthOf twice and pushed into childIds or rootIds twice.
collectSubtreeIds
/**
* The subtree's ids including the root itself (breadth first)
* An id that is not in the tree yields an empty array
*/
export function collectSubtreeIds(
tasks: TaskNode[],
rootId: string,
tree: TaskTree = buildTaskTree(tasks)
): string[]| Parameter | Type | Default | Description |
|---|---|---|---|
tasks | TaskNode[] | none | Read only when tree is omitted. |
rootId | string | none | The id whose subtree is collected. |
tree | TaskTree | buildTaskTree(tasks) | A prebuilt tree. Passing one skips the rebuild. tasks is still required. |
| Case | Result |
|---|---|
rootId is in tree.parentOf | [rootId, ...descendants], breadth first |
rootId is not in tree.parentOf | [] |
rootId is a member of a cut cycle | [rootId], because its children became roots |
The order is breadth first, not depth first. The example below has a root root with children
a and b, and a has children a1 and a2.
collectSubtreeIds(tasks, 'root'); // ['root', 'a', 'b', 'a1', 'a2']
collectSubtreeIds(tasks, 'a'); // ['a', 'a1', 'a2']
collectSubtreeIds(tasks, 'a1'); // ['a1']rollUpTasks
/**
* The tasks with every parent recomputed as a summary row
*
* Start and end always come from the children, never from what the data says
* (min(child start)..max(child end)).
* Deepest first, so a grandchild's move travels up through the parent and the grandparent.
* An explicit progress is left alone; only a missing one is rolled up from the children.
*/
export function rollUpTasks(
tasks: Task[],
tree: TaskTree = buildTaskTree(tasks)
): Task[]The input is the full Task, not TaskNode. startDate, endDate and progress are read.
Parent fields
| Field | Value on the returned parent |
|---|---|
startDate | Always min(child startDate). The parent's own value is discarded. |
endDate | Always max(child endDate). The parent's own value is discarded. |
progress | parent.progress ?? rollUpProgress(children). An explicit value is kept, including 0, and is not clamped. |
| everything else | Copied through unchanged. |
Both dates are re-serialized with dayjs(ms).toISOString(). A child's '2025-03-04' comes back
as '2025-03-04T00:00:00.000Z' on the parent.
Parents are processed deepest first, and each parent reads the already rewritten version of its children. A grandchild's dates reach the grandparent in one call.
A parent whose children have no parseable startDate or no parseable endDate is left unchanged.
Progress roll-up
This runs only when the parent has no progress. Each child is weighted by its duration in
milliseconds, endDate minus startDate, floored at 0.
| Rule | Value |
|---|---|
Child progress | Clamped to 0 to 100 before weighting. |
Missing or non-numeric child progress | Counts as 0 and does not count as a reported value. |
No child reports a progress | undefined. The parent keeps progress: undefined. |
| Every child has zero duration | A plain mean over the child count. |
| Result | Rounded with Math.round to an integer percent. |
Ten days at 100% plus thirty days at 0% gives 25. Two children with zero duration at 100% and
0% give 50.
Return identity
| Case | Returned array |
|---|---|
tree.childIds is empty (no parents) | The input array instance |
| No parent was rewritten | The input array instance |
| Otherwise | A new array. Rewritten parents are new objects. Every other element keeps its identity and the order is unchanged. |
sortTasksBySequence
/** Sort tasks by their sequence hierarchy - row order comes from this and nothing else */
export function sortTasksBySequence<T extends Pick<Task, "sequence">>(
tasks: T[]
): T[]The argument is structural. Anything with a sequence string sorts, and the element type comes
back unchanged. The result is a new array. The input is not mutated, and the elements keep their
identity. The sort is stable, so two tasks with the same sequence keep their input order.
Task data describes how the strings compare.
GanttTaskMove
The destination of a move. validateMove and moveTask both take it.
export interface GanttTaskMove {
taskId: string;
/** New parent, null for the root level */
toParentId: string | null;
/** Slot among the new parent's children, counted in the list as it stands after the move */
toIndex: number;
}| Field | Type | Description |
|---|---|---|
taskId | string | The task to move. Its whole subtree moves with it. |
toParentId | string | null | The new parent, or null for the root level. Passing the current parent reorders among siblings. |
toIndex | number | Slot among the new parent's children, counted after the moved subtree has been lifted out. An out-of-range value is clamped into the list, not refused. |
GanttTaskMoveChange
The move that was applied. moveTask returns it and the chart passes it to onTaskMove.
export interface GanttTaskMoveChange extends GanttTaskMove {
fromParentId: string | null;
fromIndex: number;
/** Sibling the task now follows - null when it is the first child */
afterId: string | null;
/** Sibling the task now precedes - null when it is the last child */
beforeId: string | null;
}| Field | Description |
|---|---|
fromParentId | The previous parent, null for the root level. |
fromIndex | The index among that parent's children before the move. |
afterId | The sibling the task follows after the move, null when it is first. |
beforeId | The sibling the task precedes after the move, null when it is last. |
toIndex on the change is the clamped index that was used, not the requested one. toParentId
with toIndex suits an API that takes an index. afterId and beforeId suit an API that takes
a neighbouring id. Both are read from the same result, so they cannot disagree.
Reordering rows covers onTaskMove.
GanttMoveOptions
export interface GanttMoveOptions {
hierarchy: boolean;
canReorder?: (task: Task) => boolean;
}| Field | Required | Description |
|---|---|---|
hierarchy | yes | On, sibling lists come from parentId, normalized the way buildTaskTree normalizes it. Off, depth comes from sequence alone and sibling lists come from the path. A move that changes the parent is refused. |
canReorder | no | Whether a task may move. Omitted, every task may. The chart passes resolveTaskInteraction(task, config).canReorder. See GanttInteractionConfig. |
GanttMoveRejection
The reason a move was refused. validateMove returns one of these, or null when the move is
allowed.
export type GanttMoveRejection =
| "unknown-task"
| "unknown-parent"
| "read-only"
| "cycle"
| "reparent-disabled"
| "no-op";| Value | Trigger |
|---|---|
'unknown-task' | move.taskId is not in tasks. |
'unknown-parent' | move.toParentId is set and not in tasks. A null parent is always accepted. |
'read-only' | options.canReorder returned false for the task. |
'reparent-disabled' | options.hierarchy is false and the move changes the parent. |
'cycle' | move.toParentId is inside the moved task's own subtree, the task itself included. Checked only when hierarchy is on, because a move never rewrites parentId otherwise. |
'no-op' | The task is already that parent's child at that index. |
validateMove
/**
* Whether a move can be applied, and why not when it cannot
*/
export function validateMove(
tasks: Task[],
move: GanttTaskMove,
options: GanttMoveOptions
): GanttMoveRejection | nullnull means the move is allowed. Any other value is the GanttMoveRejection naming the reason.
The checks run in the order of the GanttMoveRejection table above, and the first failure is
returned. The function is cheap enough to call on every frame of a drag. The chart calls it that
way to show a blocked drop while the pointer is down.
moveTask
/**
* Applies a move, renumbering every sequence from the row order that results
*/
export function moveTask(
tasks: Task[],
move: GanttTaskMove,
options: GanttMoveOptions
): { tasks: Task[]; change: GanttTaskMoveChange } | nullmoveTask returns null for any move that validateMove refuses. Call validateMove first to
get the reason.
sequence is a path. Its length is the depth and its last segment the slot among siblings, so no
key fits between "1.1" and "1.2". moveTask renumbers instead. It rearranges the forest,
walks it depth first in row order and gives every task the path it renders at. parentId is
rewritten on the moved task only. Its children keep pointing at it, so the subtree moves untouched.
| Field | Changed on |
|---|---|
sequence | Every task whose path in the tree changed. |
parentId | The moved task only, and only when options.hierarchy is on. |
| everything else | No task. Dates, progress and dependencies are copied through. |
A task whose sequence and parentId are both unchanged is returned as the same object, so a
host diffing by identity sees only the tasks that moved. The array itself is always new. Its
element order is the input order, not the new row order. Sort it with sortTasksBySequence to
render it.
Example
The example below builds a tree, rolls up the parent and moves a child to the root level.
import {
buildTaskTree,
collectSubtreeIds,
moveTask,
rollUpTasks,
} from '@jaeungkim/gantt-chart';
import type { Task } from '@jaeungkim/gantt-chart';
const tasks: Task[] = [
{
id: 'p', name: 'Phase 1', parentId: null, sequence: '1',
startDate: '2025-03-01', endDate: '2025-03-02',
},
{
id: 'c1', name: 'Design', parentId: 'p', sequence: '1.1',
startDate: '2025-01-01', endDate: '2025-01-11', progress: 100,
},
{
id: 'c2', name: 'Build', parentId: 'p', sequence: '1.2',
startDate: '2025-01-11', endDate: '2025-02-10', progress: 0,
},
];
const tree = buildTaskTree(tasks);
tree.depthOf.get('c1'); // 1
tree.childIds.get('p'); // ['c1', 'c2']
tree.rootIds; // ['p']
collectSubtreeIds(tasks, 'p', tree); // ['p', 'c1', 'c2']
const rolled = rollUpTasks(tasks, tree);
rolled[0].startDate; // '2025-01-01T00:00:00.000Z'
rolled[0].endDate; // '2025-02-10T00:00:00.000Z'
rolled[0].progress; // 25
const moved = moveTask(tasks, { taskId: 'c2', toParentId: null, toIndex: 0 }, {
hierarchy: true,
});
moved!.tasks.map((t) => `${t.id} ${t.sequence}`); // ['p 2', 'c1 2.1', 'c2 1']
moved!.change.beforeId; // 'p' - c2 landed above itNotes
getVisibleTasksis the collapse filter that drops any task with a collapsed ancestor. It is in the same core module, but not exported from the package root, so it cannot be imported from@jaeungkim/gantt-chart. The chart calls it internally. A host drives the same behaviour through thecollapsedIdsanddefaultCollapsedIdsprops, described in Task list and hierarchy.TaskNodeandrollUpProgressare internal and not exported from the package root. This page names them only to describe the signatures above.- All six functions are pure and never mutate their input.
buildTaskTreereturns a newTaskTreeon every call, andcollectSubtreeIds,sortTasksBySequenceandmoveTaska new array. OnlyrollUpTaskscan return the array instance it was given. - The three tree helpers order nothing. Row order comes from
sequence, andparentIdsets depth only.sortTasksBySequenceapplies that order andmoveTaskis the only function that rewrites it. Nothing validates that the two fields agree. See Task data. validateMoveandmoveTaskcheck structure only. Whether a task may be moved is the host's decision, passed in asoptions.canReorder.