레퍼런스

트리 헬퍼

`buildTaskTree`, `rollUpTasks`, `moveTask`를 비롯한 트리 헬퍼의 시그니처

buildTaskTree, collectSubtreeIds, rollUpTasks는 parentId 계층을 다루는 헤드리스 헬퍼예요. 부모 링크를 정규화하고 하위 트리를 순회하며 부모마다 요약 행을 다시 계산해요. TaskTree는 세 함수가 공유하는 정규화된 트리예요. sortTasksBySequence, validateMove, moveTask는 작업을 행 순서로 정렬하고 이동을 검사한 뒤 적용해요. 여섯 함수 모두 패키지 루트에서 가져올 수 있고 React나 DOM 없이 동작해요.

import {
  buildTaskTree,
  collectSubtreeIds,
  moveTask,
  rollUpTasks,
  sortTasksBySequence,
  validateMove,
  type GanttMoveOptions,
  type GanttMoveRejection,
  type GanttTaskMove,
  type GanttTaskMoveChange,
  type TaskTree,
} from '@jaeungkim/gantt-chart';

화면에서 계층이 어떻게 그려지는지는 작업 목록과 계층에서 설명해요. 행 드래그 제스처는 행 순서 바꾸기를 참고하세요. React 밖에서 이 헬퍼를 쓰는 방법은 헤드리스 코어에 있어요.

TaskTree

/**
 * parentId로 만든 정규화된 트리
 *
 * 고아(데이터에 없는 부모 id), 자기 참조, 순환 체인은 모두 부모 링크가 끊기고 루트가 돼요.
 * 그래서 나오는 parentOf/childIds는 언제나 비순환이에요. 아래 함수들과 렌더는 무한 루프
 * 걱정 없이 위아래로 걸어 다닐 수 있어요.
 */
export interface TaskTree {
  /** 부모 id -> 자식 id 목록(입력 순서) */
  childIds: Map<string, string[]>;
  /** 작업 id -> 정규화된 부모 id(루트, 고아, 순환이면 null) */
  parentOf: Map<string, string | null>;
  /** 작업 id -> 루트로부터의 깊이 */
  depthOf: Map<string, number>;
  /** 루트 id 목록, 입력 순서 - childIds에 키가 없는 형제 목록이에요 */
  rootIds: string[];
}
필드타입내용
childIdsMap<string, string[]>부모 id에서 자식 id 목록으로 가는 맵이에요. 입력 순서를 따르고 자식이 하나 이상인 부모에만 키가 생겨요.
parentOfMap<string, string | null>입력 작업마다 항목이 하나씩 있는 맵이에요. 루트, 고아, 자기 참조, 순환의 일원이면 null이에요.
depthOfMap<string, number>루트에서 내려온 단계 수예요. 루트는 0이고 상한은 없어요.
rootIdsstring[]parentOf가 null인 모든 id예요. 입력 순서를 따라요.

childIds에 null 키는 없어요. childIds.has(leafId)는 false이고 최상위 형제 목록은 childIds가 아니라 rootIds에 있어요. 트리는 정렬하지 않으므로 childIds.get(parent)와 rootIds는 입력 배열 순서 그대로예요.

buildTaskTree

export function buildTaskTree(tasks: TaskNode[]): TaskTree

TaskNode는 Pick<Task, "id" | "parentId">예요. 구조적 타입이라 { id: string; parentId: string | null } 형태면 무엇이든 넘길 수 있어요. Task와 TaskTransformed도 그대로 넘겨도 돼요. Task와 작업 타입을 참고하세요.

부모 해석

각 작업의 parentId는 부모 id나 null로 해석돼요. null로 해석된 작업은 루트예요.

입력해석 결과
parentId가 null, undefined, ""null
parentId === task.id(자기 참조)null
parentId가 tasks에 없는 id를 가리킴(고아)null
그 부모에서 원본 parentId 체인을 따라 올라가다 이미 본 id를 다시 만남(순환)null
그 밖의 경우parentId

링크가 끊겨도 콜백이나 콘솔 경고, TaskTree 필드로는 알려주지 않아요. 순환 탐색은 지나간 id를 모두 기록하므로 어떤 입력이든 n단계 안에 끝나요.

순환 검사는 해석된 parentOf가 아니라 각 조상의 원본 parentId를 읽어요. 그래서 조상 체인이 순환을 지나는 작업도 루트가 돼요. a와 b가 서로를 가리키고 c가 a를 가리키면 셋 다 parentOf === null에 깊이 0이에요.

