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/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/Gantt.style.scss b/src/components/gantt/Gantt.style.scss index 24c9efba0..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; @@ -48,9 +48,35 @@ align-items: center; } - &-label-column { + &-scroll { + display: flex; + align-items: center; + justify-content: center; + padding: 0 variables.$xxxs; + color: variables.$white; + background: variables.$bodyBg; + z-index: 3; + + &--right { + position: sticky; + right: 0; + margin-left: auto; + } + } + + // 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; @@ -58,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/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 diff --git a/src/components/gantt/GanttGroup.tsx b/src/components/gantt/GanttGroup.tsx index ba559bee2..3a0d6c27c 100644 --- a/src/components/gantt/GanttGroup.tsx +++ b/src/components/gantt/GanttGroup.tsx @@ -4,12 +4,26 @@ 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 + // 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 { @@ -35,6 +49,12 @@ 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. + // `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]) @@ -68,11 +88,87 @@ export const GanttGroup: React.FC = (props) => { } }, [items, start, end, step]) + const {effTime, invEffTime, inGap}: 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. `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] + 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) e = Math.min(e, g.effGapEnd) + } + return e + } + + const invEffTime = (eff: number) => { + let t = eff + for (const g of gaps) { + if (eff >= g.effGapEnd) t += g.remove + } + return t + } + + 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) => { + 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) + // 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) @@ -88,6 +184,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, + scrollWidth: scroller.scrollWidth, + }) + + 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.scrollWidth - 1 + // Calculate row assignments (non-overlapping rows) const itemRows = items?.length ? items .sort((a, b) => a.start - b.start) @@ -105,6 +232,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(() => ({ @@ -122,11 +250,12 @@ export const GanttGroup: React.FC = (props) => { return (
- {!hideScaling && } + stepWidth={stepWidth} + canScrollLeft={canScrollLeft} + canScrollRight={canScrollRight}/>} {itemRows.map((row, rowIndex) => (
@@ -155,8 +284,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 && ( @@ -200,15 +329,17 @@ 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}`}/> })} ))}
) -} \ No newline at end of file +} diff --git a/src/components/gantt/GanttHeader.tsx b/src/components/gantt/GanttHeader.tsx index 454a88534..f591edeeb 100644 --- a/src/components/gantt/GanttHeader.tsx +++ b/src/components/gantt/GanttHeader.tsx @@ -1,58 +1,78 @@ 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 - start: number - step: number avgDuration: number stepWidth: CSSProperties["width"] + canScrollLeft?: boolean + canScrollRight?: boolean + // 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, ...rest} = props + const { + avgDuration, + stepWidth, + canScrollLeft, + canScrollRight, + 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]) - return
- {columns.map((_, columnIndex) => { - if (columnIndex === 0) { - return ( -
- - Range in {label.unit} - -
- ) - } - - const shouldShowLabel = columnIndex % 4 === 0 - let displayValue = "" - if (shouldShowLabel) { - const timelineValue = start + columnIndex * step - const {value, unit} = getTimelineLabel(timelineValue) - displayValue = `${Math.round(value * 10) / 10}${unit}` - } + 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
+ {/* 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} + +
+
+ {ticks.map(({time, left}) => { return (
- {displayValue} + {formatTick(time)}
) })} + {canScrollRight && ( +
+ +
+ )}
} @@ -64,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 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} +
}