{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"gantt-view","type":"registry:ui","title":"The gantt body - split resizable tree/timeline panes with synced scrolling, multi-column tree, grouped two-row header, day through year scales, zoom, drag-to-pan, lanes, drag and resize.","description":"The gantt body - split resizable tree/timeline panes with synced scrolling, multi-column tree, grouped two-row header, day through year scales, zoom, drag-to-pan, lanes, drag and resize.","dependencies":["date-fns","radix-ui"],"registryDependencies":["button","checkbox","context-menu","@neui/gantt","@neui/gantt-bar","@neui/gantt-dnd","@neui/gantt-lib","@neui/gantt-types","scroll-area","tooltip"],"files":[{"path":"gantt-view.tsx","type":"registry:ui","content":"// Title: Gantt View\n// Description: The gantt body - split resizable tree/timeline panes with synced scrolling, multi-column tree, grouped two-row header, day through year scales, zoom, drag-to-pan, lanes, drag and resize.\n\n\"use client\"\n\nimport {\n memo,\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type CSSProperties,\n type HTMLAttributes,\n type RefObject,\n} from \"react\"\nimport {\n DEFAULT_ROW_ALIGN,\n resolveScheduleMode,\n resolveTimelineLines,\n useGantt,\n useGanttSelector,\n useGanttSettings,\n useGanttViewConfig,\n type GanttColumn,\n} from \"@/components/neui/gantt/gantt\"\nimport { GanttBar } from \"@/components/neui/gantt/gantt-bar\"\nimport {\n cancelActiveGanttGestures,\n markGestureEnd,\n useGanttGestures,\n useGanttGestureTeardown,\n wasRecentDrag,\n} from \"@/components/neui/gantt/gantt-dnd\"\nimport {\n getDayKey,\n getLaneKey,\n getRangeKey,\n MIN_PACK_SLOT,\n packTimedSegments,\n reorderResources,\n resolveOffDay,\n toZoned,\n zonedStartOfDay,\n type GanttLaneMemo,\n} from \"@/components/neui/gantt/gantt-lib\"\nimport type {\n GanttDateRange,\n GanttEvent,\n GanttOccurrence,\n GanttResource,\n GanttResourceReorder,\n GanttSegment,\n} from \"@/components/neui/gantt/gantt-types\"\nimport {\n addDays,\n addMinutes,\n addMonths,\n format,\n getWeek,\n startOfMonth,\n startOfQuarter,\n startOfWeek,\n type Locale,\n} from \"date-fns\"\nimport { Slot } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport {\n ContextMenu,\n ContextMenuContent,\n ContextMenuTrigger,\n} from \"@/components/ui/context-menu\"\nimport { ScrollArea, ScrollBar } from \"@/components/ui/scroll-area\"\nimport {\n Tooltip,\n TooltipContent,\n TooltipProvider,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { IconPlaceholder } from \"@/app/(create)/components/icon-placeholder\"\n\n/** Current time, refreshed on an interval and on tab focus. */\nfunction useNow(intervalMs = 30_000): Date {\n const [now, setNow] = useState(() => new Date())\n useEffect(() => {\n const tick = () => setNow(new Date())\n const id = setInterval(tick, intervalMs)\n document.addEventListener(\"visibilitychange\", tick)\n window.addEventListener(\"focus\", tick)\n return () => {\n clearInterval(id)\n document.removeEventListener(\"visibilitychange\", tick)\n window.removeEventListener(\"focus\", tick)\n }\n }, [intervalMs])\n return now\n}\n\n/**\n * Today's zoned day key, re-rendering only at the midnight rollover (and on\n * focus/visibility) - the grid needs day granularity, not the 30s now tick.\n */\nfunction useTodayKey(timeZone: string): string {\n const [key, setKey] = useState(() => getDayKey(new Date(), timeZone))\n useEffect(() => {\n const tick = () =>\n setKey((prev) => {\n const next = getDayKey(new Date(), timeZone)\n return next === prev ? prev : next\n })\n tick()\n const id = setInterval(tick, 60_000)\n document.addEventListener(\"visibilitychange\", tick)\n window.addEventListener(\"focus\", tick)\n return () => {\n clearInterval(id)\n document.removeEventListener(\"visibilitychange\", tick)\n window.removeEventListener(\"focus\", tick)\n }\n }, [timeZone])\n return key\n}\n\n/**\n * Lowest lane free for [startMs, endMs) among a row's segments, padded by\n * MIN_PACK_SLOT exactly as packTimedSegments pads its own occupancy test.\n * Comparing raw instants instead lets a sub-slot bar read as clear, so an\n * affordance would promise a lane the packer then refuses.\n *\n * Shared by the hover hint and the drag placeholder on purpose: two copies of\n * this is how the ring and the range it paints end up on different tracks.\n */\nfunction lowestFreeLane(\n segments: GanttSegment[],\n startMs: number,\n endMs: number\n): number {\n const padMs = MIN_PACK_SLOT * 60000\n const to = Math.max(endMs, startMs + padMs)\n const busy = new Set()\n for (const segment of segments) {\n const segStart = segment.occurrence.start.getTime()\n const segEnd = Math.max(segment.occurrence.end.getTime(), segStart + padMs)\n if (segStart < to && segEnd > startMs) busy.add(segment.column ?? 0)\n }\n let lane = 0\n while (busy.has(lane)) lane += 1\n return lane\n}\n\n/**\n * Pointer x resolved against the element's TIME axis: the 0..1 fraction and\n * the same measurement in CSS pixels from the axis start. Mirrored in RTL,\n * where the range start renders at the element's right edge. One rect read\n * and one style read, because this runs on every pointer move.\n */\nfunction trackPoint(\n el: HTMLElement,\n clientX: number\n): { fraction: number; offset: number } {\n const rect = el.getBoundingClientRect()\n const rtl = getComputedStyle(el).direction === \"rtl\"\n const offset = rtl ? rect.right - clientX : clientX - rect.left\n // `offset` is exact and drives the time maths. `snapped` is the same value\n // biased so that rect start + snapped lands on a WHOLE viewport pixel: the\n // row's own edge routinely sits on a half pixel, so rounding the offset\n // alone still puts anything placed at it between two pixels.\n const snapped = rtl\n ? rect.right - Math.round(clientX)\n : Math.round(clientX) - rect.left\n return {\n fraction: rect.width > 0 ? offset / rect.width : 0,\n offset: snapped,\n }\n}\n\n/** Fraction only, for the call sites that do not place anything. */\nfunction trackFraction(el: HTMLElement, clientX: number): number {\n return trackPoint(el, clientX).fraction\n}\n\n/**\n * Row geometry is three numbers: a bar is LANE_HEIGHT_REM tall, stacked bars\n * are separated by LANE_GAP_REM, and the block as a whole is inset from the\n * row's edges by ROW_PADDING_REM. Padding and gap are deliberately NOT the\n * same value - schedules in one node belong together, so they sit tight, while\n * the row still needs real breathing room above and below. Every inter-lane\n * gap is identical, which is what keeps a stacked row reading evenly.\n */\nconst LANE_HEIGHT_REM = 1.25\nconst LANE_GAP_REM = 0.1875\nconst ROW_PADDING_REM = 0.5\n/** Drop-indicator height (h-5); it is centered inside its lane band. */\nconst GHOST_HEIGHT_REM = 1.25\n/** Bars narrower than this flip their title outside in barLabel \"auto\". */\nconst AUTO_LABEL_MIN_REM = 7\nconst DEFAULT_TREE_PANEL = {\n width: 288,\n minWidth: 180,\n maxWidth: 640,\n resizable: true,\n nameColumnWidth: 208,\n}\nconst DEFAULT_COLUMN_WIDTH = 96\nconst DEFAULT_ZOOM_RANGE = { min: 0.5, max: 3 }\n/** Timeline pane never shrinks below this so it stays usable on narrow screens. */\nconst MIN_TIMELINE_WIDTH = 200\n/** Scroll distance from an edge that triggers infinite-range growth. */\nconst INFINITE_EDGE_PX = 160\n\ninterface TimelineUnit {\n key: string\n label: string\n ms: number\n /** Relative width share; uniform scales use 1 (year: days per month). */\n weight: number\n isToday?: boolean\n isOff?: boolean\n}\n\ninterface TimelineGroup {\n key: string\n label: string\n span: number\n}\n\ninterface TimelineRow {\n resource: GanttResource\n parentId: string | null\n depth: number\n isGroup: boolean\n collapsed: boolean\n}\n\n/** Per-row packed bars plus the extents the off-screen chips need. */\ninterface TimelineRowBars {\n segments: GanttSegment[]\n laneCount: number\n /**\n * Lane a drag-create in flight would land on, or null when none is aimed at\n * this row. The row reserves the track, so it grows exactly as it will on\n * commit and the placeholder never has to overlap the bar it is going under.\n * Computed here, once, because BOTH panes size themselves from this object -\n * deriving it a second time in the timeline row is how the two would drift.\n */\n draftLane: number | null\n /** Mode this row was packed under; the draft overlay must honour it. */\n scheduleMode: \"single\" | \"multiple\"\n heightRem: number\n /** Gap above the first bar; equal to every other gap in the row. */\n laneOffsetRem: number\n /**\n * The band the FIRST schedule occupies, its gaps included. The tree cell\n * sizes its label box to exactly this, so the label and the first bar share\n * a centerline however many lanes the node grew.\n */\n bandRem: number\n /** Envelope of all bars, as track fractions; null when the row is empty. */\n extent: {\n from: number\n to: number\n color?: string\n label: string\n /** First bar start, for the jump-chip tooltip. */\n startMs: number\n } | null\n /**\n * Parent rollup: descendant-bar envelope + duration-weighted progress,\n * present only on group rows without bars of their own.\n */\n summary: { from: number; to: number; progress: number | null } | null\n}\n\ninterface TimelineReorderState {\n resourceId: string\n /** Insertion offset (px) within the tree pane. */\n top: number\n valid: boolean\n proposal: GanttResourceReorder | null\n}\n\ninterface GanttViewProps extends HTMLAttributes {\n /** Day-scale unit interval in minutes; defaults to the interval view config. */\n interval?: number\n asChild?: boolean\n}\n\n/** The pane's scrollable viewport (custom ScrollArea or native host). */\nfunction getPaneViewport(pane: HTMLElement | null): HTMLElement | null {\n return (\n pane?.querySelector(\"[data-slot=scroll-area-viewport]\") ?? null\n )\n}\n\n/** Distance scrolled from the inline-start edge (RTL reports negative). */\nfunction getScrollStart(viewport: HTMLElement): number {\n return Math.abs(viewport.scrollLeft)\n}\n\n/** Write a distance-from-inline-start back as a signed scrollLeft. */\nfunction setScrollStart(viewport: HTMLElement, value: number) {\n viewport.scrollLeft =\n getComputedStyle(viewport).direction === \"rtl\" ? -value : value\n}\n\nfunction GanttView({\n className,\n asChild = false,\n interval: intervalProp,\n ...props\n}: GanttViewProps) {\n const instance = useGantt()\n const settings = useGanttSettings()\n const viewConfig = useGanttViewConfig()\n const range = useGanttSelector(\n (state) => state.visibleRange,\n { isEqual: (a, b) => getRangeKey(a) === getRangeKey(b) }\n )\n const occurrences = useGanttSelector(\n () => instance.api.getOccurrences(),\n {\n calendar: instance,\n isEqual: (a, b) =>\n a.length === b.length &&\n a.every(\n (occ, i) =>\n occ.key === b[i]?.key &&\n occ.start.getTime() === b[i]?.start.getTime() &&\n occ.end.getTime() === b[i]?.end.getTime() &&\n occ.event === b[i]?.event\n ),\n }\n )\n\n // Tree expand/collapse: controlled (collapsedGroups/onCollapsedGroupsChange)\n // or uncontrolled (defaultCollapsedGroups) - same pattern as selectedRows.\n const [internalCollapsed, setInternalCollapsed] = useState(\n () => viewConfig.defaultCollapsedGroups ?? []\n )\n const collapsedIds = viewConfig.collapsedGroups ?? internalCollapsed\n const collapsedGroups = useMemo(() => new Set(collapsedIds), [collapsedIds])\n\n const scale = useGanttSelector((state) => state.scale)\n const interval = Math.min(\n Math.max(intervalProp ?? viewConfig.interval, 15),\n 240\n )\n // Every layout metric is consumer-overridable; unset keys keep defaults.\n const metrics = viewConfig.metrics\n const laneHeightRem = metrics?.laneHeight ?? LANE_HEIGHT_REM\n const rowPaddingRem = metrics?.rowPadding ?? ROW_PADDING_REM\n const laneGapRem = metrics?.laneGap ?? LANE_GAP_REM\n const minRowRem = metrics?.minRowHeight ?? 2.5\n const minTimelineWidth = metrics?.minTimelineWidth ?? MIN_TIMELINE_WIDTH\n const infiniteEdgePx = metrics?.infiniteScrollEdge ?? INFINITE_EDGE_PX\n const timeZone = settings.timeZone\n const rangeStartMs = range.start.getTime()\n const rangeEndMs = range.end.getTime()\n const rangeKey = getRangeKey(range)\n const snapMin = scale === \"day\" ? settings.snapDuration : 24 * 60\n // day-granular time input so the today highlight rolls over at midnight\n // without the whole grid re-rendering on the 30s now tick\n const todayDayKey = useTodayKey(timeZone)\n\n const rows = useMemo(() => {\n const result: TimelineRow[] = []\n const walk = (\n resources: GanttResource[],\n depth: number,\n parentId: string | null\n ) => {\n for (const resource of resources) {\n const isGroup = !!resource.children?.length\n const collapsed = collapsedGroups.has(resource.id)\n result.push({ resource, parentId, depth, isGroup, collapsed })\n if (isGroup && !collapsed)\n walk(resource.children!, depth + 1, resource.id)\n }\n }\n walk(settings.resources, 0, null)\n return result\n }, [settings.resources, collapsedGroups])\n\n // Header model: bottom row = units, top row = grouping sectors.\n // Weights are proportional to REAL duration (a 23h/25h DST day differs from\n // its siblings), so weight-driven gridlines, ms-fraction bar geometry, and\n // the dnd pointer math all share one coordinate system.\n const { units, groups, unitWidthRem } = useMemo(() => {\n const units: TimelineUnit[] = []\n const groups: TimelineGroup[] = []\n // day-start ms for the today window checks; recomputed when the day key\n // rolls over (this memo depends on todayDayKey)\n const todayStartMs = zonedStartOfDay(new Date(), timeZone).getTime()\n if (scale === \"day\") {\n const labelFormat =\n interval % 60 === 0 ? settings.i18n.formats.timeGutter : \"h:mm\"\n // walk whole days: infinite scroll can extend the range past one day\n let dayCursor = zonedStartOfDay(range.start, timeZone)\n while (dayCursor.getTime() < rangeEndMs) {\n const zonedDay = toZoned(dayCursor, timeZone)\n const nextDay = zonedStartOfDay(addDays(zonedDay, 1), timeZone)\n const dayMinutes = (nextDay.getTime() - dayCursor.getTime()) / 60000\n const dayOff = resolveOffDay(\n dayCursor,\n timeZone,\n viewConfig.offDays ?? true\n )\n let span = 0\n for (let m = 0; m < dayMinutes; m += interval) {\n const time = addMinutes(zonedDay, m)\n // a DST day whose minutes don't divide evenly leaves a short\n // final unit; its weight must be its REAL share or bars drift\n const weight = Math.min(interval, dayMinutes - m) / interval\n units.push({\n key: `${getDayKey(dayCursor, timeZone)}-m${m}`,\n label: format(time, labelFormat, { locale: settings.locale }),\n ms: time.getTime(),\n weight,\n isOff: dayOff,\n })\n span += weight\n }\n groups.push({\n key: getDayKey(dayCursor, timeZone),\n label: format(zonedDay, settings.i18n.formats.dayTitle, {\n locale: settings.locale,\n }),\n span,\n })\n dayCursor = nextDay\n }\n return {\n units,\n groups,\n unitWidthRem:\n metrics?.unitWidths?.day ?? Math.max(2.5, 5 * (interval / 60)),\n }\n }\n if (scale === \"quarter\") {\n // units are week-aligned weeks (lib aligns the range), groups are months\n let cursor = zonedStartOfDay(range.start, timeZone)\n while (cursor.getTime() < rangeEndMs) {\n const zoned = toZoned(cursor, timeZone)\n const next = zonedStartOfDay(addDays(zoned, 7), timeZone)\n // real week duration / nominal week: 1 except across DST changes\n const weight =\n (next.getTime() - cursor.getTime()) / (7 * 24 * 60 * 60000)\n units.push({\n key: getDayKey(cursor, timeZone),\n label: format(zoned, \"MMM d\", { locale: settings.locale }),\n ms: cursor.getTime(),\n weight,\n isToday:\n todayStartMs >= cursor.getTime() && todayStartMs < next.getTime(),\n })\n const monthKey = format(zoned, \"yyyy-MM\")\n const lastGroup = groups[groups.length - 1]\n if (lastGroup && lastGroup.key === monthKey) {\n lastGroup.span += weight\n } else {\n groups.push({\n key: monthKey,\n label: format(zoned, \"MMMM\", { locale: settings.locale }),\n span: weight,\n })\n }\n cursor = next\n }\n return { units, groups, unitWidthRem: metrics?.unitWidths?.quarter ?? 8 }\n }\n if (scale === \"year\") {\n // units are calendar months (weight = real duration), groups are quarters\n let cursor: Date = startOfMonth(toZoned(range.start, timeZone))\n while (cursor.getTime() < rangeEndMs) {\n const next = startOfMonth(addMonths(cursor, 1))\n // nominal-day units so a month reads ~30 wide; real ms keeps DST months true\n const weight = (next.getTime() - cursor.getTime()) / (24 * 60 * 60000)\n units.push({\n key: format(cursor, \"yyyy-MM\"),\n label: format(cursor, \"MMM\", { locale: settings.locale }),\n ms: cursor.getTime(),\n weight,\n isToday:\n todayStartMs >= cursor.getTime() && todayStartMs < next.getTime(),\n })\n const quarterStart = startOfQuarter(cursor)\n const quarterKey = format(quarterStart, \"yyyy-QQQ\")\n const lastGroup = groups[groups.length - 1]\n if (lastGroup && lastGroup.key === quarterKey) {\n lastGroup.span += weight\n } else {\n groups.push({\n key: quarterKey,\n label: format(quarterStart, \"QQQ yyyy\", {\n locale: settings.locale,\n }),\n span: weight,\n })\n }\n cursor = next\n }\n return { units, groups, unitWidthRem: metrics?.unitWidths?.year ?? 10 }\n }\n // week/month: units are days, groups are ISO-ish weeks\n let cursor = zonedStartOfDay(range.start, timeZone)\n while (cursor.getTime() < rangeEndMs) {\n const zoned = toZoned(cursor, timeZone)\n const nextDay = zonedStartOfDay(addDays(zoned, 1), timeZone)\n // real day duration / 24h: 1 except the 23h/25h DST days\n const weight = (nextDay.getTime() - cursor.getTime()) / (24 * 60 * 60000)\n units.push({\n key: getDayKey(cursor, timeZone),\n label: format(zoned, \"EEE d\", { locale: settings.locale }),\n ms: cursor.getTime(),\n weight,\n isToday: getDayKey(cursor, timeZone) === todayDayKey,\n isOff: resolveOffDay(cursor, timeZone, viewConfig.offDays ?? true),\n })\n // locale supplies firstWeekContainsDate so W-numbers match the locale's\n // week numbering (ISO in de/fr, US-style otherwise); the explicit\n // weekStartsOn keeps the number aligned with the rendered grid\n const weekNumber = getWeek(zoned, {\n locale: settings.locale,\n weekStartsOn: settings.weekStartsOn,\n })\n // key + label from the true week start: a range that begins midweek\n // must not split or mislabel its first group (incl. the Jan 1 week)\n const weekStart = startOfWeek(zoned, {\n weekStartsOn: settings.weekStartsOn,\n })\n const weekKey = `w-${format(weekStart, \"yyyy-MM-dd\")}`\n const lastGroup = groups[groups.length - 1]\n if (lastGroup && lastGroup.key === weekKey) {\n lastGroup.span += weight\n } else {\n groups.push({\n key: weekKey,\n label: `${settings.i18n.labels.week(weekNumber)} ${format(weekStart, \"MMM d\", { locale: settings.locale })} - ${format(addDays(weekStart, 6), \"d\", { locale: settings.locale })}`,\n span: weight,\n })\n }\n cursor = nextDay\n }\n return {\n units,\n groups,\n unitWidthRem: metrics?.unitWidths?.[scale] ?? (scale === \"week\" ? 10 : 4),\n }\n }, [\n scale,\n interval,\n range.start,\n rangeEndMs,\n timeZone,\n settings.i18n,\n settings.locale,\n settings.weekStartsOn,\n viewConfig.offDays,\n todayDayKey,\n metrics,\n ])\n\n // Zoom multiplies the minimum unit width; the flex track still fills when\n // the zoomed width is narrower than the pane. Controlled (zoom/onZoomChange)\n // or uncontrolled (defaultZoom) - same pattern as selectedRows.\n const zoomRange = { ...DEFAULT_ZOOM_RANGE, ...viewConfig.zoomRange }\n const clampZoom = (value: number) =>\n Math.min(Math.max(value, zoomRange.min), zoomRange.max)\n const [internalZoom, setInternalZoom] = useState(\n () => viewConfig.defaultZoom ?? 1\n )\n const zoom = clampZoom(viewConfig.zoom ?? internalZoom)\n const setZoomValue = (next: number) => {\n const clamped = clampZoom(next)\n if (viewConfig.zoom === undefined) setInternalZoom(clamped)\n viewConfig.onZoomChange?.(clamped)\n }\n const canZoomIn = zoom < zoomRange.max - 1e-9\n const canZoomOut = zoom > zoomRange.min + 1e-9\n const trackRemWidth = units.length * unitWidthRem * zoom\n const trackWidth = `${trackRemWidth}rem`\n // unequal weights (year months, DST-containing day/week ranges) draw\n // boundaries from weight fractions instead of the uniform gradient\n const uniform = units.every(\n (unit) => Math.abs(unit.weight - units[0].weight) < 1e-9\n )\n const totalWeight = units.reduce((sum, unit) => sum + unit.weight, 0)\n /** Cumulative start/width fractions per unit, for backdrop stripes/lines. */\n const unitFractions = useMemo(() => {\n let acc = 0\n return units.map((unit) => {\n const start = acc / totalWeight\n acc += unit.weight\n return { unit, start, width: unit.weight / totalWeight }\n })\n }, [units, totalWeight])\n /** Snap a track fraction to its unit (hint preview target). */\n const resolveHintStop = useMemo(() => {\n return (\n fraction: number\n ): { index: number; center: number; ms: number; endMs: number } | null => {\n for (let i = 0; i < unitFractions.length; i++) {\n const { unit, start, width } = unitFractions[i]\n if (fraction < start + width || i === unitFractions.length - 1) {\n return {\n index: i,\n center: start + width / 2,\n ms: unit.ms,\n endMs: unitFractions[i + 1]?.unit.ms ?? unit.ms,\n }\n }\n }\n return null\n }\n }, [unitFractions])\n\n /** Group boundary fractions; spans are in unit-weight terms everywhere. */\n const groupBoundaries = useMemo(() => {\n const fractions: number[] = []\n let acc = 0\n for (let i = 0; i < groups.length - 1; i++) {\n acc += groups[i].span\n fractions.push(acc / totalWeight)\n }\n return fractions\n }, [groups, totalWeight])\n /** Resource id -> every descendant id, for parent rollups. */\n const descendantIds = useMemo(() => {\n const map = new Map()\n const walk = (resource: GanttResource): string[] => {\n const ids = (resource.children ?? []).flatMap((child) => [\n child.id,\n ...walk(child),\n ])\n map.set(resource.id, ids)\n return ids\n }\n settings.resources.forEach(walk)\n return map\n }, [settings.resources])\n\n // All events (not just visible occurrences) so parent rollup progress is\n // all-time and matches a consumer's own tree rollup, independent of scroll.\n const allEvents = useGanttSelector(\n (state) => state.events\n )\n const subtreeProgress = useMemo(() => {\n // consumer-owned rollup math: hand each group its descendant events\n if (viewConfig.getSummaryProgress) {\n const byResource = new Map()\n for (const ev of allEvents) {\n if (!ev.resourceId) continue\n const list = byResource.get(ev.resourceId)\n if (list) list.push(ev)\n else byResource.set(ev.resourceId, [ev])\n }\n const map = new Map()\n for (const row of rows) {\n if (!row.isGroup) continue\n const events: GanttEvent[] = []\n for (const id of descendantIds.get(row.resource.id) ?? []) {\n const list = byResource.get(id)\n if (list) events.push(...list)\n }\n map.set(\n row.resource.id,\n viewConfig.getSummaryProgress({ resource: row.resource, events })\n )\n }\n return map\n }\n // default: one pass over events -> per-resource aggregates, then a cheap\n // descendant sum per group; never O(rows x events)\n const perResource = new Map<\n string,\n { weighted: number; total: number; saw: boolean }\n >()\n for (const ev of allEvents) {\n if (!ev.resourceId) continue\n let agg = perResource.get(ev.resourceId)\n if (!agg) {\n agg = { weighted: 0, total: 0, saw: false }\n perResource.set(ev.resourceId, agg)\n }\n const dur = Math.max(ev.end.getTime() - ev.start.getTime(), 1)\n agg.total += dur\n if (typeof ev.progress === \"number\") {\n agg.saw = true\n agg.weighted += ev.progress * dur\n }\n }\n const map = new Map()\n for (const row of rows) {\n if (!row.isGroup) continue\n let weighted = 0\n let weightTotal = 0\n let saw = false\n for (const id of descendantIds.get(row.resource.id) ?? []) {\n const agg = perResource.get(id)\n if (!agg) continue\n weightTotal += agg.total\n if (agg.saw) {\n saw = true\n weighted += agg.weighted\n }\n }\n map.set(\n row.resource.id,\n saw && weightTotal > 0\n ? Math.min(Math.max(Math.round(weighted / weightTotal), 0), 100)\n : null\n )\n }\n return map\n }, [rows, allEvents, descendantIds, viewConfig.getSummaryProgress])\n\n // Lane memory across layout passes, keyed by getLaneKey (event identity,\n // NOT the time-stamped occurrence key). It stores the TIMES alongside the\n // lane so the packer can tell the schedule the user just edited apart from\n // the ones that sat still: untouched schedules keep their lane, the edited\n // one re-seeks. Written during the memo below on purpose: the pass is\n // idempotent - feeding its own output back in produces the same assignment -\n // so a StrictMode double render is a no-op.\n const laneMemory = useRef(new Map())\n const scheduleMode = viewConfig.scheduleMode\n\n // Per-row packed bars, hoisted so the tree and timeline rows share heights\n // A drag-create in flight. Reduced to the three fields the layout needs, and\n // compared by value, so this re-runs only when the SNAPPED range moves - the\n // gesture engine already gates setSlotDraft on exactly that, so it is a\n // handful of recomputes per drag rather than one per frame.\n const draftLayout = useGanttSelector<\n unknown,\n { resourceId: string; startMs: number; endMs: number } | null\n >(\n (state) => {\n const slotDraft = state.slotDraft\n if (!slotDraft?.resourceId) return null\n return {\n resourceId: slotDraft.resourceId,\n startMs: slotDraft.start.getTime(),\n endMs: slotDraft.end.getTime(),\n }\n },\n {\n isEqual: (a, b) =>\n a === b ||\n (a !== null &&\n b !== null &&\n a.resourceId === b.resourceId &&\n a.startMs === b.startMs &&\n a.endMs === b.endMs),\n }\n )\n\n const baseRowBars = useMemo(() => {\n const map = new Map()\n // read the lanes the previous pass settled on, write the ones this pass\n // settles on; rebuilding (not mutating) prunes schedules that are gone\n const previousLanes = laneMemory.current\n const nextLanes = new Map()\n const totalMin = (rangeEndMs - rangeStartMs) / 60000\n // one pass: occurrences grouped by resource, plus per-resource envelopes\n // for the parent rollups (never O(rows x occurrences))\n const byResource = new Map()\n const envelopes = new Map()\n for (const occ of occurrences) {\n const rid = occ.event.resourceId\n if (!rid) continue\n const list = byResource.get(rid)\n if (list) list.push(occ)\n else byResource.set(rid, [occ])\n const fromMin = Math.max((occ.start.getTime() - rangeStartMs) / 60000, 0)\n const toMin = Math.min(\n (occ.end.getTime() - rangeStartMs) / 60000,\n totalMin\n )\n const env = envelopes.get(rid)\n if (!env) {\n envelopes.set(rid, { fromMin, toMin })\n } else {\n env.fromMin = Math.min(env.fromMin, fromMin)\n env.toMin = Math.max(env.toMin, toMin)\n }\n }\n for (const row of rows) {\n const mine = byResource.get(row.resource.id) ?? []\n const segments: GanttSegment[] = mine.map((occurrence) => ({\n occurrence,\n day: new Date(rangeStartMs),\n isStart: occurrence.start.getTime() >= rangeStartMs,\n isEnd: occurrence.end.getTime() <= rangeEndMs,\n continuesBefore: occurrence.start.getTime() < rangeStartMs,\n continuesAfter: occurrence.end.getTime() > rangeEndMs,\n startMin: Math.max(\n (occurrence.start.getTime() - rangeStartMs) / 60000,\n 0\n ),\n endMin: Math.min(\n (occurrence.end.getTime() - rangeStartMs) / 60000,\n totalMin\n ),\n }))\n const mode = resolveScheduleMode(row.resource, scheduleMode)\n packTimedSegments(segments, { mode, preferredLanes: previousLanes })\n for (const segment of segments) {\n nextLanes.set(getLaneKey(segment.occurrence), {\n lane: segment.column ?? 0,\n startMs: segment.occurrence.start.getTime(),\n endMs: segment.occurrence.end.getTime(),\n })\n }\n const laneCount = segments.reduce(\n (max, segment) => Math.max(max, (segment.column ?? 0) + 1),\n 1\n )\n let from = Infinity\n let to = -Infinity\n for (const segment of segments) {\n from = Math.min(from, (segment.startMin ?? 0) / totalMin)\n to = Math.max(to, (segment.endMin ?? 0) / totalMin)\n }\n\n // Parent rollup from the subtree's bars: envelope clamped to the range,\n // progress weighted by each bar's full duration\n let summary: TimelineRowBars[\"summary\"] = null\n if (row.isGroup && segments.length === 0 && viewConfig.summaryBars) {\n let sumFrom = Infinity\n let sumTo = -Infinity\n for (const id of descendantIds.get(row.resource.id) ?? []) {\n const env = envelopes.get(id)\n if (!env) continue\n sumFrom = Math.min(sumFrom, env.fromMin / totalMin)\n sumTo = Math.max(sumTo, env.toMin / totalMin)\n }\n if (sumTo > sumFrom) {\n summary = {\n from: sumFrom,\n to: sumTo,\n // all-time completion (matches a consumer's tree rollup), not the\n // visible-range slice - task progress is independent of scroll\n progress: subtreeProgress.get(row.resource.id) ?? null,\n }\n }\n }\n\n // The stack, then the row's own padding around it. Centering the block\n // in the resulting height gives an equal inset top and bottom, and it\n // is also what keeps a lone bar on the tree label's centerline when a\n // short row is held open by minRowHeight.\n const blockRem = laneCount * laneHeightRem + (laneCount - 1) * laneGapRem\n const heightRem = Math.max(minRowRem, blockRem + 2 * rowPaddingRem)\n const laneOffsetRem = (heightRem - blockRem) / 2\n\n map.set(row.resource.id, {\n segments,\n laneCount,\n draftLane: null,\n scheduleMode: mode,\n heightRem,\n laneOffsetRem,\n bandRem: laneHeightRem + laneOffsetRem * 2,\n extent:\n segments.length > 0\n ? {\n from,\n to,\n color: segments[0].occurrence.event.color,\n label:\n segments.length === 1\n ? segments[0].occurrence.event.title\n : settings.i18n.labels.events(segments.length),\n startMs: Math.min(\n ...segments.map((s) => s.occurrence.start.getTime())\n ),\n }\n : summary\n ? {\n from: summary.from,\n to: summary.to,\n label: row.resource.title,\n startMs:\n rangeStartMs + summary.from * (rangeEndMs - rangeStartMs),\n }\n : null,\n summary,\n })\n }\n laneMemory.current = nextLanes\n return map\n }, [\n rows,\n occurrences,\n rangeStartMs,\n rangeEndMs,\n settings.i18n,\n viewConfig.summaryBars,\n descendantIds,\n subtreeProgress,\n laneHeightRem,\n rowPaddingRem,\n laneGapRem,\n minRowRem,\n scheduleMode,\n ])\n\n // ----- split panes: width state, splitter drag/keyboard, scroll sync -----\n const treeConfig = { ...DEFAULT_TREE_PANEL, ...viewConfig.treePanel }\n const clampTree = (width: number) =>\n Math.min(Math.max(width, treeConfig.minWidth), treeConfig.maxWidth)\n const [treeWidth, setTreeWidth] = useState(treeConfig.width)\n const configuredTreeWidth = clampTree(treeWidth)\n const columns = viewConfig.columns ?? []\n\n // \"Add task\" hint at the foot of the tree, gated by validation\n /**\n * Reserve the track a drag-create in flight will land on - as a THIN overlay\n * over the layout above, never as an input to it. Rebuilding the whole map\n * per snapped step would hand every row a new `bars` object and defeat\n * GanttTimelineRow's memo across the entire grid, so only the drafted row's\n * entry is replaced; every other row keeps its identity and never re-renders.\n *\n * `laneOffsetRem` and `bandRem` are deliberately carried over UNCHANGED.\n * Deriving them from the grown track count re-centres the row, which drags\n * the settled bars and the tree label box with it - a 2px wobble by default\n * and, under a consumer `metrics.minRowHeight` big enough to swallow the\n * growth, an 11.5px jerk that snaps back on release. Growing a row must add\n * space BELOW what is already there and move nothing.\n */\n const rowBars = useMemo(() => {\n if (!draftLayout) return baseRowBars\n const base = baseRowBars.get(draftLayout.resourceId)\n if (!base) return baseRowBars\n const next = new Map(baseRowBars)\n // \"single\" packs every bar onto lane 0, so that is where the draft goes\n // too and the row never grows. Recorded rather than left null so the\n // placeholder's data-lane still names a real destination.\n if (base.scheduleMode === \"single\") {\n next.set(draftLayout.resourceId, { ...base, draftLane: 0 })\n return next\n }\n const draftLane = lowestFreeLane(\n base.segments,\n draftLayout.startMs,\n draftLayout.endMs\n )\n const trackCount = Math.max(base.laneCount, draftLane + 1)\n const draftBlockRem =\n trackCount * laneHeightRem + (trackCount - 1) * laneGapRem\n next.set(draftLayout.resourceId, {\n ...base,\n draftLane,\n heightRem: Math.max(\n base.heightRem,\n base.laneOffsetRem * 2 + draftBlockRem\n ),\n })\n return next\n }, [baseRowBars, draftLayout, laneHeightRem, laneGapRem])\n\n const showCreateTask =\n viewConfig.displayCreateTaskHint &&\n !!settings.onCreateTask &&\n (settings.canCreateTask?.({ parentId: null }) ?? true)\n\n // Responsive guard: the timeline must always keep a usable width, so on\n // narrow containers the tree pane yields down toward its minWidth. Measured\n // (not media-queried) because the gantt can live in any column. The -1\n // reserves the splitter hairline so the timeline truly keeps MIN width.\n const [containerWidth, setContainerWidth] = useState(0)\n const clampContainer = (width: number, container: number) => {\n if (container <= 0) return width\n const ceiling = container - minTimelineWidth - 1\n // The tree's own minWidth is a PREFERENCE, not a licence to squeeze the\n // timeline out of existence: cap it by what the container can actually\n // spare. Without this cap a consumer minWidth wider than the container\n // wins outright and the timeline collapses below minTimelineWidth with\n // the splitter already pinned, so the space cannot be dragged back.\n const floor = Math.min(treeConfig.minWidth, Math.max(ceiling, 0))\n return Math.max(Math.min(width, ceiling), Math.min(floor, container - 1))\n }\n const clampedTreeWidth = clampContainer(configuredTreeWidth, containerWidth)\n /** Live width while the splitter is dragging; render reads it so a\n mid-drag re-render can't snap the pane back to stale state. */\n const liveTreeWidthRef = useRef(null)\n\n // In-flight gestures must never outlive the view (leaked window listeners,\n // body overlays and the drag cursor), and must never keep running against\n // geometry they measured before it changed - a gesture snapshots the axis\n // and row rects once at activation, so any of these invalidates it.\n // Cancel-and-revert is the safe contract; all of this is a no-op in normal\n // flows (none of these values can change during an ordinary pointer drag).\n useGanttGestureTeardown()\n useEffect(() => {\n cancelActiveGanttGestures()\n }, [zoom, scale, rangeKey, clampedTreeWidth, rows.length])\n\n // Leaf-row checkbox selection: uncontrolled unless selectedRows is passed\n const [internalSelected, setInternalSelected] = useState([])\n const selectedRows = viewConfig.selectedRows ?? internalSelected\n const selectedSet = useMemo(() => new Set(selectedRows), [selectedRows])\n // Latest-value refs so the row handlers keep ONE identity across renders -\n // the row components are memoized and must not re-render per state change\n const viewConfigRef = useRef(viewConfig)\n viewConfigRef.current = viewConfig\n const selectedRowsRef = useRef(selectedRows)\n selectedRowsRef.current = selectedRows\n const toggleRowSelected = useCallback((id: string, checked: boolean) => {\n const current = selectedRowsRef.current\n const next = checked\n ? [...current.filter((rowId) => rowId !== id), id]\n : current.filter((rowId) => rowId !== id)\n if (viewConfigRef.current.selectedRows === undefined) {\n setInternalSelected(next)\n }\n viewConfigRef.current.onSelectedRowsChange?.(next)\n }, [])\n\n const bodyRef = useRef(null)\n const treePaneRef = useRef(null)\n const timelinePaneRef = useRef(null)\n const treeRowsRef = useRef(null)\n\n // Track the body width so the tree pane can yield on narrow containers.\n // Layout effect, not effect: the first measure must flush BEFORE the first\n // paint so a clamped tree width never paints wide for a frame and then\n // snaps - the panes are laid out final from the very first displayed frame.\n useLayoutEffect(() => {\n const body = bodyRef.current\n if (!body) return\n const update = () => setContainerWidth(body.clientWidth)\n update()\n const observer = new ResizeObserver(update)\n observer.observe(body)\n return () => observer.disconnect()\n }, [])\n\n const beginSplit = (e: React.PointerEvent) => {\n if (e.button !== 0) return\n e.preventDefault()\n const pointerId = e.pointerId\n const startX = e.clientX\n const startWidth = clampedTreeWidth\n const splitter = e.currentTarget as HTMLElement\n // in RTL the tree pane sits on the right: pointer deltas invert\n const dir = getComputedStyle(splitter).direction === \"rtl\" ? -1 : 1\n splitter.setAttribute(\"data-resizing\", \"\")\n document.body.style.cursor = \"col-resize\"\n document.body.style.userSelect = \"none\"\n // Live width goes straight to the DOM: a React state write here would\n // re-render every row and bar per pointermove. State commits on release.\n // The container clamp applies live too - the timeline must not collapse\n // below its minimum mid-drag only to snap back on release.\n let liveWidth = startWidth\n const onMove = (ev: PointerEvent) => {\n if (ev.pointerId !== pointerId) return\n liveWidth = clampContainer(\n clampTree(startWidth + (ev.clientX - startX) * dir),\n bodyRef.current?.clientWidth ?? 0\n )\n liveTreeWidthRef.current = liveWidth\n if (treePaneRef.current) {\n treePaneRef.current.style.width = `${liveWidth}px`\n }\n }\n const finish = (ev?: PointerEvent) => {\n if (ev && ev.pointerId !== pointerId) return\n window.removeEventListener(\"pointermove\", onMove)\n window.removeEventListener(\"pointerup\", finish)\n window.removeEventListener(\"pointercancel\", finish)\n splitter.removeAttribute(\"data-resizing\")\n document.body.style.cursor = \"\"\n document.body.style.userSelect = \"\"\n liveTreeWidthRef.current = null\n if (liveWidth !== startWidth) {\n setTreeWidth(liveWidth)\n viewConfigRef.current.treePanel?.onWidthChange?.(liveWidth)\n }\n }\n window.addEventListener(\"pointermove\", onMove)\n window.addEventListener(\"pointerup\", finish)\n window.addEventListener(\"pointercancel\", finish)\n }\n\n // Both panes scroll vertically; whichever moves drives the other.\n useEffect(() => {\n const treeViewport = getPaneViewport(treePaneRef.current)\n const timelineViewport = getPaneViewport(timelinePaneRef.current)\n if (!treeViewport || !timelineViewport) return\n const link = (source: HTMLElement, target: HTMLElement) => {\n // Mirror only when the source's own vertical position changed -\n // horizontal-only scroll events must not replay a stale scrollTop over\n // the other pane. Assign only on drift: the mirrored handler then\n // no-ops, so no loop.\n let lastTop = source.scrollTop\n const onScroll = () => {\n if (source.scrollTop === lastTop) return\n lastTop = source.scrollTop\n if (target.scrollTop !== source.scrollTop) {\n target.scrollTop = source.scrollTop\n }\n }\n source.addEventListener(\"scroll\", onScroll)\n return () => source.removeEventListener(\"scroll\", onScroll)\n }\n const unlinkTree = link(treeViewport, timelineViewport)\n const unlinkTimeline = link(timelineViewport, treeViewport)\n let unforward: (() => void) | null = null\n if (treeViewport.hasAttribute(\"data-gantt-native-scroll\")) {\n // Native mode: the tree's vertical axis is overflow-hidden (its bar\n // would duplicate the timeline's), so vertical wheel intent forwards to\n // the timeline, which mirrors back through the link above. Horizontal\n // wheel intent stays native for the tree's own columns.\n const onWheel = (e: WheelEvent) => {\n // ctrl/cmd (and trackpad pinch, which sets ctrlKey) is the zoom\n // gesture; scrolling as well would move the rows out from under it\n if (e.ctrlKey || e.metaKey) return\n if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return\n const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY\n timelineViewport.scrollTop += dy\n e.preventDefault()\n }\n treeViewport.addEventListener(\"wheel\", onWheel, { passive: false })\n unforward = () => treeViewport.removeEventListener(\"wheel\", onWheel)\n } else {\n // Custom scrollbars: both panes are real vertical scrollers. The links\n // above mirror on the scroll event, which fires only AFTER the source\n // has already painted - so with compositor momentum (wheel/trackpad) the\n // active pane runs a frame ahead of the mirror and the two visibly drift\n // (the flicker). Fix: drive BOTH viewports from one wheel handler so they\n // move in the same frame, perfectly locked. Horizontal intent stays\n // native for each pane's own axis; the links still cover scrollbar drags,\n // keyboard, touch and programmatic scrolls; touch has no wheel events,\n // so flick-scrolling syncs through the (frame-lagged) link - accepted,\n // pointer drags are the gantt's primary touch interaction.\n const onWheel = (e: WheelEvent) => {\n // ctrl/cmd (and trackpad pinch, which sets ctrlKey) is the zoom\n // gesture; scrolling as well would move the rows out from under it\n if (e.ctrlKey || e.metaKey) return\n if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return\n const max =\n timelineViewport.scrollHeight - timelineViewport.clientHeight\n if (max <= 0) return\n const unit =\n e.deltaMode === 1\n ? 16\n : e.deltaMode === 2\n ? timelineViewport.clientHeight\n : 1\n const next = Math.max(\n 0,\n Math.min(max, timelineViewport.scrollTop + e.deltaY * unit)\n )\n e.preventDefault()\n timelineViewport.scrollTop = next\n treeViewport.scrollTop = next\n }\n treeViewport.addEventListener(\"wheel\", onWheel, { passive: false })\n timelineViewport.addEventListener(\"wheel\", onWheel, { passive: false })\n unforward = () => {\n treeViewport.removeEventListener(\"wheel\", onWheel)\n timelineViewport.removeEventListener(\"wheel\", onWheel)\n }\n }\n return () => {\n unlinkTree()\n unlinkTimeline()\n unforward?.()\n }\n }, [scale, viewConfig.scrollbars])\n\n // Linked row hover: mirror data-hover onto the row's twin in the other pane\n useEffect(() => {\n const body = bodyRef.current\n if (!body) return\n let current: string | null = null\n const apply = (id: string | null) => {\n if (id === current) return\n if (current) {\n body\n .querySelectorAll(`[data-gantt-row-id=\"${CSS.escape(current)}\"]`)\n .forEach((el) => el.removeAttribute(\"data-hover\"))\n }\n if (id) {\n body\n .querySelectorAll(`[data-gantt-row-id=\"${CSS.escape(id)}\"]`)\n .forEach((el) => el.setAttribute(\"data-hover\", \"\"))\n }\n current = id\n }\n const onOver = (e: PointerEvent) => {\n const row = (e.target as HTMLElement | null)?.closest?.(\n \"[data-gantt-row-id]\"\n )\n apply(row?.getAttribute(\"data-gantt-row-id\") ?? null)\n }\n const onLeave = () => apply(null)\n body.addEventListener(\"pointerover\", onOver)\n body.addEventListener(\"pointerleave\", onLeave)\n return () => {\n body.removeEventListener(\"pointerover\", onOver)\n body.removeEventListener(\"pointerleave\", onLeave)\n apply(null)\n }\n }, [])\n\n // Auto-manage the horizontal position for the current anchor until the user\n // scrolls: pre-buffer one period per side (so infinite scroll never resizes\n // the scrollbar on the first gesture), then center the target instant.\n // Idempotent - re-running just re-centers the same instant - so React\n // StrictMode's double-invoke and range-growth re-renders are both safe.\n const anchorMs = useGanttSelector((state) => state.date.getTime())\n // flattened to a primitive so an inline `initialCenter={new Date(...)}`\n // cannot re-run the centring effect on every render\n const initialCenter =\n viewConfig.initialCenter instanceof Date\n ? viewConfig.initialCenter.getTime()\n : viewConfig.initialCenter\n const manageRef = useRef({ key: \"\", buffered: false, userTook: false })\n useLayoutEffect(() => {\n const key = `${scale}:${anchorMs}:${viewConfig.scrollbars}`\n if (manageRef.current.key !== key) {\n // An anchor change from an extendRange window SLIDE continues the\n // user's own travel: the guard survives, or the pre-buffer branch\n // would re-extend and cascade further slides at the window cap.\n const slideContinuation =\n manageRef.current.userTook && instance.internals.didAnchorSlide()\n manageRef.current = slideContinuation\n ? { key, buffered: manageRef.current.buffered, userTook: true }\n : { key, buffered: false, userTook: false }\n }\n if (manageRef.current.userTook) return\n // Deferred-mount wait: when the viewport is not measurable yet (hidden\n // tab, display:none ancestor), a ResizeObserver resumes positioning on\n // the exact frame it gains a size - RO callbacks run in the rendering\n // steps BEFORE that frame paints, so no uncentered frame is ever shown.\n // (An rAF retry here would paint the range start first and then snap.)\n let waiter: ResizeObserver | null = null\n const run = () => {\n waiter?.disconnect()\n waiter = null\n const viewport = getPaneViewport(timelinePaneRef.current)\n const axis = viewport?.querySelector(\"[data-gantt-axis]\")\n if (!viewport || !axis) return\n if (viewport.clientWidth === 0) {\n waiter = new ResizeObserver(() => {\n if (viewport.clientWidth > 0) run()\n })\n waiter.observe(viewport)\n return\n }\n // pre-buffer once; the re-run after the range grows lands the center\n if (viewConfig.infiniteScroll && !manageRef.current.buffered) {\n manageRef.current.buffered = true\n extendLockRef.current = true\n instance.internals.extendRange(\"before\")\n instance.internals.extendRange(\"after\")\n return\n }\n extendLockRef.current = false\n if (viewport.scrollWidth <= viewport.clientWidth) return\n // read the clock at run time - the effect must not depend on a\n // reactive now that re-runs it (and the whole grid) every 30s.\n // Target now ONLY when the anchor period itself contains it: keying\n // on the whole (buffered) visible range would re-center prev/next\n // navigation right back onto today.\n const active = instance.getState().activeRange\n let target: number\n if (typeof initialCenter === \"number\") {\n target = initialCenter\n } else if (initialCenter === \"anchor\") {\n target = anchorMs\n } else {\n const nowMs = Date.now()\n target =\n nowMs >= active.start.getTime() && nowMs < active.end.getTime()\n ? nowMs\n : anchorMs\n }\n const fraction = Math.min(\n Math.max((target - rangeStartMs) / (rangeEndMs - rangeStartMs), 0),\n 1\n )\n setScrollStart(\n viewport,\n Math.max(0, fraction * viewport.scrollWidth - viewport.clientWidth / 2)\n )\n }\n run()\n return () => {\n waiter?.disconnect()\n }\n }, [\n scale,\n anchorMs,\n initialCenter,\n viewConfig.scrollbars,\n viewConfig.infiniteScroll,\n rangeKey,\n rangeStartMs,\n rangeEndMs,\n instance,\n ])\n\n // ----- infinite scroll: grow the range near an edge, keep the position -----\n // Restoration is anchored to a TIMESTAMP, not pixel deltas: it survives\n // growth, window slides, and zoom changes alike.\n const pendingRestoreRef = useRef<{\n ms: number\n align: \"start\" | \"center\"\n /**\n * Park the anchored instant this many pixels from the viewport's inline\n * start instead of at the edge or the middle. Wheel zoom needs it so the\n * instant under the cursor stays under the cursor.\n */\n offsetPx?: number\n } | null>(null)\n const extendLockRef = useRef(false)\n const lastUserScrollRef = useRef(0)\n /** Fine-grained viewport-center instant, for controlled-zoom anchoring. */\n const fineCenterRef = useRef(null)\n const lastZoomRef = useRef(null)\n\n // Only user gestures may extend the range - programmatic scrolls (chip\n // jumps, auto-center, zoom clamping) must never grow it.\n useEffect(() => {\n const pane = timelinePaneRef.current\n if (!pane) return\n const markIntent = () => {\n lastUserScrollRef.current = performance.now()\n }\n // zooming is not scroll intent - the re-seat it triggers must not be\n // mistaken for the user reaching an edge and asking to grow the range\n const markWheelIntent = (e: WheelEvent) => {\n if (e.ctrlKey || e.metaKey) return\n markIntent()\n }\n // pointerdown counts only where pressing can scroll: the scrollbars,\n // the pan header, or a native-scroll host - NOT bars, chips, or zoom\n const onPointerDown = (e: PointerEvent) => {\n const target = e.target as HTMLElement | null\n if (\n target?.closest(\n \"[data-slot=scroll-area-scrollbar], [data-slot=gantt-timeline-header], [data-gantt-native-scroll]\"\n )\n ) {\n markIntent()\n }\n }\n pane.addEventListener(\"wheel\", markWheelIntent, { passive: true })\n pane.addEventListener(\"pointerdown\", onPointerDown)\n pane.addEventListener(\"touchstart\", markIntent, { passive: true })\n pane.addEventListener(\"keydown\", markIntent)\n return () => {\n pane.removeEventListener(\"wheel\", markWheelIntent)\n pane.removeEventListener(\"pointerdown\", onPointerDown)\n pane.removeEventListener(\"touchstart\", markIntent)\n pane.removeEventListener(\"keydown\", markIntent)\n }\n }, [])\n\n useEffect(() => {\n if (!viewConfig.infiniteScroll) return\n const viewport = getPaneViewport(timelinePaneRef.current)\n if (!viewport) return\n const tryExtend = (direction: \"before\" | \"after\") => {\n // anchor the left edge as an instant, from the LIVE axis range\n const axis = viewport.querySelector(\"[data-gantt-axis]\")\n const liveStart = Number(axis?.dataset.ganttRangeStart)\n const liveEnd = Number(axis?.dataset.ganttRangeEnd)\n if (!axis || Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return\n extendLockRef.current = true\n manageRef.current.userTook = true\n pendingRestoreRef.current = {\n ms:\n liveStart +\n (getScrollStart(viewport) / viewport.scrollWidth) *\n (liveEnd - liveStart),\n align: \"start\",\n }\n if (!instance.internals.extendRange(direction)) {\n pendingRestoreRef.current = null\n extendLockRef.current = false\n }\n }\n // scrollLeft is signed by direction; all edge math runs on the\n // distance-from-inline-start so RTL panes behave identically\n const isRtl = getComputedStyle(viewport).direction === \"rtl\"\n const onScroll = () => {\n if (extendLockRef.current) return\n if (performance.now() - lastUserScrollRef.current > 1200) return\n // a track that fits the pane has no scroll gesture to extend from\n if (viewport.scrollWidth <= viewport.clientWidth + 8) return\n const fromStart = getScrollStart(viewport)\n const fromEnd = viewport.scrollWidth - fromStart - viewport.clientWidth\n const direction =\n fromStart < infiniteEdgePx\n ? (\"before\" as const)\n : fromEnd < infiniteEdgePx\n ? (\"after\" as const)\n : null\n if (!direction) return\n tryExtend(direction)\n }\n // parked exactly on an edge, further wheeling emits no scroll event -\n // the wheel itself is the growth gesture then\n const onWheel = (e: WheelEvent) => {\n // a zoom gesture must never grow the range: the track is rescaling\n // under the pointer, so an edge reading mid-gesture is meaningless\n if (e.ctrlKey || e.metaKey) return\n if (extendLockRef.current || e.deltaX === 0) return\n if (viewport.scrollWidth <= viewport.clientWidth + 8) return\n const towardStart = isRtl ? e.deltaX > 0 : e.deltaX < 0\n if (towardStart && getScrollStart(viewport) <= 0) {\n tryExtend(\"before\")\n } else if (\n !towardStart &&\n getScrollStart(viewport) + viewport.clientWidth >=\n viewport.scrollWidth - 1\n ) {\n tryExtend(\"after\")\n }\n }\n viewport.addEventListener(\"scroll\", onScroll)\n viewport.addEventListener(\"wheel\", onWheel, { passive: true })\n return () => {\n viewport.removeEventListener(\"scroll\", onScroll)\n viewport.removeEventListener(\"wheel\", onWheel)\n }\n }, [\n instance,\n viewConfig.infiniteScroll,\n scale,\n viewConfig.scrollbars,\n infiniteEdgePx,\n ])\n\n // Report the visible-center instant so the nav title names what you are\n // looking at. Throttled to period boundaries (a coarse key) so scrolling\n // within a period never re-renders the grid.\n useEffect(() => {\n const viewport = getPaneViewport(timelinePaneRef.current)\n if (!viewport) return\n const keyFmt =\n scale === \"day\"\n ? \"yyyy-MM-dd\"\n : scale === \"week\"\n ? \"RRRR-'W'II\"\n : scale === \"month\"\n ? \"yyyy-MM\"\n : scale === \"quarter\"\n ? \"yyyy-qqq\"\n : \"yyyy\"\n let raf = 0\n let lastKey = \"\"\n const measure = () => {\n raf = 0\n const axis = viewport.querySelector(\"[data-gantt-axis]\")\n const liveStart = Number(axis?.dataset.ganttRangeStart)\n const liveEnd = Number(axis?.dataset.ganttRangeEnd)\n if (!axis || Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return\n const fraction =\n (getScrollStart(viewport) + viewport.clientWidth / 2) /\n Math.max(1, viewport.scrollWidth)\n const centerMs = liveStart + fraction * (liveEnd - liveStart)\n // fine center first (controlled-zoom anchor), then the coarse-keyed\n // store report that drives the nav title\n fineCenterRef.current = centerMs\n const center = new Date(centerMs)\n const key = format(toZoned(center, timeZone), keyFmt)\n if (key === lastKey) return\n lastKey = key\n instance.internals.setViewportCenter(center)\n }\n const schedule = () => {\n if (!raf) raf = requestAnimationFrame(measure)\n }\n viewport.addEventListener(\"scroll\", schedule)\n schedule()\n return () => {\n viewport.removeEventListener(\"scroll\", schedule)\n if (raf) cancelAnimationFrame(raf)\n }\n }, [instance, scale, timeZone, viewConfig.scrollbars, rangeKey])\n\n // Re-seat the viewport on its anchored instant before paint. Runs for range\n // growth, window slides, and zoom changes; a slide moves the anchor date,\n // so pre-mark auto-centering as done for the new key.\n useLayoutEffect(() => {\n // Consumer-driven (controlled) zoom changes carry no anchorZoomCenter\n // call; anchor them to the last known viewport center so the view does\n // not drift. Built-in buttons set pendingRestore first and win.\n if (\n lastZoomRef.current !== null &&\n lastZoomRef.current !== zoom &&\n !pendingRestoreRef.current &&\n fineCenterRef.current !== null\n ) {\n pendingRestoreRef.current = { ms: fineCenterRef.current, align: \"center\" }\n }\n lastZoomRef.current = zoom\n let raf: number | null = null\n let attempts = 0\n const seat = () => {\n raf = null\n const viewport = getPaneViewport(timelinePaneRef.current)\n if (viewport && pendingRestoreRef.current) {\n // Not laid out yet (0-width on first mount): centering with a 0 offset\n // parks the view a half-pane off. Defer until the pane is measured so\n // \"center\" lands the anchored instant in the middle on initial load.\n if (viewport.clientWidth === 0 && attempts++ < 20) {\n raf = requestAnimationFrame(seat)\n return\n }\n const { ms, align, offsetPx } = pendingRestoreRef.current\n pendingRestoreRef.current = null\n // clamp: a stale anchor (e.g. an ignored controlled-zoom proposal)\n // must never park the view outside the track\n const fraction = Math.min(\n Math.max((ms - rangeStartMs) / (rangeEndMs - rangeStartMs), 0),\n 1\n )\n const offset =\n offsetPx ?? (align === \"center\" ? viewport.clientWidth / 2 : 0)\n setScrollStart(\n viewport,\n Math.max(0, fraction * viewport.scrollWidth - offset)\n )\n }\n extendLockRef.current = false\n }\n seat()\n return () => {\n if (raf !== null) cancelAnimationFrame(raf)\n }\n }, [\n rangeKey,\n zoom,\n rangeStartMs,\n rangeEndMs,\n scale,\n viewConfig.scrollbars,\n instance,\n ])\n\n /** Keep the view centered on the same instant across a zoom step. */\n const anchorZoomCenter = () => {\n const viewport = getPaneViewport(timelinePaneRef.current)\n const axis = viewport?.querySelector(\"[data-gantt-axis]\")\n if (!viewport || !axis) return\n const liveStart = Number(axis.dataset.ganttRangeStart)\n const liveEnd = Number(axis.dataset.ganttRangeEnd)\n if (Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return\n pendingRestoreRef.current = {\n ms:\n liveStart +\n ((getScrollStart(viewport) + viewport.clientWidth / 2) /\n viewport.scrollWidth) *\n (liveEnd - liveStart),\n align: \"center\",\n }\n }\n\n /**\n * Keep the instant under the POINTER pinned across a zoom step. The buttons\n * anchor the viewport center, but a wheel or pinch gesture points at\n * something - zooming away from it reads as the content sliding out from\n * under the cursor.\n */\n const anchorZoomPointer = (clientX: number) => {\n const viewport = getPaneViewport(timelinePaneRef.current)\n const axis = viewport?.querySelector(\"[data-gantt-axis]\")\n if (!viewport || !axis) return\n const liveStart = Number(axis.dataset.ganttRangeStart)\n const liveEnd = Number(axis.dataset.ganttRangeEnd)\n if (Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return\n // trackPoint mirrors in RTL, where the range start is the right edge\n const offsetPx = trackPoint(viewport, clientX).offset\n pendingRestoreRef.current = {\n ms:\n liveStart +\n ((getScrollStart(viewport) + offsetPx) / viewport.scrollWidth) *\n (liveEnd - liveStart),\n align: \"start\",\n offsetPx,\n }\n }\n\n // ----- ctrl/cmd + wheel (and trackpad pinch) zooms the time range -----\n // The listener is manual and non-passive because it must preventDefault:\n // React's synthetic wheel handler cannot. It attaches once and reads the\n // live logic through a ref, so a zoom step never re-binds mid-gesture.\n const wheelZoomRef = useRef<((e: WheelEvent) => void) | null>(null)\n useEffect(() => {\n wheelZoomRef.current = (e: WheelEvent) => {\n if (!viewConfig.wheelZoom) return\n // Browsers deliver a trackpad pinch as wheel + ctrlKey on every\n // platform; metaKey is the Mac keyboard idiom the same gesture implies.\n if (!e.ctrlKey && !e.metaKey) return\n const lines = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1\n // Continuous, not stepped: a pinch emits dozens of small deltas per\n // second, so the button's 0.25 step would slam to a limit instantly.\n // Exponential keeps each notch proportional at any zoom level.\n const next = clampZoom(zoom * Math.exp(-e.deltaY * lines * 0.002))\n // Already clamped: hand the gesture back so the browser's own page\n // zoom still works for anyone who relies on it.\n if (Math.abs(next - zoom) < 1e-4) return\n e.preventDefault()\n // Controlled zoom anchors via fineCenterRef when the parent adopts;\n // pre-setting an anchor would leak stale if the parent ignores it.\n if (viewConfig.zoom === undefined) anchorZoomPointer(e.clientX)\n setZoomValue(+next.toFixed(4))\n }\n })\n useEffect(() => {\n const viewport = getPaneViewport(timelinePaneRef.current)\n if (!viewport) return\n const onWheel = (e: WheelEvent) => wheelZoomRef.current?.(e)\n viewport.addEventListener(\"wheel\", onWheel, { passive: false })\n return () => viewport.removeEventListener(\"wheel\", onWheel)\n }, [viewConfig.scrollbars, scale])\n\n // Drag-to-pan from the header (intent-based: activates after 4px)\n const beginHeaderPan = (e: React.PointerEvent) => {\n if (e.button !== 0) return\n const viewport = getPaneViewport(timelinePaneRef.current)\n if (!viewport) return\n const pointerId = e.pointerId\n const startX = e.clientX\n const startLeft = viewport.scrollLeft\n let active = false\n const onMove = (ev: PointerEvent) => {\n if (ev.pointerId !== pointerId) return\n const dx = ev.clientX - startX\n if (!active && Math.abs(dx) < 4) return\n if (!active) {\n markGestureEnd()\n setIsPanning(true)\n }\n active = true\n // panning is a user scroll: keep the infinite-scroll gate open\n lastUserScrollRef.current = performance.now()\n document.body.style.cursor = \"grabbing\"\n document.body.style.userSelect = \"none\"\n viewport.scrollLeft = startLeft - dx\n }\n const finish = (ev?: PointerEvent) => {\n if (ev && ev.pointerId !== pointerId) return\n window.removeEventListener(\"pointermove\", onMove)\n window.removeEventListener(\"pointerup\", finish)\n window.removeEventListener(\"pointercancel\", finish)\n document.body.style.cursor = \"\"\n document.body.style.userSelect = \"\"\n if (active) setIsPanning(false)\n }\n window.addEventListener(\"pointermove\", onMove)\n window.addEventListener(\"pointerup\", finish)\n window.addEventListener(\"pointercancel\", finish)\n }\n\n // Panning suppresses the placement hints (scroll intent, not create intent)\n const [isPanning, setIsPanning] = useState(false)\n\n // ----- tree-row drag reorder (mirrors the event engine: live validity,\n // destructive styling when invalid, Esc cancel, commit via callback) -----\n const [reorder, setReorder] = useState(null)\n const reorderEnabled = !!settings.onResourceReorder\n const settingsRef = useRef(settings)\n settingsRef.current = settings\n const rowsRef = useRef(rows)\n rowsRef.current = rows\n\n const beginRowReorder = useCallback(\n (e: React.PointerEvent, dragRow: TimelineRow) => {\n const settings = settingsRef.current\n const rows = rowsRef.current\n if (e.button !== 0 || !settings.onResourceReorder) return\n e.preventDefault()\n e.stopPropagation()\n const container = treeRowsRef.current\n const pane = treePaneRef.current\n if (!container || !pane) return\n const rowEls = Array.from(\n container.querySelectorAll(\"[data-slot=gantt-row-group]\")\n )\n document.body.style.cursor = \"grabbing\"\n document.body.style.userSelect = \"none\"\n let current: TimelineReorderState | null = null\n let lastBoundary = -1\n\n // Carry overlay: clone the WHOLE row (name + detail columns), flat -\n // it reads as the row itself moving, not a separate card\n const pointerId = e.pointerId\n const rowEl = (e.currentTarget as HTMLElement).closest(\n \"[data-slot=gantt-row-group]\"\n )\n const overlay = document.createElement(\"div\")\n overlay.setAttribute(\"data-slot\", \"gantt-drag-overlay\")\n overlay.className =\n \"bg-background pointer-events-none fixed overflow-hidden opacity-95\"\n overlay.style.zIndex = \"100\"\n // body-appended, so it is outside the gantt root that owns the type\n // scale: without adopting the root's resolved metrics the carried row\n // renders at the document default and reads bigger than the row it left\n const ganttRoot = pane.closest(\"[data-slot=gantt]\")\n if (ganttRoot) {\n const rootStyle = getComputedStyle(ganttRoot)\n overlay.style.fontSize = rootStyle.fontSize\n overlay.style.lineHeight = rootStyle.lineHeight\n overlay.style.fontFamily = rootStyle.fontFamily\n overlay.style.letterSpacing = rootStyle.letterSpacing\n overlay.style.direction = rootStyle.direction\n }\n if (rowEl) {\n const rowRect = rowEl.getBoundingClientRect()\n overlay.style.width = `${rowRect.width}px`\n overlay.style.height = `${rowRect.height}px`\n const clone = rowEl.cloneNode(true) as HTMLElement\n clone.removeAttribute(\"data-gantt-row-id\")\n clone.classList.remove(\"border-b\")\n clone.style.height = \"100%\"\n overlay.appendChild(clone)\n }\n document.body.appendChild(overlay)\n const grabRect = rowEl?.getBoundingClientRect()\n const grabDY = grabRect ? e.clientY - grabRect.top : 8\n // vertical-only carry, locked to the tree panel like a list row drag\n const lockedX = grabRect?.left ?? pane.getBoundingClientRect().left\n const paneRect = pane.getBoundingClientRect()\n const rowH = grabRect?.height ?? 40\n // Rows do not move during the gesture (the carry is a fixed overlay), so\n // rects are measured ONCE - per-move full-row rect scans forced a\n // synchronous reflow after every overlay style write.\n const rects = rowEls.map((el) => el.getBoundingClientRect())\n const place = (y: number) => {\n const top = Math.min(\n Math.max(y - grabDY, paneRect.top),\n paneRect.bottom - rowH\n )\n overlay.style.left = `${lockedX}px`\n overlay.style.top = `${top}px`\n }\n place(e.clientY)\n\n const propose = (boundary: number): TimelineReorderState => {\n const below = rows[boundary]\n const parentId =\n below?.parentId ?? rows[rows.length - 1]?.parentId ?? null\n let index = 0\n for (let i = 0; i < boundary; i++) {\n if (\n rows[i].parentId === parentId &&\n rows[i].resource.id !== dragRow.resource.id\n ) {\n index++\n }\n }\n const next = reorderResources(\n settings.resources,\n dragRow.resource.id,\n parentId,\n index\n )\n const proposal: GanttResourceReorder | null = next\n ? {\n resourceId: dragRow.resource.id,\n parentId,\n index,\n resources: next,\n }\n : null\n const valid =\n !!proposal && (settings.canReorderResource?.(proposal) ?? true)\n const top =\n boundary < rects.length\n ? rects[boundary].top - paneRect.top\n : (rects[rects.length - 1]?.bottom ?? paneRect.top) - paneRect.top\n return { resourceId: dragRow.resource.id, top, valid, proposal }\n }\n\n const onMove = (ev: PointerEvent) => {\n if (ev.pointerId !== pointerId) return\n place(ev.clientY)\n let boundary = rects.length\n for (let i = 0; i < rects.length; i++) {\n if (ev.clientY < rects[i].top + rects[i].height / 2) {\n boundary = i\n break\n }\n }\n // the immutable tree clone + validity check run only when the pointer\n // crosses into another slot, not per pointermove\n if (boundary === lastBoundary) return\n lastBoundary = boundary\n const nextState = propose(boundary)\n if (\n current &&\n current.top === nextState.top &&\n current.valid === nextState.valid\n ) {\n return\n }\n current = nextState\n document.body.style.cursor = nextState.valid\n ? \"grabbing\"\n : \"not-allowed\"\n setReorder(nextState)\n }\n const finish = (commit: boolean) => {\n window.removeEventListener(\"pointermove\", onMove)\n window.removeEventListener(\"pointerup\", onUp)\n window.removeEventListener(\"pointercancel\", onCancelEvent)\n window.removeEventListener(\"keydown\", onKey)\n overlay.remove()\n document.body.style.cursor = \"\"\n document.body.style.userSelect = \"\"\n if (commit && current?.proposal && !current.valid) {\n // released on a rejected position (e.g. a pinned row): let the\n // consumer explain it - the destructive indicator already showed live\n settings.onResourceReorderReject?.(current.proposal)\n } else if (commit && current?.valid && current.proposal) {\n settings.onResourceReorder?.(current.proposal)\n const announcer = pane\n .closest(\"[data-slot=gantt]\")\n ?.querySelector(\"[data-slot=gantt-announcer]\")\n if (announcer) {\n announcer.textContent = `${dragRow.resource.title}: ${settings.i18n.labels.reorder}`\n }\n }\n setReorder(null)\n }\n const onUp = (ev: PointerEvent) => {\n if (ev.pointerId !== pointerId) return\n finish(true)\n }\n const onCancelEvent = (ev: PointerEvent) => {\n if (ev.pointerId !== pointerId) return\n finish(false)\n }\n const onKey = (ev: KeyboardEvent) => {\n if (ev.key === \"Escape\") finish(false)\n }\n window.addEventListener(\"pointermove\", onMove)\n window.addEventListener(\"pointerup\", onUp)\n window.addEventListener(\"pointercancel\", onCancelEvent)\n window.addEventListener(\"keydown\", onKey)\n },\n []\n )\n\n const collapsedIdsRef = useRef(collapsedIds)\n collapsedIdsRef.current = collapsedIds\n // Stable identity (reads through refs) so memoized rows survive re-renders\n const onToggleRow = useCallback((row: TimelineRow) => {\n const current = collapsedIdsRef.current\n const next = current.includes(row.resource.id)\n ? current.filter((id) => id !== row.resource.id)\n : [...current, row.resource.id]\n if (viewConfigRef.current.collapsedGroups === undefined) {\n setInternalCollapsed(next)\n }\n viewConfigRef.current.onCollapsedGroupsChange?.(next)\n }, [])\n\n const loading = useGanttSelector((state) => state.loading)\n const customScrollbars = viewConfig.scrollbars !== \"native\"\n const gridLines = resolveTimelineLines(viewConfig.timelineLines)\n const showVerticalLines = gridLines.vertical !== null\n const offDayClassName =\n (typeof viewConfig.offDays === \"object\" && viewConfig.offDays.className) ||\n \"bg-muted/40\"\n // Body texture: default off-days carry a whisper-faint diagonal hatch over\n // a lighter wash (header cells stay flat). A custom offDays.className\n // replaces both surfaces verbatim.\n const offDayBodyClassName =\n (typeof viewConfig.offDays === \"object\" && viewConfig.offDays.className) ||\n \"bg-muted/25 bg-[repeating-linear-gradient(135deg,transparent,transparent_5px,color-mix(in_oklab,var(--color-border)_35%,transparent)_5px,color-mix(in_oklab,var(--color-border)_35%,transparent)_6px)]\"\n\n // Header-only unit lines, and ONE mechanism for every vertical line in the\n // header: positioned spans at calc(fraction% - 1px). A background gradient\n // rasterizes stripe positions differently from element layout at fractional\n // unit widths, which shifted the group-row boundaries 1px off the unit\n // lines below them mid-track - identical span formulas snap identically.\n // The body stays bare - rows separate by whitespace, never vertical borders.\n const showUnitLines = !uniform || showVerticalLines\n\n // ----- tree pane content -----\n // Header label offset = the row cell's ps-3 (0.75rem) left gutter + the\n // toggle/checkbox gutter (w-5 + me-1 = 1.5rem) + the reorder grip (0.875rem)\n // when present, so \"Resources\" lines up with the row titles below it.\n const namePaddingStart = reorderEnabled ? \"3.125rem\" : \"2.25rem\"\n const treeContent = (\n \n {/* Same 65px height as the two-row timeline header so the panes align.\n The head keeps its own bottom rule (under the Resources label), but\n the header/body boundary line is transparent so the first tree node\n has no rule directly above it - the 1px is kept only to preserve the\n 65px height, matching the timeline. */}\n \n
\n
\n \n \n {settings.i18n.labels.resources}\n \n
\n {columns.map((column) => (\n \n {column.title ?? column.id}\n
\n ))}\n
\n
\n {viewConfig.columnsMenu && (\n \n {viewConfig.columnsMenu}\n \n )}\n \n \n
\n {rows.map((row) => (\n \n ))}\n {showCreateTask && (\n \n settings.onCreateTask?.({\n parentId: null,\n index: settings.resources.length,\n })\n }\n >\n \n {reorderEnabled && (\n \n )}\n {/* group chevrons sit centered in a size-5 button that fills\n this w-5 gutter, so the + must center here too */}\n \n \n \n {settings.i18n.labels.addTask}\n \n \n )}\n
\n \n )\n\n // ----- timeline pane content -----\n const timelineContent = (\n \n {/* Two-row grouped header; also the drag-to-pan surface */}\n \n {/* group sectors; boundaries painted like the body lines */}\n
\n
\n {groups.map((group) => (\n \n {/* Pure-CSS sticky: the label rides the leading edge for as\n long as its own band is on screen, then the next band pushes\n it out - so the day you are looking at always names itself.\n The browser composites this; a scroll listener would run JS\n on every frame to do worse. start-3 matches the cell's ps-3\n so the gutter is identical parked or pinned. */}\n \n {group.label}\n \n
\n ))}\n
\n {/* same span formula as the unit lines below: equal fractions get\n equal layout rounding, so the two rows' lines never drift apart */}\n {groupBoundaries.map((fraction) => (\n \n ))}\n \n {/* units, engine axis = this row */}\n \n {/* chrome underlay: off-day washes under the label layer */}\n
\n {unitFractions.map(\n ({ unit, start, width }) =>\n unit.isOff && (\n \n )\n )}\n
\n
\n {units.map((unit) => (\n \n {/* today reads as a soft pill, not just tinted text */}\n {unit.isToday ? (\n \n {unit.label}\n \n ) : (\n unit.label\n )}\n
\n ))}\n \n {showUnitLines &&\n unitFractions.slice(1).map(({ unit, start }) => (\n \n ))}\n {viewConfig.nowIndicator && (\n \n )}\n \n \n {/* Rows over a shared backdrop (off days, today, boundaries, now);\n grows so the columns run to the bottom of the pane. Pressing anywhere\n here that is not a bar or a hint tile begins a scroll pan - the whole\n panel is a draggable canvas, not just the rows. */}\n \n {/* off-day / today / now backdrop only; vertical gridlines are\n per-row and reveal on selection, not painted here */}\n \n {unitFractions.map(({ unit, start, width }) => (\n \n {unit.isOff && (\n \n )}\n {unit.isToday && scale !== \"day\" && (\n \n )}\n \n ))}\n {/* one layer for the whole body, not per row: the unit boundaries\n have to line up with the header's spans exactly, so both use the\n same fraction formula */}\n {gridLines.vertical !== null &&\n unitFractions\n .slice(1)\n .map(({ unit, start }) => (\n \n ))}\n {viewConfig.nowIndicator && (\n \n )}\n \n {rows.map((row, rowIndex) => (\n \n ))}\n {rows.length === 0 && viewConfig.renderNoResources && (\n \n {viewConfig.renderNoResources()}\n \n )}\n {showCreateTask && (\n \n )}\n \n \n )\n\n const horizontalScrollbar = (\n \n )\n\n const Comp = asChild ? Slot.Root : \"div\"\n\n return (\n \n
\n {/* Tree pane */}\n \n {customScrollbars ? (\n // keyed by scale: Base UI measures overflow once per mount, and a\n // scale switch changes content without resizing the viewport\n [data-orientation=vertical]]:hidden\"\n >\n {treeContent}\n {horizontalScrollbar}\n \n ) : (\n \n {treeContent}\n
\n )}\n {/* Reserved horizontal-scrollbar rail: the tree usually has no\n horizontal overflow, so its real scrollbar never mounts and its\n bottom edge would sit higher than the timeline's pinned strip.\n This static rail fills that gutter (same 1rem height + top border)\n so the bottom strip reads as one continuous band across both\n panes; a real tree scrollbar (with columns) draws over it. */}\n {customScrollbars && (\n \n )}\n {/* Reorder insertion indicator, pinned to the visible pane */}\n {reorder && (\n \n {/* caret head pointing along the insertion line; the whole\n indicator sits above the row carry overlay (z 100) */}\n \n \n \n )}\n \n {/* Splitter */}\n {treeConfig.resizable ? (\n {\n setTreeWidth(treeConfig.width)\n treeConfig.onWidthChange?.(treeConfig.width)\n }}\n onKeyDown={(e) => {\n if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") {\n e.preventDefault()\n const dir =\n getComputedStyle(e.currentTarget).direction === \"rtl\" ? -1 : 1\n const delta = (e.key === \"ArrowLeft\" ? -16 : 16) * dir\n const next = clampTree(clampedTreeWidth + delta)\n setTreeWidth(next)\n treeConfig.onWidthChange?.(next)\n }\n }}\n >\n {/* grip pill: makes the hairline read as draggable on approach */}\n \n \n ) : (\n
\n )}\n {/* Timeline pane */}\n \n {viewConfig.zoomControl && (\n \n {/* aria-disabled instead of disabled: the not-allowed cursor\n must still show at the zoom limits */}\n \n \n \n {\n if (!canZoomIn) return\n // controlled zoom anchors via fineCenterRef when\n // the parent adopts; a pre-set anchor would leak\n // stale if the parent ignores the proposal\n if (viewConfig.zoom === undefined) anchorZoomCenter()\n setZoomValue(\n +(zoom + (zoomRange.step ?? 0.25)).toFixed(2)\n )\n }}\n >\n \n \n \n \n {settings.i18n.labels.zoomIn}\n \n \n \n \n {\n if (!canZoomOut) return\n if (viewConfig.zoom === undefined) anchorZoomCenter()\n setZoomValue(\n +(zoom - (zoomRange.step ?? 0.25)).toFixed(2)\n )\n }}\n >\n \n \n \n \n {settings.i18n.labels.zoomOut}\n \n \n \n
\n )}\n {customScrollbars ? (\n // The vertical scrollbar is inset into the body lane: it starts\n // below the sticky 65px two-row header (otherwise its top slides\n // behind the header and the thumb is clipped) and stops above the\n // pinned 16px horizontal strip. `!` overrides the primitive's\n // inline top/bottom; h-auto lets top+bottom define the track\n // height so the thumb is measured against the visible lane.\n // The last selector is the radix twin of base's\n // ScrollAreaPrimitive.Content min-h-full: radix's Viewport wraps\n // children in its own display:table div, and without a height on\n // it the content's min-h-full collapses - the columns then stop at\n // the last row and leave the rest of the pane dead and unpannable.\n [data-orientation=vertical]]:top-[65px]! [&>[data-orientation=vertical]]:bottom-4! [&>[data-orientation=vertical]]:h-auto! [&>[data-slot=scroll-area-viewport]>div]:flex! [&>[data-slot=scroll-area-viewport]>div]:min-h-full [&>[data-slot=scroll-area-viewport]>div]:flex-col\"\n >\n {timelineContent}\n {horizontalScrollbar}\n \n ) : (\n \n {timelineContent}\n \n )}\n {/* Reserved scrollbar rail - the twin of the tree rail. Keeps the\n bottom gutter present even when the real horizontal scrollbar is\n hidden (e.g. hover-reveal scrollbars at rest), so the strip reads\n as one continuous reserved band across both panes; the real\n scrollbar (z-40) draws over it when active. */}\n {customScrollbars && (\n \n )}\n {viewConfig.offscreenIndicators && (\n \n )}\n \n {loading && (\n \n \n {settings.i18n.labels.loading}\n \n \n )}\n {(viewConfig.renderDragPreview || viewConfig.renderResizeIndicator) && (\n \n )}\n \n \n )\n}\n\n/**\n * The red now-line, self-ticking: only this component re-renders on the 30s\n * clock, never the grid around it. z-10 keeps it above row content but UNDER\n * the sticky header (z-30) - vertical scrolling slides it beneath, never over.\n */\nfunction GanttNowLine({\n rangeStartMs,\n rangeEndMs,\n}: {\n rangeStartMs: number\n rangeEndMs: number\n}) {\n const now = useNow()\n const ms = now.getTime()\n if (ms < rangeStartMs || ms >= rangeEndMs) return null\n const fraction = (ms - rangeStartMs) / (rangeEndMs - rangeStartMs)\n return (\n \n )\n}\n\n/**\n * The now-line's dot cap, pinned INSIDE the sticky header at the header/body\n * boundary: it stays put while the line scrolls beneath the header.\n */\nfunction GanttNowDot({\n rangeStartMs,\n rangeEndMs,\n}: {\n rangeStartMs: number\n rangeEndMs: number\n}) {\n const now = useNow()\n const ms = now.getTime()\n if (ms < rangeStartMs || ms >= rangeEndMs) return null\n const fraction = (ms - rangeStartMs) / (rangeEndMs - rangeStartMs)\n return (\n \n )\n}\n\n/**\n * Consumer-owned drag/resize indicators (renderDragPreview /\n * renderResizeIndicator): content is React and re-renders per snap step from\n * drag state; the dnd engine adopts this wrapper and writes its\n * cursor-tracking transform imperatively, flipping visibility on the first\n * positioned frame so nothing flashes at the viewport origin.\n */\nfunction GanttCustomDragLayer() {\n const viewConfig = useGanttViewConfig()\n const drag = useGanttSelector((state) => state.drag)\n if (!drag) return null\n const render =\n drag.kind === \"move\"\n ? viewConfig.renderDragPreview\n : viewConfig.renderResizeIndicator\n if (!render) return null\n return (\n \n {render({\n occurrence: drag.occurrence,\n kind: drag.kind,\n start: drag.proposedStart,\n end: drag.proposedEnd,\n valid: drag.valid,\n })}\n \n )\n}\n\n/** Memoized: only rows whose props actually changed re-render. */\nconst GanttTreeRow = memo(function GanttTreeRow({\n row,\n heightRem,\n bandRem,\n columns,\n nameWidth,\n dimmed,\n selected,\n onSelectedChange,\n onGripPointerDown,\n onToggle,\n}: {\n row: TimelineRow\n heightRem: number\n bandRem: number\n columns: GanttColumn[]\n nameWidth: number\n dimmed: boolean\n selected: boolean\n onSelectedChange?: (id: string, checked: boolean) => void\n onGripPointerDown?: (e: React.PointerEvent, row: TimelineRow) => void\n onToggle: (row: TimelineRow) => void\n}) {\n const settings = useGanttSettings()\n const viewConfig = useGanttViewConfig()\n const ctx = {\n resource: row.resource,\n depth: row.depth,\n isGroup: row.isGroup,\n collapsed: row.collapsed,\n }\n\n // consumer-owned right-click menu, same contract as the bar menu\n const menu = viewConfig.renderResourceMenu?.(ctx)\n\n // A node with several schedules grows its row; \"start\" keeps the label and\n // its columns on the FIRST schedule's baseline instead of floating them to\n // the middle of a tall row. Every cell's inner box is minRowHeight tall, so\n // a single-lane row renders identically either way.\n const alignStart = (viewConfig.rowAlign ?? DEFAULT_ROW_ALIGN) === \"start\"\n\n const rowNode = (\n {\n // chrome clicks (chevron, checkbox, grip) are their own actions\n if ((e.target as HTMLElement).closest(\"button, [role=checkbox]\"))\n return\n settings.onResourceClick?.(ctx, e)\n }\n : undefined\n }\n onDoubleClick={\n settings.onResourceDoubleClick\n ? (e: React.MouseEvent) => {\n if ((e.target as HTMLElement).closest(\"button, [role=checkbox]\"))\n return\n settings.onResourceDoubleClick?.(ctx, e)\n }\n : undefined\n }\n >\n
\n {/* In-flow name cell: the whole tree row scrolls horizontally as one;\n row-level hover/selected tints show through the transparent cell */}\n \n {/* exactly the band the first schedule occupies: same height, both\n top-anchored, so the label and that schedule share a centerline */}\n \n {onGripPointerDown && (\n onGripPointerDown(e, row)}\n onClick={(e) => e.stopPropagation()}\n >\n \n \n )}\n {/* per-level indent keeps sibling titles on one x */}\n \n {/* fixed gutter: groups toggle here, leaves carry the checkbox -\n titles of one level share the same x either way */}\n \n {row.isGroup ? (\n onToggle(row)}\n >\n \n \n ) : (\n viewConfig.rowCheckboxes &&\n onSelectedChange && (\n \n onSelectedChange(row.resource.id, checked)\n }\n aria-label={row.resource.title}\n className={cn(\n \"size-3.5 opacity-0 transition-opacity group-hover/gantt-row:opacity-100 group-data-hover/gantt-row:opacity-100 focus-visible:opacity-100\",\n selected && \"opacity-100\"\n )}\n />\n )\n )}\n \n {viewConfig.renderResourceLabel?.(ctx) ?? (\n {row.resource.title}\n )}\n
\n \n {columns.map((column) => (\n \n \n {column.render?.(ctx)}\n \n \n ))}\n
\n
\n \n )\n\n if (!menu) return rowNode\n return (\n \n {rowNode}\n \n {menu}\n \n \n )\n})\n\nconst GanttTimelineRow = memo(function GanttTimelineRow({\n row,\n rowIndex,\n bars,\n rangeStartMs,\n rangeEndMs,\n trackWidth,\n trackRemWidth,\n rowBorder,\n selected,\n resolveHintStop,\n isPanning,\n laneHeightRem,\n laneGapRem,\n minRowRem,\n}: {\n row: TimelineRow\n rowIndex: number\n bars: TimelineRowBars | undefined\n rangeStartMs: number\n rangeEndMs: number\n trackWidth: string\n trackRemWidth: number\n rowBorder: \"solid\" | \"dashed\" | null\n selected: boolean\n resolveHintStop: (\n fraction: number\n ) => { index: number; center: number; ms: number; endMs: number } | null\n isPanning: boolean\n laneHeightRem: number\n laneGapRem: number\n minRowRem: number\n}) {\n const instance = useGantt()\n const settings = useGanttSettings()\n const viewConfig = useGanttViewConfig()\n const gestures = useGanttGestures()\n const segments = bars?.segments ?? []\n // parents aggregate their subtree; they take no direct scheduling gestures\n const schedulable = !row.isGroup || viewConfig.parentScheduling\n const heightRem = bars?.heightRem ?? minRowRem\n const laneCount = bars?.laneCount ?? 1\n const singleTrack =\n resolveScheduleMode(row.resource, viewConfig.scheduleMode) === \"single\"\n // shared with the tree pane so the two can never drift apart\n const laneOffsetRem = bars?.laneOffsetRem ?? (minRowRem - laneHeightRem) / 2\n\n // Hover affordance over empty track space: the ghost tile snaps to the\n // unit under the cursor, so the (real) tooltip re-anchors per unit\n const [hintStop, setHintStop] = useState<{\n index: number\n center: number\n ms: number\n endMs: number\n } | null>(null)\n // Gated on an active hint: rows without one read a stable false, so a\n // gesture starting/ending anywhere doesn't re-render every row.\n const hintSuppressed = useGanttSelector(\n (state) =>\n hintStop !== null && (state.drag !== null || state.slotDraft !== null)\n )\n const canSchedule = useGanttSelector(\n (state) => state.interactions.selectSlot\n )\n // The affordance is offered ANYWHERE on a schedulable row - over bare track\n // and over existing bars alike - because a row can always take another\n // schedule on a free lane. The primitive deliberately owns NO opinion about\n // what may land where: that is `canSelectSlot`, the consumer's call. (It\n // used to withhold the hint on a one-track row that already held a\n // schedule, which also blocked adding a second NON-overlapping one.)\n // The lowest track FREE at the hovered time. Two jobs, kept separate on\n // purpose:\n // - VISIBILITY: `hintFreeLane < laneCount` is what proves an empty track\n // actually exists to draw on. It is computed regardless of mode, because\n // a \"single\" row whose only track is booked has nowhere free either, and\n // pinning the placement to 0 there would put the ring straight on the bar.\n // - PLACEMENT: \"single\" packs everything onto track 0, so that is where its\n // ring belongs; otherwise the ring sits on the track the schedule will\n // actually land on. Lane is a function of TIME only, so moving the pointer\n // down onto the ring never moves it away from you.\n const hintFreeLane = hintStop\n ? lowestFreeLane(segments, hintStop.ms, hintStop.endMs)\n : 0\n const hintLane = bars?.scheduleMode === \"single\" ? 0 : hintFreeLane\n const hintTopRem = Math.min(\n laneOffsetRem + hintLane * (laneHeightRem + laneGapRem) + laneHeightRem / 2,\n Math.max(heightRem - laneHeightRem / 2, laneHeightRem / 2)\n )\n // Single source of truth for \"the add affordance is live at this spot\". The\n // ring renders on it AND the row takes its cursor from it, so the pointer can\n // never promise something the ring is not offering.\n const hintVisible =\n hintStop !== null &&\n hintFreeLane < laneCount &&\n !hintSuppressed &&\n !isPanning\n // name the gesture that is actually wired, not a generic one\n const hintLabel = viewConfig.dragCreate\n ? settings.i18n.labels.scheduleHintDrag\n : settings.i18n.labels.scheduleHint\n const showHint =\n viewConfig.displayScheduleHint &&\n schedulable &&\n canSchedule &&\n !isPanning &&\n !!(settings.onSelectSlot || settings.onSlotClick)\n\n const fractionOf = (ms: number) =>\n Math.min(Math.max((ms - rangeStartMs) / (rangeEndMs - rangeStartMs), 0), 1)\n\n const dragTarget = useGanttSelector(\n (state) => {\n const drag = state.drag\n if (!drag || drag.proposedResourceId !== row.resource.id) return null\n return drag.valid ? \"valid\" : \"invalid\"\n }\n )\n const ghost = useGanttSelector<\n unknown,\n {\n from: number\n to: number\n color?: string\n valid: boolean\n title: string\n kind: string\n occurrenceKey: string\n } | null\n >(\n (state) => {\n const drag = state.drag\n if (!drag || drag.proposedResourceId !== row.resource.id) return null\n return {\n from: fractionOf(drag.proposedStart.getTime()),\n to: fractionOf(drag.proposedEnd.getTime()),\n color: drag.occurrence.event.color,\n valid: drag.valid,\n title: drag.occurrence.event.title,\n kind: drag.kind,\n occurrenceKey: drag.occurrence.key,\n }\n },\n {\n isEqual: (a, b) =>\n a === b ||\n (a !== null &&\n b !== null &&\n a.from === b.from &&\n a.to === b.to &&\n a.valid === b.valid),\n }\n )\n const draft = useGanttSelector<\n unknown,\n {\n from: number\n to: number\n startMs: number\n endMs: number\n } | null\n >(\n (state) => {\n const slotDraft = state.slotDraft\n if (!slotDraft || slotDraft.resourceId !== row.resource.id) return null\n const startMs = slotDraft.start.getTime()\n const endMs = slotDraft.end.getTime()\n return {\n from: fractionOf(startMs),\n to: fractionOf(endMs),\n startMs,\n endMs,\n }\n },\n {\n // from/to as well as the instants: they are derived through fractionOf,\n // which closes over the range. An extendRange mid-gesture moves every\n // bar while an instants-only compare serves the cached (stale) fractions,\n // leaving the placeholder pinned to where the range used to be.\n isEqual: (a, b) =>\n a === b ||\n (a !== null &&\n b !== null &&\n a.startMs === b.startMs &&\n a.endMs === b.endMs &&\n a.from === b.from &&\n a.to === b.to),\n }\n )\n\n /**\n * Where the schedule being painted will land - resolved in the shared layout\n * memo, which also RESERVES that track, so the row has already grown to hold\n * it. The clamp is only a backstop for the frame between a draft appearing\n * and the layout catching up.\n */\n const draftLane = bars?.draftLane ?? 0\n const draftTopRem = Math.min(\n laneOffsetRem + draftLane * (laneHeightRem + laneGapRem),\n Math.max(heightRem - laneHeightRem, 0)\n )\n // the range you are painting, named while you paint it - a drag that shows\n // no times asks you to guess where you let go\n const draftLabel = draft\n ? settings.i18n.functions.formatEventTime(\n toZoned(new Date(draft.startMs), settings.timeZone),\n toZoned(new Date(draft.endMs), settings.timeZone),\n false,\n settings.locale\n )\n : \"\"\n\n /**\n * The row's create contract, in one place: onSlotClick if the consumer\n * wired it, else a ready-made slot draft. Both the hint tile and a plain\n * click on bare track go through this, so clicking anywhere placeable\n * behaves the same as clicking the tile.\n */\n const createAt = (\n stop: { ms: number; endMs: number },\n e: React.MouseEvent\n ) => {\n if (settings.onSlotClick) {\n settings.onSlotClick(\n { date: new Date(stop.ms), allDay: false, resourceId: row.resource.id },\n e\n )\n } else {\n settings.onSelectSlot?.({\n start: new Date(stop.ms),\n end: new Date(Math.max(stop.endMs, stop.ms + 1)),\n allDay: false,\n resourceId: row.resource.id,\n })\n }\n }\n\n // The drop indicator belongs on the lane the dragged schedule actually\n // occupies. The gesture has not committed, so the segment is still packed\n // under its pre-drag key - no second packing pass, just a lookup.\n const ghostLane = ghost\n ? (segments.find(\n (segment) => segment.occurrence.key === ghost.occurrenceKey\n )?.column ?? 0)\n : 0\n const ghostLaneOffsetRem =\n laneOffsetRem +\n ghostLane * (laneHeightRem + laneGapRem) +\n (laneHeightRem - GHOST_HEIGHT_REM) / 2\n\n return (\n {\n // Opt-in drag-create owns presses on empty schedulable track (the\n // onSelectSlot contract); otherwise the press bubbles to the\n // container and pans the timeline.\n if (\n viewConfig.dragCreate &&\n e.button === 0 &&\n schedulable &&\n canSchedule &&\n // the bare track, or the hint tile that spawns under the cursor -\n // a sub-threshold press still ends as the tile's own click\n (e.target === e.currentTarget ||\n !!(e.target as HTMLElement).closest?.(\n \"[data-slot=gantt-schedule-hint]\"\n )) &&\n !!settings.onSelectSlot\n ) {\n // validate BEFORE claiming the press: on a vetoed slot (e.g. the\n // one-schedule-per-task rule) the press falls through to the pan\n const stop = resolveHintStop(\n trackFraction(e.currentTarget, e.clientX)\n )\n const allowed =\n stop !== null &&\n (settings.canSelectSlot?.({\n start: new Date(stop.ms),\n end: new Date(Math.max(stop.endMs, stop.ms + 1)),\n allDay: false,\n resourceId: row.resource.id,\n }) ??\n true)\n if (!allowed) return\n e.stopPropagation()\n gestures.beginCreate(e)\n }\n }}\n onPointerMove={(e) => {\n // read interaction state imperatively - a subscription here would\n // re-render the row for every gesture anywhere on the grid\n const interacting =\n instance.getState().drag !== null ||\n instance.getState().slotDraft !== null\n if (!showHint || e.pointerType !== \"mouse\" || interacting) {\n if (hintStop) setHintStop(null)\n return\n }\n // EMPTY TRACK ONLY. A bar under the pointer means the pointer is not\n // on an empty slot, so the affordance goes away entirely rather than\n // hovering over booked time - and the bar is left completely alone,\n // hover and click both. (No \"pointer is over the tile\" bail: the ring\n // is pointer-events-none, so it is never the target; a bail on it would\n // freeze it on the spot, since it rides under the cursor.)\n if (e.target !== e.currentTarget) {\n if (hintStop) setHintStop(null)\n return\n }\n const { fraction, offset } = trackPoint(e.currentTarget, e.clientX)\n // The dot follows the pointer FREELY - it is a cursor, not a cell, so\n // it is never quantised to the interval grid. Its position is written\n // as a CSS custom property straight onto the row: the cursor moves\n // every frame and a state update per frame would re-render the row and\n // every bar in it. React state still owns WHICH slot is offered, and\n // that changes only once per interval.\n //\n // In pixels rather than a percentage, pre-snapped by trackPoint to the\n // viewport pixel grid: a fractional inset makes the browser antialias\n // the ring and the glyph across two device pixels, which reads as a\n // furry, smudged dot. -translate-x-1/2 of an even-sized box keeps it on\n // the grid, so the result is crisp.\n e.currentTarget.style.setProperty(\"--gantt-hint-x\", `${offset}px`)\n const stop = resolveHintStop(fraction)\n // validate placement before offering it: a consumer canSelectSlot\n // veto (e.g. a locked span) hides the hint entirely\n const allowed =\n stop !== null &&\n (settings.canSelectSlot?.({\n start: new Date(stop.ms),\n end: new Date(Math.max(stop.endMs, stop.ms + 1)),\n allDay: false,\n resourceId: row.resource.id,\n }) ??\n true)\n const next = allowed ? stop : null\n // state changes only when the cursor crosses into another unit\n if (next?.index !== hintStop?.index) setHintStop(next)\n }}\n onPointerLeave={() => {\n if (hintStop) setHintStop(null)\n }}\n onClick={(e) => {\n // With dragCreate the press belongs to the create GESTURE, which only\n // activates past its movement threshold - so a click that never moved\n // committed nothing and the row felt dead. Treat it as a create at the\n // hovered slot, using the same validation the hint tile uses.\n if (!viewConfig.dragCreate || e.target !== e.currentTarget) return\n if (!schedulable || !canSchedule) return\n if (wasRecentDrag()) return\n if (!settings.onSlotClick && !settings.onSelectSlot) return\n const stop = resolveHintStop(trackFraction(e.currentTarget, e.clientX))\n if (!stop) return\n const allowed =\n settings.canSelectSlot?.({\n start: new Date(stop.ms),\n end: new Date(Math.max(stop.endMs, stop.ms + 1)),\n allDay: false,\n resourceId: row.resource.id,\n }) ?? true\n if (!allowed) return\n createAt(stop, e)\n setHintStop(null)\n }}\n >\n {/* row CONTENT (bars, labels, ghosts). Pointer-transparent so\n empty-track presses still hit the row itself. */}\n {/* content-visibility lets the browser skip rendering this layer for\n rows scrolled out of view (large trees stay cheap on low-end\n devices). Safe here: the layer is absolute inset-0 (geometry comes\n from the row, never from content), its paint containment keeps it\n a stacking context, and nothing inside escapes the row box - the\n schedule hint deliberately lives OUTSIDE this layer. Browsers\n without support simply ignore it. */}\n \n {segments.map((segment, segmentIndex) => {\n const from = fractionOf(\n rangeStartMs + (segment.startMin ?? 0) * 60000\n )\n const to = fractionOf(rangeStartMs + (segment.endMin ?? 0) * 60000)\n if (to <= from) return null\n const lane = segment.column ?? 0\n // Title placement: outside beside the bar when configured (or too\n // short in \"auto\"), flipped before the bar near the range end, and\n // back inside when the bar spans the whole view.\n const barRemWidth = (to - from) * trackRemWidth\n const wantsOutside =\n viewConfig.barLabel === \"outside\" ||\n (viewConfig.barLabel === \"auto\" &&\n barRemWidth <\n (viewConfig.metrics?.autoLabelMin ?? AUTO_LABEL_MIN_REM))\n const placement = !wantsOutside\n ? \"inside\"\n : to <= 0.92\n ? \"after\"\n : from >= 0.08\n ? \"before\"\n : \"inside\"\n // the wrapper carries the drag kind itself (from the row's ghost\n // state, same notify as the bar's own attribute) so the hide rules\n // below use plain attribute selectors instead of :has(), which\n // older Firefox (<121) does not support\n const segDragKind =\n ghost && ghost.occurrenceKey === segment.occurrence.key\n ? ghost.kind\n : undefined\n return (\n \n \n {placement !== \"inside\" && (\n \n {segment.occurrence.event.title}\n \n )}\n \n )\n })}\n {bars?.summary && segments.length === 0 && (\n \n {viewConfig.renderSummary ? (\n // consumer-owned rollup: the positioned envelope wrapper stays\n viewConfig.renderSummary({\n resource: row.resource,\n start: new Date(\n rangeStartMs + bars.summary.from * (rangeEndMs - rangeStartMs)\n ),\n end: new Date(\n rangeStartMs + bars.summary.to * (rangeEndMs - rangeStartMs)\n ),\n progress: bars.summary.progress,\n })\n ) : (\n <>\n {/* envelope end caps: the classic PM rollup silhouette, muted */}\n \n \n
\n {bars.summary.progress !== null && (\n \n )}\n
\n {bars.summary.progress !== null && (\n \n {bars.summary.progress}%\n \n )}\n \n )}\n \n )}\n {ghost && (\n \n {ghost.kind !== \"move\" && (\n // label rides OUTSIDE after the bar, exactly like the resting\n // outside placement - never inside the schedule\n \n {ghost.title}\n \n )}\n \n )}\n {draft && (\n \n {/* the accent tint, over the opaque base rather than over whatever\n happens to be beneath the row */}\n \n \n )}\n \n {/* OUTSIDE the content layer for the same reason as the hint bubble: the\n layer's paint containment would CLIP a chip that overhangs the row.\n Centred on the range being painted, so the times track the drag. */}\n {draft && draftLabel && (\n \n \n {draftLabel}\n {/* Same arrow as every other bubble, one size down. A 45-degree\n square is symmetric, so size-2.5 spans ~14px whichever edge it\n straddles: a small nub under a ~120px-wide chip, but most of the\n short edge of a ~26px-TALL one. size-1.5 spans ~8.5px and\n protrudes ~4px, the usual tooltip-arrow proportion, so the side\n arrow reads the same weight as the ones above and below. */}\n \n \n \n )}\n {/* OUTSIDE the content layer: its paint containment creates a stacking\n context that would trap the bubble's z-index under neighboring rows.\n As a direct row child its z-30 stacks above every row in the pane. */}\n {/* `hintLane < laneCount` is the second half of \"empty slots only\": the\n pointer being on bare track can still mean the row's own padding above\n a column that is booked on every track. There is no free track to\n draw on there, so the clamp would park the ring on top of a bar -\n exactly what must never happen. Booking such an instant is a drag from\n a free point, not a click. */}\n {hintVisible && hintStop && (\n \n {viewConfig.renderScheduleHint ? (\n // consumer-owned hint: the wrapper stays snapped + validated;\n // the content drives its own create flow\n viewConfig.renderScheduleHint({\n start: new Date(hintStop.ms),\n end: new Date(Math.max(hintStop.endMs, hintStop.ms + 1)),\n resource: row.resource,\n })\n ) : (\n <>\n {/* the band IS the placement affordance: it covers the interval\n under the pointer, so what you see is the slot you get. Clicking\n books it; pressing and dragging paints a longer range. The bubble\n mirrors the tooltip theme, arrow included. */}\n {/* The dot IS the cursor: an open ring, so whatever it is standing\n on stays readable through it. The outer background ring is what\n keeps it visible on a dark bar - an outline, not a fill.\n The GLYPH is the native cursor rather than an icon: `cell` is the\n range-select cursor every spreadsheet uses, which is exactly the\n drag-a-range gesture, and `copy` carries a plus for the\n click-to-add case. Clicking books the interval it is standing in;\n pressing and dragging paints a range whose ends snap to the\n nearest boundary. */}\n {\n // with drag-create on the press belongs to the row's create\n // gesture; a sub-threshold press still ends as this click\n if (!viewConfig.dragCreate) e.stopPropagation()\n }}\n onClick={(e) => {\n e.stopPropagation()\n // a completed drag-create already committed via onSelectSlot\n if (wasRecentDrag()) return\n createAt(hintStop, e)\n setHintStop(null)\n }}\n />\n \n {hintLabel}\n \n \n \n )}\n \n )}\n \n )\n})\n\ninterface OffscreenChip {\n id: string\n side: \"start\" | \"end\"\n top: number\n color?: string\n label: string\n /** First bar start, shown in the chip tooltip. */\n startMs: number | null\n /** scrollLeft that brings the bar back into view. */\n target: number\n /** End-chip inset in px, widened to clear the zoom control when they overlap. */\n insetEnd: number\n}\n\nfunction sameChips(a: OffscreenChip[], b: OffscreenChip[]): boolean {\n return (\n a.length === b.length &&\n a.every(\n (chip, i) =>\n chip.id === b[i].id &&\n chip.side === b[i].side &&\n chip.top === b[i].top &&\n chip.target === b[i].target &&\n chip.insetEnd === b[i].insetEnd\n )\n )\n}\n\n/**\n * Edge chips for rows whose bars sit entirely outside the visible timeline;\n * clicking scrolls the bar back into view. Reads geometry straight from the\n * DOM (row data attributes), so scrolling never re-renders the grid.\n */\nfunction GanttOffscreenChips({\n paneRef,\n occurrences,\n locale,\n refreshKey,\n}: {\n paneRef: RefObject\n occurrences: GanttOccurrence[]\n locale?: Locale\n refreshKey: string\n}) {\n const settings = useGanttSettings()\n const [chips, setChips] = useState([])\n\n useEffect(() => {\n const pane = paneRef.current\n const viewport = getPaneViewport(pane)\n if (!pane || !viewport) return\n let raf = 0\n const measure = () => {\n raf = 0\n const paneRect = pane.getBoundingClientRect()\n const header = viewport.querySelector(\n \"[data-slot=gantt-timeline-header]\"\n )\n const headerBottom = header\n ? header.getBoundingClientRect().bottom - paneRect.top\n : 0\n const trackW = viewport.scrollWidth\n const visibleStart = getScrollStart(viewport)\n const visibleEnd = visibleStart + viewport.clientWidth\n // the floating zoom control shares the right edge (higher z); end chips\n // whose row center falls in its band shift left so they stay clickable\n const zoomEl = pane.querySelector(\"[data-slot=gantt-zoom]\")\n const zoom = zoomEl\n ? {\n top: zoomEl.getBoundingClientRect().top - paneRect.top - 8,\n bottom: zoomEl.getBoundingClientRect().bottom - paneRect.top + 8,\n inset: paneRect.right - zoomEl.getBoundingClientRect().left + 8,\n }\n : null\n const next: OffscreenChip[] = []\n for (const rowEl of viewport.querySelectorAll(\n \"[data-gantt-row]\"\n )) {\n const from = parseFloat(rowEl.dataset.ganttBarMin ?? \"\")\n const to = parseFloat(rowEl.dataset.ganttBarMax ?? \"\")\n if (Number.isNaN(from) || Number.isNaN(to)) continue\n const rect = rowEl.getBoundingClientRect()\n const top = rect.top - paneRect.top + rect.height / 2\n if (top < headerBottom + 10 || top > paneRect.height - 16) continue\n const startPx = from * trackW\n const endPx = to * trackW\n const startMs = parseFloat(rowEl.dataset.ganttBarStartMs ?? \"\")\n const base = {\n id: rowEl.dataset.ganttRowId ?? \"\",\n top: Math.round(top),\n color: rowEl.dataset.ganttBarColor,\n label: rowEl.dataset.ganttBarLabel ?? \"\",\n startMs: Number.isNaN(startMs) ? null : startMs,\n }\n if (endPx <= visibleStart + 2) {\n next.push({\n ...base,\n side: \"start\",\n target: startPx - 24,\n insetEnd: 14,\n })\n } else if (startPx >= visibleEnd - 2) {\n const overlapsZoom = zoom && top >= zoom.top && top <= zoom.bottom\n next.push({\n ...base,\n side: \"end\",\n target: endPx - viewport.clientWidth + 24,\n insetEnd: overlapsZoom ? Math.max(14, zoom.inset) : 14,\n })\n }\n }\n setChips((prev) => (sameChips(prev, next) ? prev : next))\n }\n const schedule = () => {\n if (!raf) raf = requestAnimationFrame(measure)\n }\n viewport.addEventListener(\"scroll\", schedule)\n const observer = new ResizeObserver(schedule)\n observer.observe(viewport)\n schedule()\n return () => {\n viewport.removeEventListener(\"scroll\", schedule)\n observer.disconnect()\n if (raf) cancelAnimationFrame(raf)\n }\n }, [paneRef, occurrences, refreshKey])\n\n if (chips.length === 0) return null\n\n const scrollTo = (chip: OffscreenChip) => {\n const viewport = getPaneViewport(paneRef.current)\n if (!viewport) return\n const target = Math.max(0, chip.target)\n viewport.scrollTo({\n // chip targets are distances from the inline start; RTL signs them\n left: getComputedStyle(viewport).direction === \"rtl\" ? -target : target,\n behavior: \"smooth\",\n })\n // hand keyboard focus to the bar the chip promised (the chip unmounts)\n const bar = viewport.querySelector(\n `[data-gantt-row-id=\"${CSS.escape(chip.id)}\"] [data-slot=gantt-bar]`\n )\n bar?.focus({ preventScroll: true })\n }\n\n return (\n \n \n {chips.map((chip) => (\n \n \n scrollTo(chip)}\n >\n {chip.side === \"start\" ? (\n \n ) : (\n \n )}\n \n \n \n \n
{chip.label}
\n {chip.startMs !== null && (\n
\n {/* zoned: the chip must name the same day the grid shows */}\n {format(\n toZoned(new Date(chip.startMs), settings.timeZone),\n \"MMM d, yyyy\",\n { locale }\n )}\n
\n )}\n
\n
\n ))}\n
\n \n )\n}\n\nexport { GanttView }\nexport type { GanttViewProps }","target":"components/neui/gantt/gantt-view.tsx"}]}