Task의 parentId는 필수 필드예요. 타입이 string | null이라 null은 넣을 수 있지만 생략은 안 돼요.

중복 id

tasks에 같은 id가 두 번 나와도 중복 제거하거나 알려주지 않아요. 내부 조회 맵은 마지막 항목을 남기지만 작업별 순회는 배열 요소마다 한 번씩 돌아요. 그래서 그 id는 parentOf와 depthOf에 두 번 쓰이고 childIds나 rootIds에도 두 번 들어가요.

collectSubtreeIds

/**
 * 루트 자신을 포함한 하위 트리의 id 목록(너비 우선)
 * 트리에 없는 id는 빈 배열을 돌려줘요
 */
export function collectSubtreeIds(
  tasks: TaskNode[],
  rootId: string,
  tree: TaskTree = buildTaskTree(tasks)
): string[]
파라미터타입기본값설명
tasksTaskNode[]nonetree를 생략했을 때만 읽어요.
rootIdstringnone하위 트리를 모을 id예요.
treeTaskTreebuildTaskTree(tasks)미리 만든 트리예요. 넘기면 트리를 다시 만들지 않지만 tasks는 그래도 넘겨야 해요.
경우결과
rootId가 tree.parentOf에 있음[rootId, ...descendants], 너비 우선
rootId가 tree.parentOf에 없음[]
rootId가 끊긴 순환의 일원[rootId]. 자식들이 루트가 됐기 때문이에요

순서는 깊이 우선이 아니라 너비 우선이에요. 아래 예제는 root 아래에 a와 b가 있고 a 아래에 a1과 a2가 있는 트리예요.

collectSubtreeIds(tasks, 'root'); // ['root', 'a', 'b', 'a1', 'a2']
collectSubtreeIds(tasks, 'a');    // ['a', 'a1', 'a2']
collectSubtreeIds(tasks, 'a1');   // ['a1']

rollUpTasks

/**
 * 모든 부모를 요약 행으로 다시 계산한 작업 목록
 *
 * 시작과 끝은 데이터에 적힌 값이 아니라 언제나 자식에서 나와요
 * (min(자식 start)..max(자식 end)).
 * 깊은 쪽부터 처리해서, 손자의 이동이 부모를 거쳐 조부모까지 올라가요.
 * 명시된 progress는 그대로 두고, 빠진 것만 자식에서 롤업해요.
 */
export function rollUpTasks(
  tasks: Task[],
  tree: TaskTree = buildTaskTree(tasks)
): Task[]

입력은 TaskNode가 아니라 완전한 Task예요. startDate, endDate, progress를 읽어요.

부모 필드

필드반환된 부모의 값
startDate언제나 min(child startDate)예요. 부모 자신의 값은 버려요.
endDate언제나 max(child endDate)예요. 부모 자신의 값은 버려요.
progressparent.progress ?? rollUpProgress(children)예요. 명시된 값은 0이라도 그대로 두고 범위 제한도 하지 않아요.
그 밖의 필드그대로 복사해요.

두 날짜는 dayjs(ms).toISOString()으로 다시 직렬화해요. 자식의 '2025-03-04'는 부모에서 '2025-03-04T00:00:00.000Z'가 돼요.

부모는 깊은 쪽부터 처리하고 각 부모는 이미 다시 쓰인 자식을 읽어요. 그래서 손자의 날짜가 한 번의 호출로 조부모까지 올라가요.

자식의 startDate나 endDate가 하나도 파싱되지 않는 부모는 그대로 둬요.

진행률 롤업

부모에 progress가 없을 때만 계산해요. 자식은 밀리초 단위 기간으로 가중치를 받아요. 기간은 endDate에서 startDate를 뺀 값이고 0 아래로는 내려가지 않아요.

규칙값
자식의 progress가중치를 매기기 전에 0에서 100 사이로 범위를 제한해요.
progress가 없거나 숫자가 아닌 자식0으로 계산해요. 보고된 값으로는 치지 않아요.
progress를 보고한 자식이 없음undefined예요. 부모는 progress: undefined를 유지해요.
모든 자식의 기간이 0자식 수로 나눈 단순 평균이에요.
결과Math.round로 반올림한 정수 퍼센트예요.

100%인 10일과 0%인 30일을 합치면 25예요. 기간이 0인 자식 둘이 100%와 0%면 50이에요.

반환 동일성

