From 58585e8707f9e3c49986fa9d5fa48cb12b67aa77 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Mon, 31 Aug 2026 08:52:18 +0100 Subject: [PATCH 1/8] feat: add sticky scroll container styles for Gantt component --- src/components/gantt/Gantt.style.scss | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/components/gantt/Gantt.style.scss b/src/components/gantt/Gantt.style.scss index 24c9efba0..68eeb27b1 100644 --- a/src/components/gantt/Gantt.style.scss +++ b/src/components/gantt/Gantt.style.scss @@ -48,6 +48,26 @@ align-items: center; } + &-scroll { + position: sticky; + display: flex; + align-items: center; + justify-content: center; + padding: 0 variables.$xxxs; + color: variables.$white; + background: variables.$bodyBg; + z-index: 3; + + &--left { + left: 0; + } + + &--right { + right: 0; + margin-left: auto; + } + } + &-label-column { position: sticky; left: 0; From d53efe77aa4d51e9f20bce7dd24b324b1c5f8205 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Mon, 31 Aug 2026 08:52:28 +0100 Subject: [PATCH 2/8] feat: adjust Gantt component bounds for improved item duration handling --- src/components/gantt/Gantt.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/gantt/Gantt.tsx b/src/components/gantt/Gantt.tsx index f467caee9..0551291d6 100644 --- a/src/components/gantt/Gantt.tsx +++ b/src/components/gantt/Gantt.tsx @@ -37,8 +37,8 @@ export const Gantt: React.FC = (props) => { for (let i = 0; i < groups.length; i++) { const group = groups[i] const newStep = (group.step + itemDuration) / 1.75 - const lowerBound = (group.step / 3) - 10 - const upperBound = (group.step * 3) + 10 + const lowerBound = (group.step / 2) + const upperBound = (group.step * 2) if (lowerBound < itemDuration && itemDuration < upperBound) { group.step = newStep From 3447a4fcca014f2e167c52c013de254727c1b8d6 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Mon, 31 Aug 2026 08:52:34 +0100 Subject: [PATCH 3/8] feat: enhance GanttGroup to track horizontal scroll state for improved scroll indicators --- src/components/gantt/GanttGroup.tsx | 40 ++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/components/gantt/GanttGroup.tsx b/src/components/gantt/GanttGroup.tsx index ba559bee2..5afe40cb9 100644 --- a/src/components/gantt/GanttGroup.tsx +++ b/src/components/gantt/GanttGroup.tsx @@ -35,6 +35,10 @@ export const GanttGroup: React.FC = (props) => { const [viewportWidth, setViewportWidth] = React.useState(0) const [activeGroup, setActiveGroup] = React.useState(undefined) + // Horizontal scroll state of the surrounding ScrollArea viewport, used to + // drive the "you can scroll" indicators that stick to the visible edges. + const [scrollState, setScrollState] = React.useState({scrollLeft: 0, clientWidth: 0, groupWidth: 0}) + // Parse stepWidth to pixels const stepWidthPx = React.useMemo(() => parseInt(stepWidth as string), [stepWidth]) @@ -88,6 +92,37 @@ export const GanttGroup: React.FC = (props) => { } }, []) + // Track the horizontal scroll position / size of the enclosing ScrollArea + // viewport so the scroll indicators know whether more content is available. + React.useEffect(() => { + const container = viewportRef.current + if (!container) return + const scroller = container.closest("[data-radix-scroll-area-viewport]") as HTMLElement | null + if (!scroller) return + + const update = () => setScrollState({ + scrollLeft: scroller.scrollLeft, + clientWidth: scroller.clientWidth, + groupWidth: container.offsetWidth, + }) + + update() + scroller.addEventListener("scroll", update, {passive: true}) + window.addEventListener("resize", update) + const resizeObserver = new ResizeObserver(update) + resizeObserver.observe(scroller) + resizeObserver.observe(container) + return () => { + scroller.removeEventListener("scroll", update) + window.removeEventListener("resize", update) + resizeObserver.disconnect() + } + }, []) + + // A 1px threshold avoids the indicator flickering on sub-pixel scroll ends. + const canScrollLeft = scrollState.scrollLeft > 1 + const canScrollRight = scrollState.scrollLeft + scrollState.clientWidth < scrollState.groupWidth - 1 + // Calculate row assignments (non-overlapping rows) const itemRows = items?.length ? items .sort((a, b) => a.start - b.start) @@ -105,6 +140,7 @@ export const GanttGroup: React.FC = (props) => { gridTemplateColumns: `repeat(${columnsToRender}, ${stepWidth})`, minWidth: "100%", gridColumn: "1 / -1", + position: "relative", }), [columnsToRender, stepWidth]) const rowStyle: CSSProperties = React.useMemo(() => ({ @@ -126,7 +162,9 @@ export const GanttGroup: React.FC = (props) => { start={start} step={step} avgDuration={avgDuration} - stepWidth={stepWidth}/>} + stepWidth={stepWidth} + canScrollLeft={canScrollLeft} + canScrollRight={canScrollRight}/>} {itemRows.map((row, rowIndex) => (
From 145eb5e936d6c3db3f26ce6aca56a5c0712b41f2 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Mon, 31 Aug 2026 08:52:40 +0100 Subject: [PATCH 4/8] feat: add scroll indicators to GanttHeader for improved navigation --- src/components/gantt/GanttHeader.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/gantt/GanttHeader.tsx b/src/components/gantt/GanttHeader.tsx index 454a88534..145272652 100644 --- a/src/components/gantt/GanttHeader.tsx +++ b/src/components/gantt/GanttHeader.tsx @@ -1,6 +1,7 @@ import React, {CSSProperties} from "react" import {Component, mergeComponentProps} from "../../utils" import {Text} from "../text/Text" +import {IconChevronLeft, IconChevronRight} from "@tabler/icons-react" export interface GanttHeaderProps extends Component { columnCount: number @@ -8,17 +9,24 @@ export interface GanttHeaderProps extends Component { step: number avgDuration: number stepWidth: CSSProperties["width"] + canScrollLeft?: boolean + canScrollRight?: boolean } export const GanttHeader: React.FC = (props) => { - const {columnCount, start, step, avgDuration, stepWidth, ...rest} = props + const {columnCount, start, step, avgDuration, stepWidth, canScrollLeft, canScrollRight, ...rest} = props const stepWidthPx = React.useMemo(() => parseInt(stepWidth as string), [stepWidth]) const label = React.useMemo(() => getTimelineLabel(avgDuration), [avgDuration]) const columns = React.useMemo(() => Array.from({length: columnCount}), [columnCount]) return
+ {canScrollLeft && ( +
+ +
+ )} {columns.map((_, columnIndex) => { if (columnIndex === 0) { return ( @@ -53,6 +61,11 @@ export const GanttHeader: React.FC = (props) => {
) })} + {canScrollRight && ( +
+ +
+ )}
} From 100f8ab1b3e971aaac4b73bf483a67579d7ae769 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Mon, 31 Aug 2026 09:14:59 +0100 Subject: [PATCH 5/8] feat: implement compressed time mapping for Gantt timeline to handle large gaps --- src/components/gantt/Gantt.stories.tsx | 4 +- src/components/gantt/GanttGroup.tsx | 95 +++++++++++++++++++++----- src/components/gantt/GanttHeader.tsx | 40 ++++++++++- 3 files changed, 117 insertions(+), 22 deletions(-) diff --git a/src/components/gantt/Gantt.stories.tsx b/src/components/gantt/Gantt.stories.tsx index 16a81e5b6..3ea963fc2 100644 --- a/src/components/gantt/Gantt.stories.tsx +++ b/src/components/gantt/Gantt.stories.tsx @@ -72,8 +72,8 @@ export const GanttExample = () => { }, { id: "2", - start: 199, - end: 300, + start: 999, + end: 1100, data: { icon: IconArrowRampRight2, displayMessage: "If" diff --git a/src/components/gantt/GanttGroup.tsx b/src/components/gantt/GanttGroup.tsx index 5afe40cb9..d3955cee2 100644 --- a/src/components/gantt/GanttGroup.tsx +++ b/src/components/gantt/GanttGroup.tsx @@ -4,12 +4,15 @@ import {GanttProps} from "./Gantt" import {GanttItem} from "./GanttItem" import {GanttHeader} from "./GanttHeader" -const getItemPosition = (itemStart: number, itemEnd: number, start: number, end: number, timeRange: number, totalTimelineWidth: number) => { - const relativeStart = Math.max(0, itemStart - start) - const relativeEnd = Math.min(timeRange, itemEnd - start) - const left = (relativeStart / timeRange) * totalTimelineWidth - const width = ((relativeEnd - relativeStart) / timeRange) * totalTimelineWidth - return {left, width} +// The maximum empty gap between two items, expressed in columns. Larger gaps are +// compressed to this size so the timeline "jumps" instead of leaving huge voids. +const MAX_GAP_COLUMNS = 3 + +interface TimeScale { + // Map an actual time value to its compressed ("effective") time. + effTime: (t: number) => number + // Inverse: map a compressed time back to the actual time (used for labels). + invEffTime: (e: number) => number } export interface GanttGroupProps extends GanttProps { @@ -72,8 +75,63 @@ export const GanttGroup: React.FC = (props) => { } }, [items, start, end, step]) + const {effTime, invEffTime}: TimeScale = React.useMemo(() => { + const maxGap = MAX_GAP_COLUMNS * step + + // Occupied time intervals, sorted and merged. + const merged: [number, number][] = [] + const sorted = (items ?? []).map(i => [i.start, i.end] as [number, number]).sort((a, b) => a[0] - b[0]) + for (const [s, e] of sorted) { + const last = merged[merged.length - 1] + if (last && s <= last[1]) last[1] = Math.max(last[1], e) + else merged.push([s, e]) + } + + // Collect the gaps that exceed the allowed width. + const gaps: { gapStart: number, gapEnd: number, remove: number, removedBefore: number }[] = [] + let removed = 0 + for (let i = 1; i < merged.length; i++) { + const gapStart = merged[i - 1][1] + const gapEnd = merged[i][0] + const len = gapEnd - gapStart + if (len > maxGap) { + gaps.push({gapStart, gapEnd, remove: len - maxGap, removedBefore: removed}) + removed += len - maxGap + } + } + + const effTime = (t: number) => { + let e = t + for (const g of gaps) { + if (t >= g.gapEnd) e -= g.remove + else if (t > g.gapStart + maxGap) e -= t - (g.gapStart + maxGap) + } + return e + } + + const invEffTime = (eff: number) => { + let t = eff + for (const g of gaps) { + const effGapStart = g.gapStart - g.removedBefore + if (eff >= effGapStart + maxGap) t += g.remove + } + return t + } + + return {effTime, invEffTime} + }, [items, step]) + + // Position of an item on the compressed timeline (in pixels). + const positionFor = (startT: number, endT: number) => { + const effStart = Math.max(start, effTime(startT)) + const effEnd = effTime(endT) + const left = ((effStart - start) / step) * stepWidthPx + const width = ((effEnd - effStart) / step) * stepWidthPx + return {left, width} + } + // Column rendering calculations - const columnsNeeded = items && items.length > 0 ? Math.ceil((itemMaxEnd - start) / step) : timelineColumns + const columnsNeeded = items && items.length > 0 ? Math.ceil((effTime(itemMaxEnd) - start) / step) : timelineColumns const columnsInViewport = Math.ceil(viewportWidth / stepWidthPx) const columnsToRender = Math.max(columnsInViewport, columnsNeeded + 2) @@ -163,6 +221,7 @@ export const GanttGroup: React.FC = (props) => { step={step} avgDuration={avgDuration} stepWidth={stepWidth} + timeAtColumn={(columnIndex) => invEffTime(start + columnIndex * step)} canScrollLeft={canScrollLeft} canScrollRight={canScrollRight}/>} {itemRows.map((row, rowIndex) => ( @@ -193,8 +252,8 @@ export const GanttGroup: React.FC = (props) => { ${withAlpha(hashToColor(props.id!.replace("target", "source")), 0.5)} 4px ) `, - left: `${getItemPosition(itemMinStart, itemMinStart + step, start, end, timeRange, totalTimelineWidth).left}px`, - width: `${getItemPosition(itemMinStart, itemMinStart + step, start, end, timeRange, totalTimelineWidth).width}px`, + left: `${positionFor(itemMinStart, itemMinStart + step).left}px`, + width: `${positionFor(itemMinStart, itemMinStart + step).width}px`, }} />
= (props) => { ${withAlpha(hashToColor(props.id!.replace("target", "source")), 0.5)} 4px ) `, - left: `${getItemPosition(itemMaxEnd - step, itemMaxEnd, start, end, timeRange, totalTimelineWidth).left}px`, - width: `${getItemPosition(itemMaxEnd - step, itemMaxEnd, start, end, timeRange, totalTimelineWidth).width}px`, + left: `${positionFor(itemMaxEnd - step, itemMaxEnd).left}px`, + width: `${positionFor(itemMaxEnd - step, itemMaxEnd).width}px`, }} /> )} {row.map((item, itemIndex) => { - const itemPosition = getItemPosition(item.start, item.end, start, end, timeRange, totalTimelineWidth) + const itemPosition = positionFor(item.start, item.end) const hasVisibleWidth = itemPosition.width > 0 return hasVisibleWidth && ( @@ -238,11 +297,13 @@ export const GanttGroup: React.FC = (props) => {
{row.map((item, itemIndex) => { return item.type === "group" && activeGroup === item.id && item.start))) - ((((Math.min(...item.data.items.map((item: any) => item.start))) / (item.data.firstGroupStep * item.data.step)) * (item.data.groupStep * item.data.step)))} - step={item.data.groupStep * item.data.step} - stepWidth={stepWidth} rowHeight={rowHeight} items={item.data.items} - key={`group-target-${itemIndex}`}/> + id={`group-target-${itemIndex}`} + start={(Math.min(...item.data.items.map((item: any) => item.start))) - ((((Math.min(...item.data.items.map((item: any) => item.start))) / (item.data.firstGroupStep * item.data.step)) * (item.data.groupStep * item.data.step)))} + step={item.data.groupStep * item.data.step} + stepWidth={stepWidth} + rowHeight={rowHeight} + items={item.data.items} + key={`group-target-${itemIndex}`}/> })}
))} diff --git a/src/components/gantt/GanttHeader.tsx b/src/components/gantt/GanttHeader.tsx index 145272652..a70fe0206 100644 --- a/src/components/gantt/GanttHeader.tsx +++ b/src/components/gantt/GanttHeader.tsx @@ -11,16 +11,44 @@ export interface GanttHeaderProps extends Component { stepWidth: CSSProperties["width"] canScrollLeft?: boolean canScrollRight?: boolean + // Maps a column index to the actual time shown in its label. Defaults to a + // linear mapping; the Gantt passes a compressed mapping so labels jump over + // collapsed gaps. + timeAtColumn?: (columnIndex: number) => number } export const GanttHeader: React.FC = (props) => { - const {columnCount, start, step, avgDuration, stepWidth, canScrollLeft, canScrollRight, ...rest} = props + const { + columnCount, + start, + step, + avgDuration, + stepWidth, + canScrollLeft, + canScrollRight, + timeAtColumn, + ...rest + } = props const stepWidthPx = React.useMemo(() => parseInt(stepWidth as string), [stepWidth]) const label = React.useMemo(() => getTimelineLabel(avgDuration), [avgDuration]) const columns = React.useMemo(() => Array.from({length: columnCount}), [columnCount]) + const jumpColumns = React.useMemo(() => { + if (!timeAtColumn) return new Set() + const jumps = new Set() + for (let i = 1; i < columnCount; i++) { + if (timeAtColumn(i) - timeAtColumn(i - 1) > step * 1.5) jumps.add(i) + } + return jumps + }, [timeAtColumn, columnCount, step]) + + const nearJump = (columnIndex: number) => { + for (let d = -2; d <= 2; d++) if (jumpColumns.has(columnIndex + d)) return true + return false + } + return
{canScrollLeft && (
@@ -38,10 +66,16 @@ export const GanttHeader: React.FC = (props) => { ) } - const shouldShowLabel = columnIndex % 4 === 0 + const timelineValue = timeAtColumn ? timeAtColumn(columnIndex) : start + columnIndex * step + + // The jump column (end of a compressed gap) always gets a label so it + // aligns with the item after the gap; regular cadence labels next to a + // jump are dropped so we don't render two near-identical values. + const isJump = jumpColumns.has(columnIndex) + const shouldShowLabel = isJump || (columnIndex % 4 === 0 && !nearJump(columnIndex)) + let displayValue = "" if (shouldShowLabel) { - const timelineValue = start + columnIndex * step const {value, unit} = getTimelineLabel(timelineValue) displayValue = `${Math.round(value * 10) / 10}${unit}` } From 45081b74b588b96ea0197790c650b7057308f48b Mon Sep 17 00:00:00 2001 From: nicosammito Date: Tue, 1 Sep 2026 09:02:41 +0100 Subject: [PATCH 6/8] feat: update GanttGroup to track full scrollable width for improved scroll indicators --- src/components/gantt/GanttGroup.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/gantt/GanttGroup.tsx b/src/components/gantt/GanttGroup.tsx index d3955cee2..e2ddf7ddb 100644 --- a/src/components/gantt/GanttGroup.tsx +++ b/src/components/gantt/GanttGroup.tsx @@ -40,7 +40,9 @@ export const GanttGroup: React.FC = (props) => { // Horizontal scroll state of the surrounding ScrollArea viewport, used to // drive the "you can scroll" indicators that stick to the visible edges. - const [scrollState, setScrollState] = React.useState({scrollLeft: 0, clientWidth: 0, groupWidth: 0}) + // `scrollWidth` is the viewport's full scrollable width (which can exceed + // this group's own width when a wider nested group is expanded). + const [scrollState, setScrollState] = React.useState({scrollLeft: 0, clientWidth: 0, scrollWidth: 0}) // Parse stepWidth to pixels const stepWidthPx = React.useMemo(() => parseInt(stepWidth as string), [stepWidth]) @@ -161,7 +163,7 @@ export const GanttGroup: React.FC = (props) => { const update = () => setScrollState({ scrollLeft: scroller.scrollLeft, clientWidth: scroller.clientWidth, - groupWidth: container.offsetWidth, + scrollWidth: scroller.scrollWidth, }) update() @@ -179,7 +181,7 @@ export const GanttGroup: React.FC = (props) => { // A 1px threshold avoids the indicator flickering on sub-pixel scroll ends. const canScrollLeft = scrollState.scrollLeft > 1 - const canScrollRight = scrollState.scrollLeft + scrollState.clientWidth < scrollState.groupWidth - 1 + const canScrollRight = scrollState.scrollLeft + scrollState.clientWidth < scrollState.scrollWidth - 1 // Calculate row assignments (non-overlapping rows) const itemRows = items?.length ? items From b69521a549ed14c6b91a5dd6cd14976ece6cbe52 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Tue, 22 Sep 2026 01:56:40 +0200 Subject: [PATCH 7/8] feat: refactor context menu, menu, and tooltip components to improve padding handling and scrolling behavior --- src/components/context-menu/ContextMenu.style.scss | 7 ++++++- src/components/context-menu/ContextMenu.tsx | 8 ++++++-- src/components/menu/Menu.style.scss | 7 ++++++- src/components/menu/Menu.tsx | 8 ++++++-- src/components/tooltip/Tooltip.style.scss | 7 ++++++- src/components/tooltip/Tooltip.tsx | 4 +++- 6 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/components/context-menu/ContextMenu.style.scss b/src/components/context-menu/ContextMenu.style.scss index 99719bd65..1e7a92bb8 100644 --- a/src/components/context-menu/ContextMenu.style.scss +++ b/src/components/context-menu/ContextMenu.style.scss @@ -5,7 +5,8 @@ .context-menu { &__content, &__sub-content { - padding: variables.$xxs; + // no padding here - it lives on the inner element inside the auto scroll + // area, so the scrollbar sits at the edge of the content position: relative; box-sizing: border-box; z-index: 999; @@ -20,6 +21,10 @@ } } + &__content-inner { + padding: variables.$xxs; + } + &__label { text-transform: uppercase; font-size: variables.$xs; diff --git a/src/components/context-menu/ContextMenu.tsx b/src/components/context-menu/ContextMenu.tsx index 0e8f55134..eb60e848a 100644 --- a/src/components/context-menu/ContextMenu.tsx +++ b/src/components/context-menu/ContextMenu.tsx @@ -40,7 +40,9 @@ export const ContextMenuContent: React.FC = (props) => return - {children} +
+ {children} +
} @@ -69,7 +71,9 @@ export const ContextMenuSubContent: React.FC = (prop const {children, ...rest} = props return - {children} +
+ {children} +
} diff --git a/src/components/menu/Menu.style.scss b/src/components/menu/Menu.style.scss index 8a736a4e4..f38ca831d 100644 --- a/src/components/menu/Menu.style.scss +++ b/src/components/menu/Menu.style.scss @@ -5,7 +5,8 @@ .menu { &__content, &__sub-content { - padding: variables.$xxs; + // no padding here - it lives on the inner element inside the auto scroll + // area, so the scrollbar sits at the edge of the content position: relative; box-sizing: border-box; z-index: 999; @@ -20,6 +21,10 @@ } } + &__content-inner { + padding: variables.$xxs; + } + &__label { text-transform: uppercase; font-size: variables.$xs; diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx index ac6db106a..f9e73f779 100644 --- a/src/components/menu/Menu.tsx +++ b/src/components/menu/Menu.tsx @@ -70,7 +70,9 @@ export const MenuContent: React.FC = (props) => { return - {children} +
+ {children} +
} @@ -99,7 +101,9 @@ export const MenuSubContent: React.FC = (props) => { const {children, ...rest} = props return - {children} +
+ {children} +
} diff --git a/src/components/tooltip/Tooltip.style.scss b/src/components/tooltip/Tooltip.style.scss index d9478dc70..340ea4cc2 100644 --- a/src/components/tooltip/Tooltip.style.scss +++ b/src/components/tooltip/Tooltip.style.scss @@ -7,7 +7,8 @@ &__content { z-index: 999; - padding: variables.$xxs variables.$xs; + // no padding here - it lives on the inner element inside the auto scroll + // area, so the scrollbar sits at the edge of the content box-sizing: border-box; // clamp to the space the popper has left on screen; the inner // auto scroll area shrinks with it and takes over scrolling @@ -22,6 +23,10 @@ } } + &__content-inner { + padding: variables.$xxs variables.$xs; + } + &__arrow { fill: helpers.backgroundColor(variables.$tertiary); } diff --git a/src/components/tooltip/Tooltip.tsx b/src/components/tooltip/Tooltip.tsx index 7659cf3cb..be4498d47 100644 --- a/src/components/tooltip/Tooltip.tsx +++ b/src/components/tooltip/Tooltip.tsx @@ -29,7 +29,9 @@ export const TooltipContent: React.FC = (props) => { return - {children} +
+ {children} +
} From f080e37f3cae880e2fc2ccc6f09cb871c4bcf8cf Mon Sep 17 00:00:00 2001 From: nicosammito Date: Tue, 22 Sep 2026 08:51:41 +0200 Subject: [PATCH 8/8] feat: enhance Gantt components with improved sticky behavior and gap handling for better timeline accuracy --- src/components/gantt/Gantt.style.scss | 23 ++++--- src/components/gantt/GanttGroup.tsx | 66 +++++++++++++------ src/components/gantt/GanttHeader.tsx | 92 ++++++++++----------------- 3 files changed, 96 insertions(+), 85 deletions(-) diff --git a/src/components/gantt/Gantt.style.scss b/src/components/gantt/Gantt.style.scss index 68eeb27b1..d13c971a7 100644 --- a/src/components/gantt/Gantt.style.scss +++ b/src/components/gantt/Gantt.style.scss @@ -26,7 +26,7 @@ grid-column: 1 / -1; display: flex; top: 0; - background: #070514; + background: variables.$bodyBg; border-bottom: 1px solid rgba(255, 255, 255, 0.25); z-index: 10; @@ -49,7 +49,6 @@ } &-scroll { - position: sticky; display: flex; align-items: center; justify-content: center; @@ -58,19 +57,26 @@ background: variables.$bodyBg; z-index: 3; - &--left { - left: 0; - } - &--right { + position: sticky; right: 0; margin-left: auto; } } - &-label-column { + // Sticky left block: scroll indicator first, label next to it. + &-start { position: sticky; left: 0; + width: fit-content; + height: 100%; + display: flex; + align-items: center; + background: variables.$bodyBg; + z-index: 2; + } + + &-label-column { width: fit-content; height: 100%; padding: 8px; @@ -78,8 +84,7 @@ align-items: center; justify-content: start; box-sizing: border-box; - background: #070514; - z-index: 2; + background: variables.$bodyBg; } } } \ No newline at end of file diff --git a/src/components/gantt/GanttGroup.tsx b/src/components/gantt/GanttGroup.tsx index e2ddf7ddb..3a0d6c27c 100644 --- a/src/components/gantt/GanttGroup.tsx +++ b/src/components/gantt/GanttGroup.tsx @@ -13,6 +13,17 @@ interface TimeScale { effTime: (t: number) => number // Inverse: map a compressed time back to the actual time (used for labels). invEffTime: (e: number) => number + // True while `t` sits inside a compressed gap, where the timeline jumps and + // no label may be placed. + inGap: (t: number) => boolean +} + +// Pick a "round" interval (1, 2, 5 or 10 x 10^n) close to the target spacing, so +// header labels land on values a human reads as whole (50μs, 0.1s, ...). +const niceInterval = (target: number) => { + const magnitude = Math.pow(10, Math.floor(Math.log10(target))) + const normalized = target / magnitude + return (normalized < 1.5 ? 1 : normalized < 3 ? 2 : normalized < 7 ? 5 : 10) * magnitude } export interface GanttGroupProps extends GanttProps { @@ -77,7 +88,7 @@ export const GanttGroup: React.FC = (props) => { } }, [items, start, end, step]) - const {effTime, invEffTime}: TimeScale = React.useMemo(() => { + const {effTime, invEffTime, inGap}: TimeScale = React.useMemo(() => { const maxGap = MAX_GAP_COLUMNS * step // Occupied time intervals, sorted and merged. @@ -89,24 +100,27 @@ export const GanttGroup: React.FC = (props) => { else merged.push([s, e]) } - // Collect the gaps that exceed the allowed width. - const gaps: { gapStart: number, gapEnd: number, remove: number, removedBefore: number }[] = [] + // Collect the gaps that exceed the allowed width. `effGapEnd` is snapped to a + // column boundary so the item following a jump starts exactly on a grid line - + // otherwise it and the header label of that column drift apart by up to a column. + const gaps: { gapStart: number, gapEnd: number, effGapEnd: number, remove: number }[] = [] let removed = 0 for (let i = 1; i < merged.length; i++) { const gapStart = merged[i - 1][1] const gapEnd = merged[i][0] - const len = gapEnd - gapStart - if (len > maxGap) { - gaps.push({gapStart, gapEnd, remove: len - maxGap, removedBefore: removed}) - removed += len - maxGap - } + if (gapEnd - gapStart <= maxGap) continue + const columns = Math.round((gapStart - removed + maxGap - start) / step) + const effGapEnd = start + columns * step + const remove = gapEnd - removed - effGapEnd + gaps.push({gapStart, gapEnd, effGapEnd, remove}) + removed += remove } const effTime = (t: number) => { let e = t for (const g of gaps) { if (t >= g.gapEnd) e -= g.remove - else if (t > g.gapStart + maxGap) e -= t - (g.gapStart + maxGap) + else if (t > g.gapStart) e = Math.min(e, g.effGapEnd) } return e } @@ -114,14 +128,15 @@ export const GanttGroup: React.FC = (props) => { const invEffTime = (eff: number) => { let t = eff for (const g of gaps) { - const effGapStart = g.gapStart - g.removedBefore - if (eff >= effGapStart + maxGap) t += g.remove + if (eff >= g.effGapEnd) t += g.remove } return t } - return {effTime, invEffTime} - }, [items, step]) + const inGap = (t: number) => gaps.some(g => t > g.gapStart && t < g.gapEnd) + + return {effTime, invEffTime, inGap} + }, [items, step, start]) // Position of an item on the compressed timeline (in pixels). const positionFor = (startT: number, endT: number) => { @@ -137,6 +152,23 @@ export const GanttGroup: React.FC = (props) => { const columnsInViewport = Math.ceil(viewportWidth / stepWidthPx) const columnsToRender = Math.max(columnsInViewport, columnsNeeded + 2) + // Header labels are anchored to round time values and positioned through the + // same compressed scale as the items, so a label lines up with the item edge + // it describes instead of drifting onto the nearest column. + const {headerTicks, headerInterval} = React.useMemo(() => { + const interval = niceInterval(step * 3) + const ticks: { time: number, left: number }[] = [] + if (!(interval > 0)) return {headerTicks: ticks, headerInterval: 1} + const maxTime = invEffTime(start + columnsToRender * step) + const first = Math.ceil(start / interval) * interval + for (let i = 0; first + i * interval <= maxTime; i++) { + const time = first + i * interval + if (inGap(time)) continue + ticks.push({time, left: ((effTime(time) - start) / step) * stepWidthPx}) + } + return {headerTicks: ticks, headerInterval: interval} + }, [effTime, invEffTime, inGap, start, step, columnsToRender, stepWidthPx]) + React.useEffect(() => { const handleResize = () => { setViewportWidth(viewportRef.current?.offsetWidth ?? 0) @@ -218,12 +250,10 @@ export const GanttGroup: React.FC = (props) => { return (
- {!hideScaling && invEffTime(start + columnIndex * step)} canScrollLeft={canScrollLeft} canScrollRight={canScrollRight}/>} {itemRows.map((row, rowIndex) => ( @@ -312,4 +342,4 @@ export const GanttGroup: React.FC = (props) => {
) -} \ No newline at end of file +} diff --git a/src/components/gantt/GanttHeader.tsx b/src/components/gantt/GanttHeader.tsx index a70fe0206..f591edeeb 100644 --- a/src/components/gantt/GanttHeader.tsx +++ b/src/components/gantt/GanttHeader.tsx @@ -4,93 +4,66 @@ import {Text} from "../text/Text" import {IconChevronLeft, IconChevronRight} from "@tabler/icons-react" export interface GanttHeaderProps extends Component { - columnCount: number - start: number - step: number avgDuration: number stepWidth: CSSProperties["width"] canScrollLeft?: boolean canScrollRight?: boolean - // Maps a column index to the actual time shown in its label. Defaults to a - // linear mapping; the Gantt passes a compressed mapping so labels jump over - // collapsed gaps. - timeAtColumn?: (columnIndex: number) => number + // Round time values with their pixel offset on the (possibly compressed) + // timeline. Positions come from the same scale the items use, so a label sits + // exactly on the time it names. + ticks: { time: number, left: number }[] + // Spacing between two ticks, in raw time units. Drives how many decimals a + // label needs to stay distinguishable from its neighbours. + interval: number } export const GanttHeader: React.FC = (props) => { const { - columnCount, - start, - step, avgDuration, stepWidth, canScrollLeft, canScrollRight, - timeAtColumn, + ticks, + interval, ...rest } = props - const stepWidthPx = React.useMemo(() => parseInt(stepWidth as string), [stepWidth]) const label = React.useMemo(() => getTimelineLabel(avgDuration), [avgDuration]) - const columns = React.useMemo(() => Array.from({length: columnCount}), [columnCount]) - const jumpColumns = React.useMemo(() => { - if (!timeAtColumn) return new Set() - const jumps = new Set() - for (let i = 1; i < columnCount; i++) { - if (timeAtColumn(i) - timeAtColumn(i - 1) > step * 1.5) jumps.add(i) - } - return jumps - }, [timeAtColumn, columnCount, step]) - - const nearJump = (columnIndex: number) => { - for (let d = -2; d <= 2; d++) if (jumpColumns.has(columnIndex + d)) return true - return false + const formatTick = (time: number) => { + const {value, unit} = getTimelineLabel(time) + const decimals = Math.max(0, Math.ceil(-Math.log10(interval / unitFactor(unit)))) + return `${parseFloat(value.toFixed(decimals))}${unit}` } return
- {canScrollLeft && ( -
- + {/* Chevron and label share one sticky block so the indicator sits next to + the label instead of on top of it. */} +
+ {canScrollLeft && ( +
+ +
+ )} +
+ + Range in {label.unit} +
- )} - {columns.map((_, columnIndex) => { - if (columnIndex === 0) { - return ( -
- - Range in {label.unit} - -
- ) - } - - const timelineValue = timeAtColumn ? timeAtColumn(columnIndex) : start + columnIndex * step - - // The jump column (end of a compressed gap) always gets a label so it - // aligns with the item after the gap; regular cadence labels next to a - // jump are dropped so we don't render two near-identical values. - const isJump = jumpColumns.has(columnIndex) - const shouldShowLabel = isJump || (columnIndex % 4 === 0 && !nearJump(columnIndex)) - - let displayValue = "" - if (shouldShowLabel) { - const {value, unit} = getTimelineLabel(timelineValue) - displayValue = `${Math.round(value * 10) / 10}${unit}` - } - +
+ {ticks.map(({time, left}) => { return (
- {displayValue} + {formatTick(time)}
) @@ -111,4 +84,7 @@ const getTimelineLabel = (duration: number): { value: number, unit: string } => return {value: duration / 1_000, unit: "ms"} } return {value: duration, unit: "μs"} -} \ No newline at end of file +} + +// Raw time units per display unit - the inverse of the divisors above. +const unitFactor = (unit: string): number => unit === "s" ? 1_000_000 : unit === "ms" ? 1_000 : 1 \ No newline at end of file