경우반환되는 배열
tree.childIds가 비어 있음(부모 없음)넘긴 배열 인스턴스 그대로
다시 쓰인 부모가 없음넘긴 배열 인스턴스 그대로
그 밖의 경우새 배열이에요. 다시 쓰인 부모만 새 객체이고 나머지 요소는 동일성과 순서를 유지해요.

sortTasksBySequence

/** 작업을 sequence 계층 순으로 정렬해요 - 행 순서는 오직 여기서 나와요 */
export function sortTasksBySequence<T extends Pick<Task, "sequence">>(
  tasks: T[]
): T[]

인자는 구조적 타입이에요. sequence 문자열만 있으면 무엇이든 정렬되고 요소 타입은 그대로 반환돼요. 새 배열을 반환하고 입력은 바꾸지 않으며 요소는 동일성을 유지해요. 정렬은 안정적이라 sequence가 같은 두 작업은 입력 순서를 지켜요. 문자열 비교 방식은 작업 데이터를 참고하세요.

GanttTaskMove

이동의 목적지예요. validateMove와 moveTask가 함께 받는 입력이에요.

export interface GanttTaskMove {
  taskId: string;
  /** 새 부모, 최상위면 null */
  toParentId: string | null;
  /** 새 부모의 자식들 사이의 자리 - 이동 뒤의 목록을 기준으로 세요 */
  toIndex: number;
}
필드타입설명
taskIdstring옮길 작업이에요. 하위 트리 전체가 함께 이동해요.
toParentIdstring | null새 부모예요. 최상위는 null이고 지금 부모를 넘기면 형제 사이에서 순서만 바꿔요.
toIndexnumber새 부모의 자식 목록에서 차지할 자리예요. 옮길 하위 트리를 들어낸 뒤의 목록으로 세고 범위 밖 값은 거부하지 않고 목록 안으로 제한해요.

GanttTaskMoveChange

실제로 적용된 이동이에요. moveTask가 반환하고 차트가 onTaskMove에 넘겨요.

export interface GanttTaskMoveChange extends GanttTaskMove {
  fromParentId: string | null;
  fromIndex: number;
  /** 이제 뒤따르는 형제 - 첫 자식이 되면 null */
  afterId: string | null;
  /** 이제 앞서는 형제 - 마지막 자식이 되면 null */
  beforeId: string | null;
}
필드설명
fromParentId이동 전 부모예요. 최상위였으면 null이에요.
fromIndex이동 전에 그 부모의 자식 목록에서 갖고 있던 인덱스예요.
afterId이동 뒤 그 작업 바로 앞에 오는 형제예요. 첫 자식이면 null이에요.
beforeId이동 뒤 그 작업 바로 뒤에 오는 형제예요. 마지막 자식이면 null이에요.

change의 toIndex는 요청한 값이 아니라 범위를 제한하고 실제로 쓴 인덱스예요. toParentId와 toIndex는 인덱스를 받는 API에 맞고 afterId와 beforeId는 이웃 id를 받는 API에 맞아요. 둘 다 같은 결과에서 읽으므로 서로 어긋나지 않아요. onTaskMove는 행 순서 바꾸기를 참고하세요.

GanttMoveOptions

export interface GanttMoveOptions {
  hierarchy: boolean;
  canReorder?: (task: Task) => boolean;
}
필드필수설명
hierarchy필수켜면 형제 목록을 parentId에서 읽고 buildTaskTree와 같은 방식으로 정규화해요. 끄면 깊이는 sequence에서만 오고 형제 목록은 경로에서 읽으며 부모를 바꾸는 이동은 거부돼요.
canReorder선택작업을 옮겨도 되는지 정해요. 생략하면 모든 작업을 옮길 수 있어요.

차트는 canReorder로 resolveTaskInteraction(task, config).canReorder를 넘겨요. GanttInteractionConfig를 참고하세요.

GanttMoveRejection

이동이 거부된 이유예요. validateMove가 이 중 하나를 반환하고 허용이면 null을 반환해요.

export type GanttMoveRejection =
  | "unknown-task"
  | "unknown-parent"
  | "read-only"
  | "cycle"
  | "reparent-disabled"
  | "no-op";
값발생 조건
'unknown-task'move.taskId가 tasks에 없어요.
'unknown-parent'move.toParentId가 있는데 tasks에 없는 id예요. null 부모는 언제나 허용돼요.
'read-only'options.canReorder가 그 작업에 false를 반환했어요.
'reparent-disabled'options.hierarchy가 false인데 이동이 부모를 바꿔요.
'cycle'move.toParentId가 옮길 작업 자신의 하위 트리 안에 있어요. 작업 자신도 포함돼요. hierarchy가 켜져 있을 때만 확인해요. 꺼져 있으면 이동이 parentId를 다시 쓰지 않기 때문이에요.
'no-op'작업이 이미 그 부모의 그 인덱스에 있어요.

validateMove

/**
 * 이동을 적용할 수 있는지, 없다면 왜 없는지
 */
export function validateMove(
  tasks: Task[],
  move: GanttTaskMove,
  options: GanttMoveOptions
): GanttMoveRejection | null

null이면 허용이에요. 그 밖의 값은 이유를 담은 GanttMoveRejection이에요.

검사는 위 GanttMoveRejection 표 순서대로 돌고 처음 걸린 것을 반환해요. 드래그하는 동안 프레임마다 호출해도 될 만큼 가벼워요. 차트도 그렇게 호출해서 포인터를 누른 동안 막힌 드롭을 표시해요.

moveTask

/**
 * 이동을 적용하고, 그 결과로 나온 행 순서에서 모든 sequence를 다시 매겨요
 */
export function moveTask(
  tasks: Task[],
  move: GanttTaskMove,
  options: GanttMoveOptions
): { tasks: Task[]; change: GanttTaskMoveChange } | null

validateMove가 거부하는 이동에는 null을 반환해요. 이유가 필요하면 validateMove를 먼저 호출하세요.

sequence는 경로예요. 길이가 깊이이고 마지막 구간이 형제 사이의 자리라 "1.1"과 "1.2" 사이에 끼울 키가 없어요. 그래서 moveTask는 번호를 다시 매겨요. 트리 전체를 재배치한 뒤 행 순서대로 깊이 우선으로 순회하면서 모든 작업에 렌더링될 자리의 경로를 매겨요. parentId는 옮긴 작업에만 다시 써요. 자식은 여전히 그 작업을 가리키므로 하위 트리는 손대지 않아도 함께 이동해요.

필드바뀌는 작업
sequence트리 경로가 바뀐 모든 작업이에요.
parentId옮긴 작업뿐이에요. options.hierarchy가 켜져 있을 때만 바뀌어요.
그 밖의 필드바뀌지 않아요. 날짜, 진행률, 의존성은 그대로 복사돼요.

sequence와 parentId가 둘 다 그대로인 작업은 같은 객체로 반환돼요. 그래서 작업 객체를 참조로 비교하는 호스트 앱에는 실제로 움직인 작업만 보여요. 배열 자체는 언제나 새것이고 요소 순서는 새 행 순서가 아니라 입력 순서예요. 렌더링하려면 sortTasksBySequence로 정렬하세요.

예제

아래 예제는 트리를 만들고 부모를 롤업한 뒤 자식 하나를 최상위로 옮겨요.

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가 그 위로 들어갔어요

참고

  • getVisibleTasks는 접힌 조상이 있는 작업을 걸러내는 접기 필터예요. 같은 코어 모듈에 있지만 패키지 루트에서는 내보내지 않아서 @jaeungkim/gantt-chart에서 가져올 수 없어요. 차트가 내부에서 호출하고 호스트 앱은 collapsedIds와 defaultCollapsedIds prop으로 같은 동작을 제어해요. 두 prop은 작업 목록과 계층에 설명돼 있어요.
  • TaskNode와 rollUpProgress는 내부용이라 패키지 루트에서 내보내지 않아요. 이 페이지는 위 시그니처를 설명하려고 이름만 썼어요.
  • 여섯 함수 모두 순수 함수라 입력을 바꾸지 않아요. buildTaskTree는 호출마다 새 TaskTree를 반환하고 collectSubtreeIds, sortTasksBySequence, moveTask는 새 배열을 반환해요. 받은 배열 인스턴스를 그대로 반환할 수 있는 함수는 rollUpTasks뿐이에요.
  • 세 트리 헬퍼는 정렬하지 않아요. 행 순서는 sequence에서 오고 parentId는 깊이만 정해요. 그 순서를 적용하는 함수가 sortTasksBySequence이고 다시 쓰는 함수는 moveTask뿐이에요. 두 필드가 맞는지 검사하는 곳은 없어요. 작업 데이터를 참고하세요.
  • validateMove와 moveTask는 구조만 검사해요. 작업을 옮겨도 되는지는 호스트 앱이 정하고 options.canReorder로 넘겨요.