From 8b07615a0fb2b786abc0471a3ee6d9dde2900ef2 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 13 Apr 2026 18:28:03 +0200 Subject: [PATCH 01/13] Replace SVG simulation timeline with uPlot Migrate the simulation timeline chart from a custom SVG implementation to uPlot for better performance and rendering quality. - 2px antialiased lines (pxAlign: false), HiDPI-aware - Streaming: maintain uPlot columnar data directly in refs, push new frames in-place instead of copying existing data on every batch - Playhead drawn on canvas via uPlot's `draw` hook for pixel-perfect alignment with the plot area (accounts for axes/padding) - Run and stacked chart types preserved - Click/drag scrubbing via cursor bind handlers - Legend with click-to-hide Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/@hashintel/petrinaut/package.json | 1 + .../subviews/simulation-timeline.tsx | 1532 ++++------------- yarn.lock | 8 + 3 files changed, 389 insertions(+), 1152 deletions(-) diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index f5a4408993c..2e348dcc27d 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -52,6 +52,7 @@ "react-icons": "5.5.0", "react-resizable-panels": "4.6.5", "typescript": "5.9.3", + "uplot": "1.6.32", "uuid": "13.0.0", "vscode-languageserver-types": "3.17.5", "web-worker": "1.4.1", diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index 304cefa0a4b..472f35a6140 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -1,6 +1,7 @@ import { css } from "@hashintel/ds-helpers/css"; -import { scaleLinear } from "d3-scale"; import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import uPlot from "uplot"; +import "uplot/dist/uPlot.min.css"; import { SegmentGroup } from "../../../../../components/segment-group"; import type { SubView } from "../../../../../components/sub-view/types"; @@ -12,41 +13,7 @@ import { } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; -/** - * Computes the maximum value from an array using a selector function. - * Performs a single pass over the array without creating intermediate copies. - * - * @param array - The array to iterate over - * @param selector - A function that extracts values to compare from each element. - * Can return a single number or an array of numbers. - * @param initialValue - The initial maximum value (defaults to -Infinity) - * @returns The maximum value found, or initialValue if array is empty - */ -const max = ( - array: readonly T[], - selector: (item: T) => number | readonly number[], - initialValue = Number.NEGATIVE_INFINITY, -): number => { - let result = initialValue; - - for (const item of array) { - const value = selector(item); - - if (typeof value === "number") { - if (value > result) { - result = value; - } - } else { - for (const val of value) { - if (val > result) { - result = val; - } - } - } - } - - return result; -}; +// -- Styles ------------------------------------------------------------------- const containerStyle = css({ display: "flex", @@ -55,145 +22,15 @@ const containerStyle = css({ gap: "[8px]", }); -const chartRowStyle = css({ - display: "flex", - flex: "[1]", - minHeight: "[60px]", - gap: "[4px]", -}); - -const yAxisStyle = css({ +const chartAreaStyle = css({ position: "relative", - display: "flex", - flexDirection: "column", - alignItems: "flex-end", - fontSize: "[10px]", - color: "[#666]", - paddingRight: "[4px]", - minWidth: "[32px]", - userSelect: "none", -}); - -const yAxisTickStyle = css({ - position: "absolute", - right: "[4px]", - lineHeight: "[1]", - transform: "translateY(-50%)", -}); - -const chartContainerStyle = css({ flex: "[1]", - position: "relative", - cursor: "pointer", + minHeight: "[0]", }); -const playheadStyle = css({ +const chartWrapperStyle = css({ position: "absolute", - top: "[0]", - bottom: "[0]", - width: "[1px]", - pointerEvents: "none", - display: "flex", - flexDirection: "column", - alignItems: "center", -}); - -const playheadLineStyle = css({ - flex: "[1]", - width: "[1.5px]", - background: "[#333]", -}); - -const playheadArrowStyle = css({ - width: "[0]", - height: "[0]", - borderLeft: "[5px solid transparent]", - borderRight: "[5px solid transparent]", - borderTop: "[7px solid #333]", - marginBottom: "[-1px]", -}); - -const svgStyle = css({ - width: "[100%]", - height: "[100%]", - display: "block", -}); - -/** - * CSS-based hover dimming for run chart lines. - * When hovering over any line, all other lines dim. - * This is much faster than React re-renders because: - * 1. Only CSS changes, no React reconciliation - * 2. Browser optimizes opacity transitions natively - * 3. PlaceLine components don't re-render on hover - */ -const runChartHoveringStyle = css({ - // When the chart has a hovered element, dim all place groups - '&[data-has-hover="true"] g[data-place-id]': { - opacity: "[0.2]", - }, - // The hovered element stays fully visible - '&[data-has-hover="true"] g[data-place-id][data-hovered="true"]': { - opacity: "[1]", - }, - // Transition for smooth dimming - "& g[data-place-id]": { - transition: "[opacity 0.15s ease]", - }, -}); - -/** - * CSS-based hover dimming for stacked area chart. - * Similar to run chart but with different base opacities. - */ -const stackedChartHoveringStyle = css({ - // When hovering, dim all areas - '&[data-has-hover="true"] path[data-place-id]': { - opacity: "[0.3]", - }, - // The hovered element gets full opacity - '&[data-has-hover="true"] path[data-place-id][data-hovered="true"]': { - opacity: "[1]", - }, - // Base opacity for non-hover state - "& path[data-place-id]": { - opacity: "[0.7]", - transition: "[opacity 0.15s ease]", - }, -}); - -const tooltipStyle = css({ - position: "fixed", - pointerEvents: "none", - backgroundColor: "[rgba(0, 0, 0, 0.85)]", - color: "neutral.s00", - padding: "[6px 10px]", - borderRadius: "md", - fontSize: "[11px]", - lineHeight: "[1.4]", - zIndex: "[1000]", - whiteSpace: "nowrap", - boxShadow: "[0 2px 8px rgba(0, 0, 0, 0.25)]", - transform: "translate(-50%, -100%)", - marginTop: "[-8px]", -}); - -const tooltipLabelStyle = css({ - display: "flex", - alignItems: "center", - gap: "[6px]", -}); - -const tooltipColorDotStyle = css({ - width: "[8px]", - height: "[8px]", - borderRadius: "[50%]", - flexShrink: "[0]", -}); - -const tooltipValueStyle = css({ - fontWeight: "semibold", - marginLeft: "[4px]", + inset: "[0]", }); const legendContainerStyle = css({ @@ -223,7 +60,8 @@ const legendColorStyle = css({ borderRadius: "[2px]", }); -// Default color palette for places without a specific color +// -- Constants ---------------------------------------------------------------- + const DEFAULT_COLORS = [ "#3b82f6", // blue "#ef4444", // red @@ -240,9 +78,17 @@ const CHART_TYPE_OPTIONS = [ { value: "stacked", label: "Stacked" }, ]; -/** - * Header action component that renders a chart type selector. - */ +// -- Types -------------------------------------------------------------------- + +/** Metadata for each place (stable across streaming updates). */ +interface PlaceMeta { + placeId: string; + placeName: string; + color: string; +} + +// -- Header action ------------------------------------------------------------ + const TimelineChartTypeSelector: React.FC = () => { const { timelineChartType: chartType, setTimelineChartType: setChartType } = use(EditorContext); @@ -257,1050 +103,457 @@ const TimelineChartTypeSelector: React.FC = () => { ); }; -interface CompartmentData { - placeId: string; - placeName: string; - color: string; - values: number[]; // token count at each frame -} +// -- Streaming data hook (uPlot-native columnar format) ----------------------- /** - * Return type for useCompartmentData hook. - * Includes both compartment data and frame times for tooltip display. + * Streaming data store that builds uPlot columnar arrays directly. + * New frames are pushed in O(k) where k = new frames, no full-array copies. */ -interface CompartmentDataResult { - compartmentData: CompartmentData[]; - frameTimes: number[]; +interface StreamingStore { + /** Place metadata (stable) */ + places: PlaceMeta[]; + /** Columnar arrays: [times, ...placeValues] — mutated in place */ + columns: number[][]; + /** Current frame count in the columns */ + length: number; + /** Revision counter — incremented on every append to trigger React updates */ + revision: number; } -/** - * Shared legend state interface for chart components. - */ -interface LegendState { - hiddenPlaces: Set; - hoveredPlaceId: string | null; +function createEmptyStore(places: PlaceMeta[]): StreamingStore { + return { + places, + columns: [[], ...places.map(() => [])], + length: 0, + revision: 0, + }; } /** - * Tooltip state for displaying token counts on hover. + * Hook that streams simulation frames directly into uPlot columnar arrays. + * Returns a store ref (mutated in place) and a revision counter for React. */ -interface TooltipState { - visible: boolean; - x: number; - y: number; - placeName: string; - color: string; - value: number; - frameIndex: number; - time: number; -} +function useStreamingData(): { + store: StreamingStore; + revision: number; +} { + "use no memo"; // imperative streaming with refs -/** - * Hook to extract compartment data from simulation frames. - * Uses incremental fetching via getFramesInRange() to only process new frames. - */ -const useCompartmentData = (): CompartmentDataResult => { const { getFramesInRange, totalFrames } = use(SimulationContext); const { petriNetDefinition: { places, types }, } = use(SDCPNContext); - const [result, setResult] = useState({ - compartmentData: [], - frameTimes: [], - }); - - // Track the number of frames we've already processed - const processedFrameCountRef = useRef(0); - - // Compute place colors once (memoized) - const placeColors = useMemo(() => { - const colors = new Map(); - for (const [index, place] of places.entries()) { - const tokenType = types.find((type) => type.id === place.colorId); - const color = - tokenType?.displayColor ?? - DEFAULT_COLORS[index % DEFAULT_COLORS.length]!; - colors.set(place.id, color); - } - return colors; - }, [places, types]); + const placeMeta: PlaceMeta[] = useMemo( + () => + places.map((place, index) => { + const tokenType = types.find((type) => type.id === place.colorId); + return { + placeId: place.id, + placeName: place.name, + color: + tokenType?.displayColor ?? + DEFAULT_COLORS[index % DEFAULT_COLORS.length]!, + }; + }), + [places, types], + ); + + const storeRef = useRef(createEmptyStore(placeMeta)); + const processedRef = useRef(0); + const [revision, setRevision] = useState(0); + + // Reset store if place structure changes + useEffect(() => { + storeRef.current = createEmptyStore(placeMeta); + processedRef.current = 0; + setRevision((r) => r + 1); + }, [placeMeta]); + // Stream new frames into the store useEffect(() => { let cancelled = false; const fetchData = async () => { - // Reset if simulation was reset (totalFrames dropped) + const store = storeRef.current; + if (totalFrames === 0) { - processedFrameCountRef.current = 0; - setResult({ compartmentData: [], frameTimes: [] }); + if (store.length > 0) { + storeRef.current = createEmptyStore(store.places); + processedRef.current = 0; + setRevision((r) => r + 1); + } return; } - // Check if we need to reset (e.g., simulation was restarted) - if (totalFrames < processedFrameCountRef.current) { - processedFrameCountRef.current = 0; + // Handle simulation restart + if (totalFrames < processedRef.current) { + storeRef.current = createEmptyStore(store.places); + processedRef.current = 0; } - const startIndex = processedFrameCountRef.current; - - // Nothing new to process + const startIndex = processedRef.current; if (startIndex >= totalFrames) { return; } - // Fetch only new frames const newFrames = await getFramesInRange(startIndex); if (cancelled || newFrames.length === 0) { return; } - setResult((prev) => { - // Performance optimization: O(p + f) instead of O(p * f) - // First: set up place structure (iterate places once) - const placeStructure = places.map((place, placeIndex) => ({ - placeId: place.id, - placeName: place.name, - color: - placeColors.get(place.id) ?? - DEFAULT_COLORS[placeIndex % DEFAULT_COLORS.length]!, - existingValues: prev.compartmentData[placeIndex]?.values ?? [], - newValues: [] as number[], - })); - - // Second: iterate frames once, extracting token counts for all places per frame - const newFrameTimes: number[] = []; - for (const frame of newFrames) { - newFrameTimes.push(frame.time); - for (const placeData of placeStructure) { - const tokenCount = frame.places[placeData.placeId]?.count ?? 0; - placeData.newValues.push(tokenCount); - } - } + // Push new data directly into existing arrays — O(k) where k = new frames + const cols = storeRef.current.columns; + const timeCol = cols[0]!; + const placeList = storeRef.current.places; - // Third: build final compartmentData from accumulated values - const newCompartmentData = placeStructure.map((placeData) => ({ - placeId: placeData.placeId, - placeName: placeData.placeName, - color: placeData.color, - values: [...placeData.existingValues, ...placeData.newValues], - })); + for (const frame of newFrames) { + timeCol.push(frame.time); + for (let p = 0; p < placeList.length; p++) { + const count = frame.places[placeList[p]!.placeId]?.count ?? 0; + cols[p + 1]!.push(count); + } + } - return { - compartmentData: newCompartmentData, - frameTimes: [...prev.frameTimes, ...newFrameTimes], - }; - }); + storeRef.current.length = timeCol.length; + storeRef.current.revision++; + processedRef.current = totalFrames; - processedFrameCountRef.current = totalFrames; + // Single state update to trigger React re-render + setRevision((r) => r + 1); }; void fetchData(); - return () => { cancelled = true; }; - }, [getFramesInRange, totalFrames, places, placeColors]); + }, [getFramesInRange, totalFrames]); - return result; -}; + return { store: storeRef.current, revision }; +} -/** - * Represents the Y-axis scale configuration. - */ -interface YAxisScale { - /** The maximum value for the Y-axis (after applying .nice()) */ - yMax: number; - /** Tick values to display on the Y-axis */ - ticks: number[]; - /** Convert a data value to a percentage (0-100) for SVG positioning */ - toPercent: (value: number) => number; +// -- uPlot data builders (from store, no copies for run chart) ---------------- + +function buildRunData( + store: StreamingStore, + hiddenPlaces: Set, +): uPlot.AlignedData { + const result: (number | null | undefined)[][] = [store.columns[0]!]; + for (let i = 0; i < store.places.length; i++) { + if (hiddenPlaces.has(store.places[i]!.placeId)) { + // Hidden series: array of nulls (uPlot skips nulls) + result.push(new Array(store.length).fill(null)); + } else { + // Visible series: direct reference to the column array (no copy!) + result.push(store.columns[i + 1]!); + } + } + return result as uPlot.AlignedData; } -/** - * Computes a nice Y-axis scale using D3's scale utilities. - * Returns tick values that are round numbers appropriate for the data range. - */ -const useYAxisScale = ( - compartmentData: CompartmentData[], - chartType: TimelineChartType, +function buildStackedData( + store: StreamingStore, hiddenPlaces: Set, -): YAxisScale => { - return useMemo(() => { - if (compartmentData.length === 0) { - return { - yMax: 10, - ticks: [0, 5, 10], - toPercent: (value: number) => 100 - (value / 10) * 100, - }; +): uPlot.AlignedData { + const visible = store.places + .map((p, i) => ({ ...p, colIdx: i + 1 })) + .filter((p) => !hiddenPlaces.has(p.placeId)); + + const cumulative = new Float64Array(store.length); + const series: number[][] = []; + + for (const p of visible) { + const col = store.columns[p.colIdx]!; + const stacked = new Array(store.length); + for (let i = 0; i < store.length; i++) { + cumulative[i]! += col[i] ?? 0; + stacked[i] = cumulative[i]!; } + series.push(stacked); + } - // Filter to visible data - const visibleData = compartmentData.filter( - (item) => !hiddenPlaces.has(item.placeId), - ); + // Reverse so top band is first + series.reverse(); + + return [store.columns[0]!, ...series] as uPlot.AlignedData; +} - let maxValue: number; +// -- uPlot options builder ---------------------------------------------------- - if (chartType === "stacked") { - // For stacked chart, calculate the maximum cumulative value - if (visibleData.length === 0) { - maxValue = 1; - } else { - const frameCount = visibleData[0]?.values.length ?? 0; - let maxCumulative = 0; - for (let frameIdx = 0; frameIdx < frameCount; frameIdx++) { - let cumulative = 0; - for (const data of visibleData) { - cumulative += data.values[frameIdx] ?? 0; +function buildUPlotOptions( + store: StreamingStore, + chartType: TimelineChartType, + hiddenPlaces: Set, + width: number, + height: number, + onScrub: (frameIndex: number) => void, + getPlayheadFrame: () => number, +): uPlot.Options { + const series: uPlot.Series[] = [{ label: "Time" }]; + + if (chartType === "stacked") { + const visible = store.places.filter((p) => !hiddenPlaces.has(p.placeId)); + const reversed = [...visible].reverse(); + for (const p of reversed) { + series.push({ + label: p.placeName, + stroke: p.color, + fill: `${p.color}88`, + width: 2, + }); + } + } else { + for (const p of store.places) { + series.push({ + label: p.placeName, + stroke: p.color, + width: 2, + show: !hiddenPlaces.has(p.placeId), + }); + } + } + + return { + width, + height, + series, + pxAlign: false, + cursor: { + lock: true, + drag: { x: false, y: false, setScale: false }, + bind: { + mousedown: (u, _targ, handler) => (e: MouseEvent) => { + handler(e); + if (u.cursor.left != null && u.cursor.left >= 0) { + onScrub(u.posToIdx(u.cursor.left)); } - maxCumulative = Math.max(maxCumulative, cumulative); - } - maxValue = Math.max(1, maxCumulative); - } - } else { - // For run chart, find the maximum individual value - maxValue = max(visibleData, (item) => item.values, 1); + return null; + }, + mousemove: (u, _targ, handler) => (e: MouseEvent) => { + handler(e); + if (e.buttons === 1 && u.cursor.left != null && u.cursor.left >= 0) { + onScrub(u.posToIdx(u.cursor.left)); + } + return null; + }, + }, + }, + legend: { show: false }, + axes: [ + { + show: true, + size: 24, + font: "10px system-ui", + stroke: "#999", + grid: { stroke: "#f3f4f6", width: 1 }, + ticks: { stroke: "#e5e7eb", width: 1 }, + values: (_u, vals) => vals.map((v) => `${v}s`), + }, + { + show: true, + size: 50, + font: "10px system-ui", + stroke: "#999", + grid: { stroke: "#f3f4f6", width: 1, dash: [4, 4] }, + ticks: { stroke: "#e5e7eb", width: 1 }, + }, + ], + scales: { + x: { time: false, auto: true }, + y: { + auto: true, + range: (_u, min, max) => [Math.min(0, min), max * 1.05], + }, + }, + hooks: { + draw: [ + (u) => { + const frameIdx = getPlayheadFrame(); + const times = u.data[0]!; + if (times.length === 0) { + return; + } + const time = times[Math.min(frameIdx, times.length - 1)]!; + const cx = u.valToPos(time, "x", true); + const plotTop = u.bbox.top / devicePixelRatio; + const plotHeight = u.bbox.height / devicePixelRatio; + const ctx = u.ctx; + + ctx.save(); + // Arrow head + ctx.fillStyle = "#333"; + ctx.beginPath(); + ctx.moveTo(cx - 5, plotTop); + ctx.lineTo(cx + 5, plotTop); + ctx.lineTo(cx, plotTop + 7); + ctx.closePath(); + ctx.fill(); + // Vertical line + ctx.strokeStyle = "#333"; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(cx, plotTop + 6); + ctx.lineTo(cx, plotTop + plotHeight); + ctx.stroke(); + ctx.restore(); + }, + ], + }, + }; +} + +// -- uPlot chart component ---------------------------------------------------- + +const UPlotChart: React.FC<{ + store: StreamingStore; + chartType: TimelineChartType; + hiddenPlaces: Set; + revision: number; + totalFrames: number; + currentFrameIndex: number; +}> = ({ + store, + chartType, + hiddenPlaces, + revision, + totalFrames, + currentFrameIndex, +}) => { + "use no memo"; // imperative uPlot lifecycle + const { setCurrentViewedFrame } = use(PlaybackContext); + const wrapperRef = useRef(null); + const chartRef = useRef(null); + const playheadFrameRef = useRef(currentFrameIndex); + playheadFrameRef.current = currentFrameIndex; + + const onScrub = useCallback( + (idx: number) => { + setCurrentViewedFrame(Math.max(0, Math.min(idx, totalFrames - 1))); + }, + [setCurrentViewedFrame, totalFrames], + ); + + // Build data from store + const data = + chartType === "stacked" + ? buildStackedData(store, hiddenPlaces) + : buildRunData(store, hiddenPlaces); + + // Create/recreate chart when structure changes + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper || store.length === 0) { + return; } - // Use D3 to create a nice scale - const scale = scaleLinear().domain([0, maxValue]).nice(); - const niceDomain = scale.domain(); - const yMax = niceDomain[1] ?? maxValue; + const rect = wrapper.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + return; + } - // Get tick values (aim for 3-5 ticks based on the range) - const ticks = scale.ticks(4); + const opts = buildUPlotOptions( + store, + chartType, + hiddenPlaces, + rect.width, + rect.height, + onScrub, + () => playheadFrameRef.current, + ); - return { - yMax, - ticks, - toPercent: (value: number) => 100 - (value / yMax) * 100, - }; - }, [compartmentData, chartType, hiddenPlaces]); -}; + chartRef.current?.destroy(); -/** - * Y-axis component that displays tick labels. - */ -const YAxis: React.FC<{ scale: YAxisScale }> = ({ scale }) => { - return ( -
- {scale.ticks.map((tick) => ( - - {tick} - - ))} -
- ); -}; + // eslint-disable-next-line new-cap -- uPlot's constructor is lowercase by convention + const u = new uPlot(opts, data, wrapper); + chartRef.current = u; -/** - * Tooltip component for displaying token count on hover. - */ -const ChartTooltip: React.FC<{ tooltip: TooltipState | null }> = ({ - tooltip, -}) => { - if (!tooltip?.visible) { - return null; - } + const ro = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) { + u.setSize({ width, height }); + } + } + }); + ro.observe(wrapper); - return ( -
-
-
- {tooltip.placeName} - {tooltip.value} -
-
- {tooltip.time.toFixed(3)}s -
-
- Frame {tooltip.frameIndex} -
-
- ); -}; + return () => { + ro.disconnect(); + u.destroy(); + chartRef.current = null; + }; + // Recreate when chart type or visible series change + }, [chartType, hiddenPlaces, store.places.length, onScrub]); -/** - * Shared playhead indicator component for timeline charts. - */ -const PlayheadIndicator: React.FC<{ totalFrames: number }> = ({ - totalFrames, -}) => { - const { currentFrameIndex } = use(PlaybackContext); - const frameIndex = currentFrameIndex; + // Stream update: just setData (no chart recreation) + useEffect(() => { + if (chartRef.current) { + chartRef.current.setData(data); + } + }, [revision]); - return ( -
-
-
-
- ); + // Redraw when playhead moves (triggers the draw hook) + useEffect(() => { + chartRef.current?.redraw(false, false); + }, [currentFrameIndex]); + + return
; }; -/** - * Shared legend component for timeline charts. - */ +// -- Legend -------------------------------------------------------------------- + const TimelineLegend: React.FC<{ - compartmentData: CompartmentData[]; + places: PlaceMeta[]; hiddenPlaces: Set; - hoveredPlaceId: string | null; onToggleVisibility: (placeId: string) => void; - onHover: (placeId: string | null) => void; -}> = ({ - compartmentData, - hiddenPlaces, - hoveredPlaceId, - onToggleVisibility, - onHover, -}) => ( +}> = ({ places, hiddenPlaces, onToggleVisibility }) => (
- {compartmentData.map((data) => { - const isHidden = hiddenPlaces.has(data.placeId); - const isHovered = hoveredPlaceId === data.placeId; - const isDimmed = hoveredPlaceId && !isHovered; + {places.map((p) => { + const isHidden = hiddenPlaces.has(p.placeId); return (
onToggleVisibility(data.placeId)} + onClick={() => onToggleVisibility(p.placeId)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); - onToggleVisibility(data.placeId); + onToggleVisibility(p.placeId); } }} - onMouseEnter={() => onHover(data.placeId)} - onMouseLeave={() => onHover(null)} - onFocus={() => onHover(data.placeId)} - onBlur={() => onHover(null)} style={{ - opacity: isHidden ? 0.4 : isDimmed ? 0.6 : 1, + opacity: isHidden ? 0.4 : 1, textDecoration: isHidden ? "line-through" : "none", }} >
- {data.placeName} + {p.placeName}
); })}
); -interface ChartProps { - compartmentData: CompartmentData[]; - frameTimes: number[]; - legendState: LegendState; - yAxisScale: YAxisScale; - onTooltipChange: (tooltip: TooltipState | null) => void; - onPlaceHover: (placeId: string | null) => void; -} - -/** - * CompartmentTimeSeries displays a line chart showing token counts over time. - * Clicking/dragging on the chart scrubs through frames. - * - * PERFORMANCE: Uses CSS-based hover dimming and event delegation to avoid - * re-rendering PlaceLine components on hover state changes. - */ -const CompartmentTimeSeries: React.FC = ({ - compartmentData, - frameTimes, - legendState, - yAxisScale, - onTooltipChange, - onPlaceHover, -}) => { - "use no memo"; // Complex chart with manual memoization — compiler cannot preserve existing useMemo/useCallback patterns - const { totalFrames } = use(SimulationContext); - const { setCurrentViewedFrame } = use(PlaybackContext); - - const chartRef = useRef(null); - const isDraggingRef = useRef(false); - - // Track locally hovered place (from SVG path hover via event delegation) - const [localHoveredPlaceId, setLocalHoveredPlaceId] = useState( - null, - ); - - const { hiddenPlaces, hoveredPlaceId } = legendState; - - // Use local hover if available, otherwise fall back to legend hover - const activeHoveredPlaceId = localHoveredPlaceId ?? hoveredPlaceId; - - // Calculate chart dimensions and scales - const chartMetrics = useMemo(() => { - if (compartmentData.length === 0 || totalFrames === 0) { - return null; - } - - return { - totalFrames, - xScale: (frameIndex: number, width: number) => - (frameIndex / Math.max(1, totalFrames - 1)) * width, - yScale: (value: number, height: number) => - height - (value / yAxisScale.yMax) * height, - }; - }, [compartmentData, totalFrames, yAxisScale.yMax]); - - // Calculate frame index from mouse position - const getFrameFromEvent = useCallback( - (event: React.MouseEvent) => { - if (!chartRef.current || !chartMetrics) { - return null; - } - - const rect = chartRef.current.getBoundingClientRect(); - const x = event.clientX - rect.left; - const width = rect.width; - - const progress = Math.max(0, Math.min(1, x / width)); - return Math.round(progress * (chartMetrics.totalFrames - 1)); - }, - [chartMetrics], - ); - - // Handle mouse interaction for scrubbing - const handleScrub = useCallback( - (event: React.MouseEvent) => { - const frameIndex = getFrameFromEvent(event); - if (frameIndex !== null) { - setCurrentViewedFrame(frameIndex); - } - }, - [getFrameFromEvent, setCurrentViewedFrame], - ); - - // Update tooltip based on mouse position and hovered place - const updateTooltip = useCallback( - (event: React.MouseEvent, hoveredId: string | null) => { - if (!hoveredId || frameTimes.length === 0) { - onTooltipChange(null); - return; - } - - const frameIndex = getFrameFromEvent(event); - if (frameIndex === null) { - onTooltipChange(null); - return; - } - - const placeData = compartmentData.find( - (data) => data.placeId === hoveredId, - ); - if (!placeData || hiddenPlaces.has(hoveredId)) { - onTooltipChange(null); - return; - } - - const value = placeData.values[frameIndex] ?? 0; - const time = frameTimes[frameIndex] ?? 0; - - onTooltipChange({ - visible: true, - x: event.clientX, - y: event.clientY, - placeName: placeData.placeName, - color: placeData.color, - value, - frameIndex, - time, - }); - }, - [ - compartmentData, - hiddenPlaces, - frameTimes, - getFrameFromEvent, - onTooltipChange, - ], - ); - - /** - * Extract placeId from an event target using event delegation. - * Walks up the DOM to find the nearest element with data-place-id. - */ - const getPlaceIdFromEvent = useCallback( - (event: React.MouseEvent): string | null => { - const target = event.target as SVGElement; - const placeGroup = target.closest("[data-place-id]"); - return placeGroup?.getAttribute("data-place-id") ?? null; - }, - [], - ); - - const handleMouseDown = useCallback( - (event: React.MouseEvent) => { - isDraggingRef.current = true; - handleScrub(event); - }, - [handleScrub], - ); - - /** - * Event delegation handler for mouse movement. - * Detects which place is being hovered by walking up the DOM tree. - */ - const handleMouseMove = useCallback( - (event: React.MouseEvent) => { - if (isDraggingRef.current) { - handleScrub(event); - } - - // Event delegation: extract placeId from the event target - const placeId = getPlaceIdFromEvent(event); - - // Only update state if hover target changed - if (placeId !== localHoveredPlaceId) { - setLocalHoveredPlaceId(placeId); - onPlaceHover(placeId); - } - - // Update tooltip with current hover state - updateTooltip(event, placeId ?? hoveredPlaceId); - }, - [ - handleScrub, - getPlaceIdFromEvent, - localHoveredPlaceId, - onPlaceHover, - updateTooltip, - hoveredPlaceId, - ], - ); - - const handleMouseUp = useCallback(() => { - isDraggingRef.current = false; - }, []); - - const handleMouseLeave = useCallback(() => { - isDraggingRef.current = false; - setLocalHoveredPlaceId(null); - onPlaceHover(null); - onTooltipChange(null); - }, [onPlaceHover, onTooltipChange]); - - // Generate SVG path for a data series - const generatePath = useCallback( - (values: number[], width: number, height: number) => { - if (!chartMetrics || values.length === 0) { - return ""; - } - - const points = values.map((value, index) => { - const x = chartMetrics.xScale(index, width); - const y = chartMetrics.yScale(value, height); - return `${x},${y}`; - }); - - return `M ${points.join(" L ")}`; - }, - [chartMetrics], - ); - - if (totalFrames === 0 || compartmentData.length === 0 || !chartMetrics) { - return null; - } - - // Filter visible data once - const visibleData = compartmentData.filter( - (data) => !hiddenPlaces.has(data.placeId), - ); - - return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- interactive chart SVG - - {/* Background grid lines */} - - - - - {/* Data lines - render non-hovered first, then hovered on top */} - {/* CSS handles opacity/dimming via data-place-id and data-hovered attributes */} - {visibleData - .filter((data) => data.placeId !== activeHoveredPlaceId) - .map((data) => ( - - {/* Visible line */} - - {/* Invisible hit area for easier hovering - events bubble to parent SVG */} - - - ))} - {/* Render hovered line on top for z-ordering */} - {activeHoveredPlaceId && - visibleData - .filter((data) => data.placeId === activeHoveredPlaceId) - .map((data) => ( - - {/* Visible line - thicker when hovered */} - - {/* Invisible hit area - events bubble to parent SVG */} - - - ))} - - ); -}; +// -- Main component ----------------------------------------------------------- -/** - * StackedAreaChart displays a stacked area chart showing token counts over time. - * Each place's tokens are stacked on top of each other to show the total distribution. - * Clicking/dragging on the chart scrubs through frames. - * - * PERFORMANCE: Uses CSS-based hover dimming and event delegation to avoid - * re-rendering path elements on hover state changes. - */ -const StackedAreaChart: React.FC = ({ - compartmentData, - frameTimes, - legendState, - yAxisScale, - onTooltipChange, - onPlaceHover, -}) => { - "use no memo"; // Complex chart with manual memoization — compiler cannot preserve existing useMemo/useCallback patterns - const { totalFrames } = use(SimulationContext); - const { setCurrentViewedFrame } = use(PlaybackContext); - - const chartRef = useRef(null); - const isDraggingRef = useRef(false); - - // Track locally hovered place (from SVG path hover via event delegation) - const [localHoveredPlaceId, setLocalHoveredPlaceId] = useState( - null, - ); - - const { hiddenPlaces, hoveredPlaceId } = legendState; - - // Use local hover if available, otherwise fall back to legend hover - const activeHoveredPlaceId = localHoveredPlaceId ?? hoveredPlaceId; - - // Filter visible compartment data - const visibleCompartmentData = useMemo(() => { - return compartmentData.filter((data) => !hiddenPlaces.has(data.placeId)); - }, [compartmentData, hiddenPlaces]); - - // Calculate stacked values and chart metrics - const { stackedData, chartMetrics } = useMemo(() => { - if (visibleCompartmentData.length === 0 || totalFrames === 0) { - return { stackedData: [], chartMetrics: null }; - } - - // Calculate stacked values: for each frame, accumulate the values - // stackedData[i] contains { placeId, color, baseValues[], topValues[] } - const stacked: Array<{ - placeId: string; - placeName: string; - color: string; - baseValues: number[]; - topValues: number[]; - }> = []; - - // Track cumulative values at each frame - const cumulativeAtFrame: number[] = new Array(totalFrames).fill(0); - - for (const data of visibleCompartmentData) { - const baseValues: number[] = [...cumulativeAtFrame]; - const topValues: number[] = data.values.map((value, frameIdx) => { - const newCumulative = (cumulativeAtFrame[frameIdx] ?? 0) + value; - cumulativeAtFrame[frameIdx] = newCumulative; - return newCumulative; - }); - - stacked.push({ - placeId: data.placeId, - placeName: data.placeName, - color: data.color, - baseValues, - topValues, - }); - } - - return { - stackedData: stacked, - chartMetrics: { - totalFrames, - xScale: (frameIndex: number, width: number) => - (frameIndex / Math.max(1, totalFrames - 1)) * width, - yScale: (value: number, height: number) => - height - (value / yAxisScale.yMax) * height, - }, - }; - }, [visibleCompartmentData, totalFrames, yAxisScale.yMax]); - - // Calculate frame index from mouse position - const getFrameFromEvent = useCallback( - (event: React.MouseEvent) => { - if (!chartRef.current || !chartMetrics) { - return null; - } - - const rect = chartRef.current.getBoundingClientRect(); - const x = event.clientX - rect.left; - const width = rect.width; - - const progress = Math.max(0, Math.min(1, x / width)); - return Math.round(progress * (chartMetrics.totalFrames - 1)); - }, - [chartMetrics], - ); - - // Handle mouse interaction for scrubbing - const handleScrub = useCallback( - (event: React.MouseEvent) => { - const frameIndex = getFrameFromEvent(event); - if (frameIndex !== null) { - setCurrentViewedFrame(frameIndex); - } - }, - [getFrameFromEvent, setCurrentViewedFrame], - ); - - // Update tooltip based on mouse position and hovered place - const updateTooltip = useCallback( - (event: React.MouseEvent, hoveredId: string | null) => { - if (!hoveredId || frameTimes.length === 0) { - onTooltipChange(null); - return; - } - - const frameIndex = getFrameFromEvent(event); - if (frameIndex === null) { - onTooltipChange(null); - return; - } - - // For stacked chart, get the original (non-stacked) value - const placeData = compartmentData.find( - (data) => data.placeId === hoveredId, - ); - if (!placeData || hiddenPlaces.has(hoveredId)) { - onTooltipChange(null); - return; - } - - const value = placeData.values[frameIndex] ?? 0; - const time = frameTimes[frameIndex] ?? 0; - - onTooltipChange({ - visible: true, - x: event.clientX, - y: event.clientY, - placeName: placeData.placeName, - color: placeData.color, - value, - frameIndex, - time, - }); - }, - [ - compartmentData, - hiddenPlaces, - frameTimes, - getFrameFromEvent, - onTooltipChange, - ], - ); - - /** - * Extract placeId from an event target using event delegation. - * For stacked chart, paths have data-place-id directly on them. - */ - const getPlaceIdFromEvent = useCallback( - (event: React.MouseEvent): string | null => { - const target = event.target as SVGElement; - // First check if the target itself has data-place-id (for path elements) - if (target.hasAttribute("data-place-id")) { - return target.getAttribute("data-place-id"); - } - // Fall back to walking up the DOM - const placeElement = target.closest("[data-place-id]"); - return placeElement?.getAttribute("data-place-id") ?? null; - }, - [], - ); - - const handleMouseDown = useCallback( - (event: React.MouseEvent) => { - isDraggingRef.current = true; - handleScrub(event); - }, - [handleScrub], - ); - - /** - * Event delegation handler for mouse movement. - * Detects which place is being hovered by checking data-place-id attributes. - */ - const handleMouseMove = useCallback( - (event: React.MouseEvent) => { - if (isDraggingRef.current) { - handleScrub(event); - } - - // Event delegation: extract placeId from the event target - const placeId = getPlaceIdFromEvent(event); - - // Only update state if hover target changed - if (placeId !== localHoveredPlaceId) { - setLocalHoveredPlaceId(placeId); - onPlaceHover(placeId); - } - - // Update tooltip with current hover state - updateTooltip(event, placeId ?? hoveredPlaceId); - }, - [ - handleScrub, - getPlaceIdFromEvent, - localHoveredPlaceId, - onPlaceHover, - updateTooltip, - hoveredPlaceId, - ], - ); - - const handleMouseUp = useCallback(() => { - isDraggingRef.current = false; - }, []); - - const handleMouseLeave = useCallback(() => { - isDraggingRef.current = false; - setLocalHoveredPlaceId(null); - onPlaceHover(null); - onTooltipChange(null); - }, [onPlaceHover, onTooltipChange]); - - // Generate SVG path for a stacked area - const generateAreaPath = useCallback( - ( - baseValues: number[], - topValues: number[], - width: number, - height: number, - ) => { - if (!chartMetrics || topValues.length === 0) { - return ""; - } - - // Build the path: top line forward, then bottom line backward - const topPoints = topValues.map((value, index) => { - const x = chartMetrics.xScale(index, width); - const y = chartMetrics.yScale(value, height); - return `${x},${y}`; - }); - - const basePoints = baseValues - .map((value, index) => { - const x = chartMetrics.xScale(index, width); - const y = chartMetrics.yScale(value, height); - return `${x},${y}`; - }) - .reverse(); - - return `M ${topPoints.join(" L ")} L ${basePoints.join(" L ")} Z`; - }, - [chartMetrics], - ); - - if (totalFrames === 0 || compartmentData.length === 0 || !chartMetrics) { - return null; - } - - return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- interactive chart SVG - - {/* Background grid lines */} - - - - - {/* Stacked areas - render from bottom to top */} - {/* CSS handles opacity/dimming via data-place-id and data-hovered attributes */} - {stackedData.map((data) => ( - - ))} - - ); -}; - -/** - * SimulationTimelineContent displays timeline information for the running simulation. - * Shows a compartment time-series chart with interactive scrubbing. - */ const SimulationTimelineContent: React.FC = () => { const { timelineChartType: chartType } = use(EditorContext); const { totalFrames } = use(SimulationContext); - const { compartmentData, frameTimes } = useCompartmentData(); + const { currentFrameIndex } = use(PlaybackContext); + const { store, revision } = useStreamingData(); - // Shared legend state - persists across chart type switches const [hiddenPlaces, setHiddenPlaces] = useState>(new Set()); - const [hoveredPlaceId, setHoveredPlaceId] = useState(null); - - // Tooltip state - const [tooltip, setTooltip] = useState(null); - - const legendState: LegendState = useMemo( - () => ({ hiddenPlaces, hoveredPlaceId }), - [hiddenPlaces, hoveredPlaceId], - ); - - // Compute Y-axis scale based on data and chart type - const yAxisScale = useYAxisScale(compartmentData, chartType, hiddenPlaces); - // Toggle visibility handler const togglePlaceVisibility = useCallback((placeId: string) => { setHiddenPlaces((prev) => { const next = new Set(prev); @@ -1313,15 +566,7 @@ const SimulationTimelineContent: React.FC = () => { }); }, []); - const handleHover = useCallback((placeId: string | null) => { - setHoveredPlaceId(placeId); - }, []); - - const handleTooltipChange = useCallback((newTooltip: TooltipState | null) => { - setTooltip(newTooltip); - }, []); - - if (compartmentData.length === 0 || totalFrames === 0) { + if (store.length === 0 || totalFrames === 0) { return (
@@ -1333,46 +578,29 @@ const SimulationTimelineContent: React.FC = () => { return (
-
- -
- {chartType === "stacked" ? ( - - ) : ( - - )} - +
+
+
-
); }; /** * SubView definition for Simulation Timeline tab. - * This tab is visible when simulation is running, paused, or complete. */ export const simulationTimelineSubView: SubView = { id: "simulation-timeline", diff --git a/yarn.lock b/yarn.lock index 1ea45a77693..074f616798c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7830,6 +7830,7 @@ __metadata: rolldown-plugin-dts: "npm:0.22.5" storybook: "npm:10.2.19" typescript: "npm:5.9.3" + uplot: "npm:1.6.32" uuid: "npm:13.0.0" vite: "npm:8.0.5" vitest: "npm:4.1.0" @@ -45230,6 +45231,13 @@ __metadata: languageName: node linkType: hard +"uplot@npm:1.6.32": + version: 1.6.32 + resolution: "uplot@npm:1.6.32" + checksum: 10c0/4d9cdd5f53371656cfc178ea4ae0d8c9bdbe4c98c99df847926d7e5f5a5bcb8043561219da2979fee7616aabe3619d80fee093befdfe39aae65e6621284c39b1 + languageName: node + linkType: hard + "upper-case-first@npm:^2.0.2": version: 2.0.2 resolution: "upper-case-first@npm:2.0.2" From 9b03c60f290505b92ec69602734b09fdd451df45 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 14 Apr 2026 00:03:11 +0200 Subject: [PATCH 02/13] Add hover tooltip and Logic Pro-style ruler to simulation timeline - Hover tooltip: imperative DOM mounted in u.over (no React renders per mousemove). Run mode uses uPlot's nearest-series focus; stacked mode walks cumulative bands at the cursor's y to find the hit. Edge-clamps and flips above/below the cursor so it always fits in the chart area. - Top x-axis rendered as a Logic Pro-style ruler with a separator line. Playhead is a rounded-top "pin" with a triangular tip and white border, drawn in physical pixels with dpr-correct sizes. - Ruler scrubbing: clicks/drags on the axis area scrub the playhead via native pointer events with setPointerCapture. - Cursor unlocks (lock: false) so the dotted crosshair only shows on hover. - Add SubView.noPadding flag and move BottomPanel padding from the outer container into the header and the padded content variant, so noPadding subviews are truly full-bleed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../horizontal/horizontal-tabs-container.tsx | 24 +- .../src/components/sub-view/types.ts | 7 + .../views/Editor/panels/BottomPanel/panel.tsx | 3 +- .../subviews/simulation-timeline.tsx | 344 ++++++++++++++++-- 4 files changed, 348 insertions(+), 30 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/horizontal/horizontal-tabs-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/horizontal/horizontal-tabs-container.tsx index 9d47a78379e..531b28e1c8e 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/horizontal/horizontal-tabs-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/horizontal/horizontal-tabs-container.tsx @@ -41,11 +41,21 @@ const tabButtonStyle = cva({ }, }); -const contentStyle = css({ - fontSize: "xs", - padding: "3", - flex: "[1]", - overflowY: "auto", +const contentStyle = cva({ + base: { + fontSize: "xs", + flex: "[1]", + overflowY: "auto", + }, + variants: { + padded: { + // Includes the 4px that previously came from the outer panel container, + // so padded subviews keep the same visual inset. + true: { padding: "[16px]" }, + false: { padding: "0" }, + }, + }, + defaultVariants: { padded: true }, }); interface TabButtonProps { @@ -122,7 +132,7 @@ export const HorizontalTabsContainer: React.FC< {/* Content */}
@@ -196,7 +206,7 @@ export const HorizontalTabsContent: React.FC<{ return (
diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts index a7bb8df8557..a94ebf7619b 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts +++ b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts @@ -72,4 +72,11 @@ export interface SubView { * Only affects vertical layout. When set, the section can be resized by dragging its bottom edge. */ resizable?: SubViewResizeConfig; + /** + * When true, the horizontal tab content wrapper renders with no padding, + * letting the subview occupy the full width/height of the panel area. + * Useful for visualizations like charts that manage their own bounds. + * Defaults to false. + */ + noPadding?: boolean; } diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx index 6cad38c3f2a..89f3159ff6f 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx @@ -52,7 +52,6 @@ const panelStyle = cva({ }); const panelContainerStyle = css({ - padding: "[4px]", display: "flex", flexDirection: "column", }); @@ -61,7 +60,7 @@ const headerStyle = css({ display: "flex", alignItems: "center", justifyContent: "space-between", - padding: "[2px]", + padding: "[6px]", flexShrink: 0, }); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index 472f35a6140..e129eb737c2 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -19,7 +19,7 @@ const containerStyle = css({ display: "flex", flexDirection: "column", height: "[100%]", - gap: "[8px]", + paddingTop: "[4px]", }); const chartAreaStyle = css({ @@ -39,7 +39,8 @@ const legendContainerStyle = css({ gap: "[12px]", fontSize: "[11px]", color: "[#666]", - paddingTop: "[4px]", + paddingY: "3", + paddingX: "3", }); const legendItemStyle = css({ @@ -60,6 +61,39 @@ const legendColorStyle = css({ borderRadius: "[2px]", }); +const tooltipStyle = css({ + position: "absolute", + pointerEvents: "none", + backgroundColor: "[rgba(0, 0, 0, 0.85)]", + color: "neutral.s00", + padding: "[6px 10px]", + borderRadius: "md", + fontSize: "[11px]", + lineHeight: "[1.4]", + zIndex: "[1000]", + whiteSpace: "nowrap", + boxShadow: "[0 2px 8px rgba(0, 0, 0, 0.25)]", + display: "none", +}); + +const tooltipLabelStyle = css({ + display: "flex", + alignItems: "center", + gap: "[6px]", +}); + +const tooltipDotStyle = css({ + width: "[8px]", + height: "[8px]", + borderRadius: "[50%]", + flexShrink: "[0]", +}); + +const tooltipValueStyle = css({ + fontWeight: "semibold", + marginLeft: "[4px]", +}); + // -- Constants ---------------------------------------------------------------- const DEFAULT_COLORS = [ @@ -278,6 +312,73 @@ function buildStackedData( return [store.columns[0]!, ...series] as uPlot.AlignedData; } +// -- Tooltip DOM (mutated imperatively in cursor hook — no React renders) ----- + +interface TooltipNodes { + root: HTMLDivElement; + dot: HTMLDivElement; + name: HTMLSpanElement; + value: HTMLSpanElement; + time: HTMLDivElement; + frame: HTMLDivElement; +} + +function createTooltip(): TooltipNodes { + const root = document.createElement("div"); + root.className = tooltipStyle; + + const label = document.createElement("div"); + label.className = tooltipLabelStyle; + + const dot = document.createElement("div"); + dot.className = tooltipDotStyle; + + const name = document.createElement("span"); + + const value = document.createElement("span"); + value.className = tooltipValueStyle; + + label.append(dot, name, value); + + const time = document.createElement("div"); + time.style.cssText = "font-size:10px;opacity:0.8;margin-top:2px"; + + const frame = document.createElement("div"); + frame.style.cssText = "font-size:9px;opacity:0.6;margin-top:2px"; + + root.append(label, time, frame); + + return { root, dot, name, value, time, frame }; +} + +/** + * Find which stacked band (place) contains the given y value at frame `idx`. + * Walks visible places in stacking order, accumulating values until we exceed + * the cursor's y. O(visible places) per call — trivial cost. + */ +function hitTestStackedBand( + store: StreamingStore, + hiddenPlaces: Set, + idx: number, + yVal: number, +): { placeIdx: number; value: number } | null { + if (yVal < 0) { + return null; + } + let cumul = 0; + for (let i = 0; i < store.places.length; i++) { + if (hiddenPlaces.has(store.places[i]!.placeId)) { + continue; + } + const v = store.columns[i + 1]![idx] ?? 0; + cumul += v; + if (yVal <= cumul) { + return { placeIdx: i, value: v }; + } + } + return null; +} + // -- uPlot options builder ---------------------------------------------------- function buildUPlotOptions( @@ -288,7 +389,97 @@ function buildUPlotOptions( height: number, onScrub: (frameIndex: number) => void, getPlayheadFrame: () => number, + tooltip: TooltipNodes, ): uPlot.Options { + // Tracks the focused series index for run-mode tooltip (set via setSeries hook). + // For stacked mode we hit-test the y position instead. + const focused = { current: -1 }; + + // Local alias so the no-param-reassign rule doesn't fire on every mutation. + const t = tooltip; + + const updateTooltip = (u: uPlot) => { + const idx = u.cursor.idx; + if (idx == null || idx < 0 || store.length === 0) { + t.root.style.display = "none"; + return; + } + + let placeIdx: number; + let value: number; + + if (chartType === "stacked") { + const top = u.cursor.top; + if (top == null || top < 0) { + t.root.style.display = "none"; + return; + } + const yVal = u.posToVal(top, "y"); + const hit = hitTestStackedBand(store, hiddenPlaces, idx, yVal); + if (!hit) { + t.root.style.display = "none"; + return; + } + placeIdx = hit.placeIdx; + value = hit.value; + } else { + // Run chart: rely on uPlot's nearest-series focus (cursor.focus.prox). + // series 0 is the x axis, so place index = focused - 1. + if (focused.current < 1) { + t.root.style.display = "none"; + return; + } + placeIdx = focused.current - 1; + if (hiddenPlaces.has(store.places[placeIdx]?.placeId ?? "")) { + t.root.style.display = "none"; + return; + } + value = store.columns[focused.current]?.[idx] ?? 0; + } + + const place = store.places[placeIdx]; + if (!place) { + t.root.style.display = "none"; + return; + } + + const time = store.columns[0]![idx] ?? 0; + + t.dot.style.background = place.color; + t.name.textContent = place.placeName; + t.value.textContent = String(value); + t.time.textContent = `${time.toFixed(3)}s`; + t.frame.textContent = `Frame ${idx}`; + + // Position inside u.over (overflow:hidden — tooltip can't escape the + // chart). Center horizontally on cursor and prefer above; clamp/flip so + // the tooltip stays fully visible inside the plot area. + t.root.style.display = "block"; // measure with current content + const cx = u.cursor.left ?? 0; + const cy = u.cursor.top ?? 0; + const ow = u.over.clientWidth; + const oh = u.over.clientHeight; + const tw = t.root.offsetWidth; + const th = t.root.offsetHeight; + const margin = 10; + + let left = cx - tw / 2; + if (left < 0) { + left = 0; + } else if (left + tw > ow) { + left = ow - tw; + } + + let top = cy - th - margin; + if (top < 0) { + // Not enough room above — flip below cursor. + top = Math.min(cy + margin, oh - th); + } + + t.root.style.left = `${left}px`; + t.root.style.top = `${top}px`; + }; + const series: uPlot.Series[] = [{ label: "Time" }]; if (chartType === "stacked") { @@ -318,9 +509,16 @@ function buildUPlotOptions( height, series, pxAlign: false, + // Disable uPlot's auto right padding (reserved for the rightmost x-axis + // label overhang). The label may overhang the right edge slightly — fine + // for our full-bleed layout. Other sides keep auto padding (null). + padding: [4, 0, 0, null], cursor: { - lock: true, + lock: false, drag: { x: false, y: false, setScale: false }, + // For run mode: dim non-focused series and snap focus to nearest line + // within `prox` pixels. Stacked mode ignores this (we hit-test bands). + focus: { prox: 16 }, bind: { mousedown: (u, _targ, handler) => (e: MouseEvent) => { handler(e); @@ -339,19 +537,22 @@ function buildUPlotOptions( }, }, legend: { show: false }, + // Dim non-focused series in run mode (canvas alpha — no DOM cost) + focus: { alpha: chartType === "stacked" ? 1 : 0.3 }, axes: [ { show: true, - size: 24, + side: 0, // top — drawn as a Logic-Pro-style ruler (see drawClear hook) + size: 26, font: "10px system-ui", - stroke: "#999", + stroke: "#475569", // slate-600 on the ruler tint grid: { stroke: "#f3f4f6", width: 1 }, - ticks: { stroke: "#e5e7eb", width: 1 }, + ticks: { stroke: "#cbd5e1", width: 1, size: 6 }, values: (_u, vals) => vals.map((v) => `${v}s`), }, { show: true, - size: 50, + size: 54, font: "10px system-ui", stroke: "#999", grid: { stroke: "#f3f4f6", width: 1, dash: [4, 4] }, @@ -359,13 +560,45 @@ function buildUPlotOptions( }, ], scales: { - x: { time: false, auto: true }, + x: { + time: false, + // Pin range exactly to data min/max — no auto padding on the right + range: (_u, min, max) => [min, max], + }, y: { auto: true, range: (_u, min, max) => [Math.min(0, min), max * 1.05], }, }, hooks: { + // Draw a thin separator line between the top axis area and the plot. + drawClear: [ + (u) => { + const ctx = u.ctx; + const { left: bx, width: bw, top: by } = u.bbox; // physical pixels + ctx.save(); + ctx.strokeStyle = "#cbd5e1"; // slate-300 + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(bx, by - 0.5); + ctx.lineTo(bx + bw, by - 0.5); + ctx.stroke(); + ctx.restore(); + }, + ], + setSeries: [ + (u, sIdx) => { + focused.current = sIdx ?? -1; + // Also refresh tooltip — setSeries may fire after setCursor for the + // same mousemove, so the cursor-only update would have stale focus. + updateTooltip(u); + }, + ], + setCursor: [ + (u) => { + updateTooltip(u); + }, + ], draw: [ (u) => { const frameIdx = getPlayheadFrame(); @@ -374,26 +607,47 @@ function buildUPlotOptions( return; } const time = times[Math.min(frameIdx, times.length - 1)]!; + // All coords in physical (canvas) pixels — match valToPos(_, _, true) + // and u.bbox. Multiply visual sizes by dpr so they look right on hidpi. + const dpr = devicePixelRatio; const cx = u.valToPos(time, "x", true); - const plotTop = u.bbox.top / devicePixelRatio; - const plotHeight = u.bbox.height / devicePixelRatio; + const plotTop = u.bbox.top; + const plotHeight = u.bbox.height; const ctx = u.ctx; + // Logic Pro-style playhead: rounded-top "pin" — rectangular body + // whose bottom corners taper diagonally to a single point at plotTop. + const headW = 12 * dpr; + const rectH = 6 * dpr; + const tipH = 6 * dpr; + const radius = 3 * dpr; + const tipY = plotTop; + const baseY = tipY - tipH; // where the taper begins + const topY = baseY - rectH; + const leftX = cx - headW / 2; + const rightX = cx + headW / 2; + ctx.save(); - // Arrow head - ctx.fillStyle = "#333"; + ctx.fillStyle = "#1e293b"; // slate-800 ctx.beginPath(); - ctx.moveTo(cx - 5, plotTop); - ctx.lineTo(cx + 5, plotTop); - ctx.lineTo(cx, plotTop + 7); - ctx.closePath(); + ctx.moveTo(leftX, topY + radius); + ctx.arcTo(leftX, topY, leftX + radius, topY, radius); // top-left + ctx.lineTo(rightX - radius, topY); + ctx.arcTo(rightX, topY, rightX, topY + radius, radius); // top-right + ctx.lineTo(rightX, baseY); // right side down + ctx.lineTo(cx, tipY); // diagonal to tip + ctx.lineTo(leftX, baseY); // diagonal back to left side + ctx.closePath(); // left side up ctx.fill(); - // Vertical line - ctx.strokeStyle = "#333"; - ctx.lineWidth = 1.5; + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 1 * dpr; + ctx.stroke(); + // Vertical line into the chart + ctx.strokeStyle = "#1e293b"; + ctx.lineWidth = 1.5 * dpr; ctx.beginPath(); - ctx.moveTo(cx, plotTop + 6); - ctx.lineTo(cx, plotTop + plotHeight); + ctx.moveTo(cx, tipY - 4); + ctx.lineTo(cx, tipY + plotHeight); ctx.stroke(); ctx.restore(); }, @@ -451,6 +705,8 @@ const UPlotChart: React.FC<{ return; } + const tooltip = createTooltip(); + const opts = buildUPlotOptions( store, chartType, @@ -459,6 +715,7 @@ const UPlotChart: React.FC<{ rect.height, onScrub, () => playheadFrameRef.current, + tooltip, ); chartRef.current?.destroy(); @@ -467,6 +724,50 @@ const UPlotChart: React.FC<{ const u = new uPlot(opts, data, wrapper); chartRef.current = u; + // Mount tooltip inside u.over (the cursor overlay div). It positions + // relative to that div and is bounded by its overflow:hidden — exactly + // matching the chart area. Cleaned up automatically by u.destroy(). + u.over.appendChild(tooltip.root); + + // Ruler scrubbing: clicks/drags on the top axis area scrub the playhead. + // u.over already handles scrubbing inside the plot via cursor.bind; here + // we cover the area above u.over (the ruler) with native listeners on + // u.root, which is the parent that contains both the ruler and u.over. + let rulerDragging = false; + const scrubFromClientX = (clientX: number) => { + const overRect = u.over.getBoundingClientRect(); + const x = Math.max(0, Math.min(clientX - overRect.left, overRect.width)); + onScrub(u.posToIdx(x)); + }; + const onRulerDown = (e: PointerEvent) => { + const overRect = u.over.getBoundingClientRect(); + // Only handle clicks above the plot area (in the ruler band) + if (e.clientY >= overRect.top) { + return; + } + if (e.clientX < overRect.left || e.clientX > overRect.right) { + return; + } + rulerDragging = true; + u.root.setPointerCapture(e.pointerId); + scrubFromClientX(e.clientX); + }; + const onRulerMove = (e: PointerEvent) => { + if (rulerDragging) { + scrubFromClientX(e.clientX); + } + }; + const onRulerUp = (e: PointerEvent) => { + if (rulerDragging) { + rulerDragging = false; + u.root.releasePointerCapture(e.pointerId); + } + }; + u.root.addEventListener("pointerdown", onRulerDown); + u.root.addEventListener("pointermove", onRulerMove); + u.root.addEventListener("pointerup", onRulerUp); + u.root.addEventListener("pointercancel", onRulerUp); + const ro = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) { @@ -609,4 +910,5 @@ export const simulationTimelineSubView: SubView = { "View the simulation timeline with compartment time-series. Click/drag to scrub through frames.", component: SimulationTimelineContent, renderHeaderAction: () => , + noPadding: true, }; From 0552558d0a0779f5910847f69b82870c31055b0a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 14 Apr 2026 16:14:18 +0200 Subject: [PATCH 03/13] Refactor chart component into derived state + focused effects; fix AI reviews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decompose the monolithic chart effect into 4 single-responsibility effects: - Effect 1: create/destroy uPlot on structural changes only - Effect 2: sync container size via useElementSize hook - Effect 3: stream data with setData(data, false) - Effect 4: playhead redraw Derived state replaces imperative plumbing: - useElementSize(ref) replaces getBoundingClientRect + inline ResizeObserver - useStableCallback(onScrub) keeps a stable identity, fixing chart recreation on every streaming update (AI review #1) - Extract attachRulerScrubbing() helper with cached overRect AI review fixes: - #1 onScrub instability causing chart recreation — useStableCallback - #2 missing placeMeta in streaming effect deps - #3 fill color hex assumption — use color-mix(in srgb) instead - #4 y-scale [0,0] when all values zero — Math.max(1, max * 1.05) - #7 playhead line offset not dpr-scaled — tipY - 4 * dpr Co-Authored-By: Claude Opus 4.6 (1M context) --- .../petrinaut/src/hooks/use-element-size.ts | 87 ++++++++ .../subviews/simulation-timeline.tsx | 186 ++++++++++-------- 2 files changed, 193 insertions(+), 80 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/hooks/use-element-size.ts diff --git a/libs/@hashintel/petrinaut/src/hooks/use-element-size.ts b/libs/@hashintel/petrinaut/src/hooks/use-element-size.ts new file mode 100644 index 00000000000..8f38f3c786a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/hooks/use-element-size.ts @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState, type RefObject } from "react"; + +interface ElementSize { + width: number; + height: number; +} + +interface UseElementSizeOptions { + /** + * Debounce interval in milliseconds. When set, the returned size only + * updates at most once per interval, batching rapid resize events. + * Useful for expensive downstream work (e.g. chart recreation). + * Defaults to 0 (no debounce — updates on every ResizeObserver callback). + */ + debounce?: number; +} + +/** + * Returns the content-box size of a DOM element, kept in sync via ResizeObserver. + * + * Returns `null` until the element is mounted and the first observation fires. + * Supports an optional `debounce` interval to throttle updates. + * + * @example + * ```tsx + * const ref = useRef(null); + * const size = useElementSize(ref, { debounce: 100 }); + * + * return
{size && `${size.width} × ${size.height}`}
; + * ``` + */ +export function useElementSize( + ref: RefObject, + options?: UseElementSizeOptions, +): ElementSize | null { + "use no memo"; // imperative observer + timer management + const [size, setSize] = useState(null); + const debounceMs = options?.debounce ?? 0; + const timerRef = useRef | null>(null); + + useEffect(() => { + const el = ref.current; + if (!el) { + return; + } + + const update = (width: number, height: number) => { + setSize((prev) => { + if (prev && prev.width === width && prev.height === height) { + return prev; // avoid spurious re-renders + } + return { width, height }; + }); + }; + + const ro = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + const { width, height } = entry.contentRect; + if (debounceMs > 0) { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + } + timerRef.current = setTimeout(() => { + update(width, height); + timerRef.current = null; + }, debounceMs); + } else { + update(width, height); + } + }); + + ro.observe(el); + + return () => { + ro.disconnect(); + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [ref, debounceMs]); + + return size; +} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index e129eb737c2..15c8732ba1a 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -1,10 +1,12 @@ import { css } from "@hashintel/ds-helpers/css"; -import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { use, useEffect, useMemo, useRef, useState } from "react"; import uPlot from "uplot"; import "uplot/dist/uPlot.min.css"; import { SegmentGroup } from "../../../../../components/segment-group"; import type { SubView } from "../../../../../components/sub-view/types"; +import { useElementSize } from "../../../../../hooks/use-element-size"; +import { useStableCallback } from "../../../../../hooks/use-stable-callback"; import { PlaybackContext } from "../../../../../playback/context"; import { SimulationContext } from "../../../../../simulation/context"; import { @@ -261,7 +263,7 @@ function useStreamingData(): { return () => { cancelled = true; }; - }, [getFramesInRange, totalFrames]); + }, [getFramesInRange, totalFrames, placeMeta]); return { store: storeRef.current, revision }; } @@ -489,7 +491,7 @@ function buildUPlotOptions( series.push({ label: p.placeName, stroke: p.color, - fill: `${p.color}88`, + fill: `color-mix(in srgb, ${p.color} 53%, transparent)`, width: 2, }); } @@ -567,7 +569,7 @@ function buildUPlotOptions( }, y: { auto: true, - range: (_u, min, max) => [Math.min(0, min), max * 1.05], + range: (_u, min, max) => [Math.min(0, min), Math.max(1, max * 1.05)], }, }, hooks: { @@ -646,7 +648,7 @@ function buildUPlotOptions( ctx.strokeStyle = "#1e293b"; ctx.lineWidth = 1.5 * dpr; ctx.beginPath(); - ctx.moveTo(cx, tipY - 4); + ctx.moveTo(cx, tipY - 4 * dpr); ctx.lineTo(cx, tipY + plotHeight); ctx.stroke(); ctx.restore(); @@ -656,6 +658,63 @@ function buildUPlotOptions( }; } +// -- Ruler scrubbing (extracted from chart effect) ---------------------------- + +/** + * Attaches pointer listeners on `u.root` to allow click/drag scrubbing on the + * top axis (ruler) area. Returns a cleanup function. + */ +function attachRulerScrubbing( + u: uPlot, + onScrub: (frameIndex: number) => void, +): () => void { + let dragging = false; + let overRect: DOMRect | null = null; + + const onDown = (e: PointerEvent) => { + overRect = u.over.getBoundingClientRect(); + if (e.clientY >= overRect.top) { + return; + } + if (e.clientX < overRect.left || e.clientX > overRect.right) { + return; + } + dragging = true; + u.root.setPointerCapture(e.pointerId); + const x = Math.max(0, Math.min(e.clientX - overRect.left, overRect.width)); + onScrub(u.posToIdx(x)); + }; + + const onMove = (e: PointerEvent) => { + if (dragging && overRect) { + const x = Math.max( + 0, + Math.min(e.clientX - overRect.left, overRect.width), + ); + onScrub(u.posToIdx(x)); + } + }; + + const onUp = (e: PointerEvent) => { + if (dragging) { + dragging = false; + u.root.releasePointerCapture(e.pointerId); + } + }; + + u.root.addEventListener("pointerdown", onDown); + u.root.addEventListener("pointermove", onMove); + u.root.addEventListener("pointerup", onUp); + u.root.addEventListener("pointercancel", onUp); + + return () => { + u.root.removeEventListener("pointerdown", onDown); + u.root.removeEventListener("pointermove", onMove); + u.root.removeEventListener("pointerup", onUp); + u.root.removeEventListener("pointercancel", onUp); + }; +} + // -- uPlot chart component ---------------------------------------------------- const UPlotChart: React.FC<{ @@ -678,30 +737,32 @@ const UPlotChart: React.FC<{ const wrapperRef = useRef(null); const chartRef = useRef(null); const playheadFrameRef = useRef(currentFrameIndex); - playheadFrameRef.current = currentFrameIndex; - const onScrub = useCallback( - (idx: number) => { - setCurrentViewedFrame(Math.max(0, Math.min(idx, totalFrames - 1))); - }, - [setCurrentViewedFrame, totalFrames], - ); + // -- Derived state ---------------------------------------------------------- + + // Reactive container size — replaces getBoundingClientRect + inline ResizeObserver + const size = useElementSize(wrapperRef); + // Boolean flag for the creation effect — triggers when size first becomes + // available (null → non-null) without re-firing on every resize. + const hasSize = size != null; - // Build data from store + // Stable identity: always calls the latest closure but never changes reference, + // so it doesn't trigger chart recreation when totalFrames changes. + const onScrub = useStableCallback((idx: number) => { + setCurrentViewedFrame(Math.max(0, Math.min(idx, totalFrames - 1))); + }); + + // Columnar data from the store (React Compiler memoizes) const data = chartType === "stacked" ? buildStackedData(store, hiddenPlaces) : buildRunData(store, hiddenPlaces); - // Create/recreate chart when structure changes + // -- Effect 1: create/destroy uPlot on structural changes ------------------- + useEffect(() => { const wrapper = wrapperRef.current; - if (!wrapper || store.length === 0) { - return; - } - - const rect = wrapper.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) { + if (!wrapper || !size || store.length === 0) { return; } @@ -711,8 +772,8 @@ const UPlotChart: React.FC<{ store, chartType, hiddenPlaces, - rect.width, - rect.height, + size.width, + size.height, onScrub, () => playheadFrameRef.current, tooltip, @@ -729,73 +790,38 @@ const UPlotChart: React.FC<{ // matching the chart area. Cleaned up automatically by u.destroy(). u.over.appendChild(tooltip.root); - // Ruler scrubbing: clicks/drags on the top axis area scrub the playhead. - // u.over already handles scrubbing inside the plot via cursor.bind; here - // we cover the area above u.over (the ruler) with native listeners on - // u.root, which is the parent that contains both the ruler and u.over. - let rulerDragging = false; - const scrubFromClientX = (clientX: number) => { - const overRect = u.over.getBoundingClientRect(); - const x = Math.max(0, Math.min(clientX - overRect.left, overRect.width)); - onScrub(u.posToIdx(x)); - }; - const onRulerDown = (e: PointerEvent) => { - const overRect = u.over.getBoundingClientRect(); - // Only handle clicks above the plot area (in the ruler band) - if (e.clientY >= overRect.top) { - return; - } - if (e.clientX < overRect.left || e.clientX > overRect.right) { - return; - } - rulerDragging = true; - u.root.setPointerCapture(e.pointerId); - scrubFromClientX(e.clientX); - }; - const onRulerMove = (e: PointerEvent) => { - if (rulerDragging) { - scrubFromClientX(e.clientX); - } - }; - const onRulerUp = (e: PointerEvent) => { - if (rulerDragging) { - rulerDragging = false; - u.root.releasePointerCapture(e.pointerId); - } - }; - u.root.addEventListener("pointerdown", onRulerDown); - u.root.addEventListener("pointermove", onRulerMove); - u.root.addEventListener("pointerup", onRulerUp); - u.root.addEventListener("pointercancel", onRulerUp); - - const ro = new ResizeObserver((entries) => { - const entry = entries[0]; - if (entry) { - const { width, height } = entry.contentRect; - if (width > 0 && height > 0) { - u.setSize({ width, height }); - } - } - }); - ro.observe(wrapper); + const cleanupRuler = attachRulerScrubbing(u, onScrub); return () => { - ro.disconnect(); + cleanupRuler(); u.destroy(); chartRef.current = null; }; - // Recreate when chart type or visible series change - }, [chartType, hiddenPlaces, store.places.length, onScrub]); + // Recreate only when chart type, visible series, or size availability changes. + // onScrub is stable (useStableCallback). Subsequent size changes trigger + // setSize (Effect 2), not recreation. + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: see comment above + }, [chartType, hiddenPlaces, store.places.length, hasSize]); + + // -- Effect 2: sync container size to existing chart ------------------------ - // Stream update: just setData (no chart recreation) useEffect(() => { - if (chartRef.current) { - chartRef.current.setData(data); + if (chartRef.current && size && size.width > 0 && size.height > 0) { + chartRef.current.setSize(size); } + }, [size]); + + // -- Effect 3: stream new data (no chart recreation) ------------------------ + + useEffect(() => { + // resetScales=false: our range functions handle bounds; skip redundant recalc + chartRef.current?.setData(data, false); }, [revision]); - // Redraw when playhead moves (triggers the draw hook) + // -- Effect 4: playhead redraw --------------------------------------------- + useEffect(() => { + playheadFrameRef.current = currentFrameIndex; chartRef.current?.redraw(false, false); }, [currentFrameIndex]); @@ -855,7 +881,7 @@ const SimulationTimelineContent: React.FC = () => { const [hiddenPlaces, setHiddenPlaces] = useState>(new Set()); - const togglePlaceVisibility = useCallback((placeId: string) => { + const togglePlaceVisibility = (placeId: string) => { setHiddenPlaces((prev) => { const next = new Set(prev); if (next.has(placeId)) { @@ -865,7 +891,7 @@ const SimulationTimelineContent: React.FC = () => { } return next; }); - }, []); + }; if (store.length === 0 || totalFrames === 0) { return ( From 39d3f1ec6394afbf81bc41247353293e0e0c4a19 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 14 Apr 2026 17:51:31 +0200 Subject: [PATCH 04/13] Extract focused helpers from buildUPlotOptions for readability Break the 275-line monolithic options builder into single-responsibility functions: - resolveHoverTarget(): pure function that resolves which place + value is under the cursor, collapsing 7 scattered hide branches into one null-returning flow - positionTooltip(): updates tooltip content and edge-clamps position inside u.over, only called on the happy path - drawPlayhead(): self-contained canvas drawing for the Logic Pro-style pin head and vertical guide line buildUPlotOptions now takes a single ChartOptions object and is ~100 lines of config assembly with no embedded business logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../subviews/simulation-timeline.tsx | 373 +++++++++--------- 1 file changed, 197 insertions(+), 176 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index 15c8732ba1a..530c472e4a5 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -381,113 +381,204 @@ function hitTestStackedBand( return null; } -// -- uPlot options builder ---------------------------------------------------- +// -- Hover target resolution (shared by tooltip) ----------------------------- + +interface HoverHit { + place: PlaceMeta; + value: number; + idx: number; + time: number; +} -function buildUPlotOptions( +/** + * Resolve the place + value under the cursor. Returns null when there's + * nothing to show (cursor outside data, no focused series, hidden place). + */ +function resolveHoverTarget( + u: uPlot, store: StreamingStore, chartType: TimelineChartType, hiddenPlaces: Set, - width: number, - height: number, - onScrub: (frameIndex: number) => void, - getPlayheadFrame: () => number, - tooltip: TooltipNodes, -): uPlot.Options { - // Tracks the focused series index for run-mode tooltip (set via setSeries hook). - // For stacked mode we hit-test the y position instead. - const focused = { current: -1 }; - - // Local alias so the no-param-reassign rule doesn't fire on every mutation. - const t = tooltip; + focusedSeriesIdx: number, +): HoverHit | null { + const idx = u.cursor.idx; + if (idx == null || idx < 0 || store.length === 0) { + return null; + } - const updateTooltip = (u: uPlot) => { - const idx = u.cursor.idx; - if (idx == null || idx < 0 || store.length === 0) { - t.root.style.display = "none"; - return; + let placeIdx: number; + let value: number; + + if (chartType === "stacked") { + const top = u.cursor.top; + if (top == null || top < 0) { + return null; + } + const hit = hitTestStackedBand( + store, + hiddenPlaces, + idx, + u.posToVal(top, "y"), + ); + if (!hit) { + return null; + } + placeIdx = hit.placeIdx; + value = hit.value; + } else { + if (focusedSeriesIdx < 1) { + return null; } + placeIdx = focusedSeriesIdx - 1; + if (hiddenPlaces.has(store.places[placeIdx]?.placeId ?? "")) { + return null; + } + value = store.columns[focusedSeriesIdx]?.[idx] ?? 0; + } - let placeIdx: number; - let value: number; + const place = store.places[placeIdx]; + if (!place) { + return null; + } - if (chartType === "stacked") { - const top = u.cursor.top; - if (top == null || top < 0) { - t.root.style.display = "none"; - return; - } - const yVal = u.posToVal(top, "y"); - const hit = hitTestStackedBand(store, hiddenPlaces, idx, yVal); - if (!hit) { - t.root.style.display = "none"; - return; - } - placeIdx = hit.placeIdx; - value = hit.value; - } else { - // Run chart: rely on uPlot's nearest-series focus (cursor.focus.prox). - // series 0 is the x axis, so place index = focused - 1. - if (focused.current < 1) { - t.root.style.display = "none"; - return; - } - placeIdx = focused.current - 1; - if (hiddenPlaces.has(store.places[placeIdx]?.placeId ?? "")) { - t.root.style.display = "none"; - return; - } - value = store.columns[focused.current]?.[idx] ?? 0; - } + return { place, value, idx, time: store.columns[0]![idx] ?? 0 }; +} - const place = store.places[placeIdx]; - if (!place) { - t.root.style.display = "none"; - return; - } +// -- Tooltip positioning (edge-clamped inside u.over) ------------------------- - const time = store.columns[0]![idx] ?? 0; - - t.dot.style.background = place.color; - t.name.textContent = place.placeName; - t.value.textContent = String(value); - t.time.textContent = `${time.toFixed(3)}s`; - t.frame.textContent = `Frame ${idx}`; - - // Position inside u.over (overflow:hidden — tooltip can't escape the - // chart). Center horizontally on cursor and prefer above; clamp/flip so - // the tooltip stays fully visible inside the plot area. - t.root.style.display = "block"; // measure with current content - const cx = u.cursor.left ?? 0; - const cy = u.cursor.top ?? 0; - const ow = u.over.clientWidth; - const oh = u.over.clientHeight; - const tw = t.root.offsetWidth; - const th = t.root.offsetHeight; - const margin = 10; - - let left = cx - tw / 2; - if (left < 0) { - left = 0; - } else if (left + tw > ow) { - left = ow - tw; - } +function positionTooltip(tooltip: TooltipNodes, u: uPlot, hit: HoverHit): void { + // Local alias to satisfy no-param-reassign rule on DOM mutations. + const t = tooltip; + t.dot.style.background = hit.place.color; + t.name.textContent = hit.place.placeName; + t.value.textContent = String(hit.value); + t.time.textContent = `${hit.time.toFixed(3)}s`; + t.frame.textContent = `Frame ${hit.idx}`; + + // Measure after content update + t.root.style.display = "block"; + const cx = u.cursor.left ?? 0; + const cy = u.cursor.top ?? 0; + const ow = u.over.clientWidth; + const oh = u.over.clientHeight; + const tw = t.root.offsetWidth; + const th = t.root.offsetHeight; + const margin = 10; + + let left = cx - tw / 2; + if (left < 0) { + left = 0; + } else if (left + tw > ow) { + left = ow - tw; + } - let top = cy - th - margin; - if (top < 0) { - // Not enough room above — flip below cursor. - top = Math.min(cy + margin, oh - th); - } + let top = cy - th - margin; + if (top < 0) { + top = Math.min(cy + margin, oh - th); + } + + t.root.style.left = `${left}px`; + t.root.style.top = `${top}px`; +} + +// -- Playhead drawing (Logic Pro-style pin) ----------------------------------- + +/** Draw the playhead pin in the ruler and a vertical guide line into the chart. */ +function drawPlayhead(u: uPlot, frameIdx: number): void { + const times = u.data[0]!; + if (times.length === 0) { + return; + } + + const dpr = devicePixelRatio; + const time = times[Math.min(frameIdx, times.length - 1)]!; + const cx = u.valToPos(time, "x", true); + const plotTop = u.bbox.top; + const plotHeight = u.bbox.height; + const ctx = u.ctx; + + // Pin dimensions (all in physical pixels for HiDPI correctness) + const headW = 12 * dpr; + const rectH = 6 * dpr; + const tipH = 6 * dpr; + const radius = 3 * dpr; + const tipY = plotTop; + const baseY = tipY - tipH; + const topY = baseY - rectH; + const leftX = cx - headW / 2; + const rightX = cx + headW / 2; + + ctx.save(); + + // Pin head: rounded-top rectangle tapering to a triangular tip + ctx.fillStyle = "#1e293b"; + ctx.beginPath(); + ctx.moveTo(leftX, topY + radius); + ctx.arcTo(leftX, topY, leftX + radius, topY, radius); + ctx.lineTo(rightX - radius, topY); + ctx.arcTo(rightX, topY, rightX, topY + radius, radius); + ctx.lineTo(rightX, baseY); + ctx.lineTo(cx, tipY); + ctx.lineTo(leftX, baseY); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 1 * dpr; + ctx.stroke(); + + // Vertical guide line + ctx.strokeStyle = "#1e293b"; + ctx.lineWidth = 1.5 * dpr; + ctx.beginPath(); + ctx.moveTo(cx, tipY - 4 * dpr); + ctx.lineTo(cx, tipY + plotHeight); + ctx.stroke(); + + ctx.restore(); +} + +// -- uPlot options builder ---------------------------------------------------- - t.root.style.left = `${left}px`; - t.root.style.top = `${top}px`; +interface ChartOptions { + store: StreamingStore; + chartType: TimelineChartType; + hiddenPlaces: Set; + size: { width: number; height: number }; + onScrub: (frameIndex: number) => void; + getPlayheadFrame: () => number; + tooltip: TooltipNodes; +} + +function buildUPlotOptions(opts: ChartOptions): uPlot.Options { + const { + store, + chartType, + hiddenPlaces, + size, + onScrub, + getPlayheadFrame, + tooltip: t, + } = opts; + + // Mutable focus index — updated by setSeries hook, read by tooltip + let focused = -1; + + const updateTooltip = (u: uPlot) => { + const hit = resolveHoverTarget(u, store, chartType, hiddenPlaces, focused); + if (!hit) { + t.root.style.display = "none"; + return; + } + positionTooltip(t, u, hit); }; + // Build series config const series: uPlot.Series[] = [{ label: "Time" }]; - if (chartType === "stacked") { - const visible = store.places.filter((p) => !hiddenPlaces.has(p.placeId)); - const reversed = [...visible].reverse(); - for (const p of reversed) { + const visible = store.places + .filter((p) => !hiddenPlaces.has(p.placeId)) + .reverse(); + for (const p of visible) { series.push({ label: p.placeName, stroke: p.color, @@ -507,19 +598,14 @@ function buildUPlotOptions( } return { - width, - height, + width: size.width, + height: size.height, series, pxAlign: false, - // Disable uPlot's auto right padding (reserved for the rightmost x-axis - // label overhang). The label may overhang the right edge slightly — fine - // for our full-bleed layout. Other sides keep auto padding (null). padding: [4, 0, 0, null], cursor: { lock: false, drag: { x: false, y: false, setScale: false }, - // For run mode: dim non-focused series and snap focus to nearest line - // within `prox` pixels. Stacked mode ignores this (we hit-test bands). focus: { prox: 16 }, bind: { mousedown: (u, _targ, handler) => (e: MouseEvent) => { @@ -539,15 +625,14 @@ function buildUPlotOptions( }, }, legend: { show: false }, - // Dim non-focused series in run mode (canvas alpha — no DOM cost) focus: { alpha: chartType === "stacked" ? 1 : 0.3 }, axes: [ { show: true, - side: 0, // top — drawn as a Logic-Pro-style ruler (see drawClear hook) + side: 0, size: 26, font: "10px system-ui", - stroke: "#475569", // slate-600 on the ruler tint + stroke: "#475569", grid: { stroke: "#f3f4f6", width: 1 }, ticks: { stroke: "#cbd5e1", width: 1, size: 6 }, values: (_u, vals) => vals.map((v) => `${v}s`), @@ -562,24 +647,19 @@ function buildUPlotOptions( }, ], scales: { - x: { - time: false, - // Pin range exactly to data min/max — no auto padding on the right - range: (_u, min, max) => [min, max], - }, + x: { time: false, range: (_u, min, max) => [min, max] }, y: { auto: true, range: (_u, min, max) => [Math.min(0, min), Math.max(1, max * 1.05)], }, }, hooks: { - // Draw a thin separator line between the top axis area and the plot. drawClear: [ (u) => { - const ctx = u.ctx; - const { left: bx, width: bw, top: by } = u.bbox; // physical pixels + const { ctx } = u; + const { left: bx, width: bw, top: by } = u.bbox; ctx.save(); - ctx.strokeStyle = "#cbd5e1"; // slate-300 + ctx.strokeStyle = "#cbd5e1"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(bx, by - 0.5); @@ -590,70 +670,12 @@ function buildUPlotOptions( ], setSeries: [ (u, sIdx) => { - focused.current = sIdx ?? -1; - // Also refresh tooltip — setSeries may fire after setCursor for the - // same mousemove, so the cursor-only update would have stale focus. - updateTooltip(u); - }, - ], - setCursor: [ - (u) => { + focused = sIdx ?? -1; updateTooltip(u); }, ], - draw: [ - (u) => { - const frameIdx = getPlayheadFrame(); - const times = u.data[0]!; - if (times.length === 0) { - return; - } - const time = times[Math.min(frameIdx, times.length - 1)]!; - // All coords in physical (canvas) pixels — match valToPos(_, _, true) - // and u.bbox. Multiply visual sizes by dpr so they look right on hidpi. - const dpr = devicePixelRatio; - const cx = u.valToPos(time, "x", true); - const plotTop = u.bbox.top; - const plotHeight = u.bbox.height; - const ctx = u.ctx; - - // Logic Pro-style playhead: rounded-top "pin" — rectangular body - // whose bottom corners taper diagonally to a single point at plotTop. - const headW = 12 * dpr; - const rectH = 6 * dpr; - const tipH = 6 * dpr; - const radius = 3 * dpr; - const tipY = plotTop; - const baseY = tipY - tipH; // where the taper begins - const topY = baseY - rectH; - const leftX = cx - headW / 2; - const rightX = cx + headW / 2; - - ctx.save(); - ctx.fillStyle = "#1e293b"; // slate-800 - ctx.beginPath(); - ctx.moveTo(leftX, topY + radius); - ctx.arcTo(leftX, topY, leftX + radius, topY, radius); // top-left - ctx.lineTo(rightX - radius, topY); - ctx.arcTo(rightX, topY, rightX, topY + radius, radius); // top-right - ctx.lineTo(rightX, baseY); // right side down - ctx.lineTo(cx, tipY); // diagonal to tip - ctx.lineTo(leftX, baseY); // diagonal back to left side - ctx.closePath(); // left side up - ctx.fill(); - ctx.strokeStyle = "#fff"; - ctx.lineWidth = 1 * dpr; - ctx.stroke(); - // Vertical line into the chart - ctx.strokeStyle = "#1e293b"; - ctx.lineWidth = 1.5 * dpr; - ctx.beginPath(); - ctx.moveTo(cx, tipY - 4 * dpr); - ctx.lineTo(cx, tipY + plotHeight); - ctx.stroke(); - ctx.restore(); - }, - ], + setCursor: [(u) => updateTooltip(u)], + draw: [(u) => drawPlayhead(u, getPlayheadFrame())], }, }; } @@ -768,16 +790,15 @@ const UPlotChart: React.FC<{ const tooltip = createTooltip(); - const opts = buildUPlotOptions( + const opts = buildUPlotOptions({ store, chartType, hiddenPlaces, - size.width, - size.height, + size, onScrub, - () => playheadFrameRef.current, + getPlayheadFrame: () => playheadFrameRef.current, tooltip, - ); + }); chartRef.current?.destroy(); From c977e0129c89cbd69e6b1bf30d5995a82dc118c5 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 14 Apr 2026 18:20:22 +0200 Subject: [PATCH 05/13] Fix stacked band rendering and stale store ref; revert frozen scales AI review fixes: - #5 Stacked chart: add bands config so each series' fill is clipped to the region between it and the adjacent series below, preventing overlapping semi-transparent layers from compositing into muddy colors - #6 Stale store ref: tooltip hooks now read from a useLatest(store) ref instead of the store value captured at chart creation time, so tooltip values stay correct after simulation restart - #9 Frozen scales: revert setData(data, false) back to setData(data) since scales must recalculate as data grows (time extends, y changes) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../subviews/simulation-timeline.tsx | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index 530c472e4a5..5675fc63040 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -6,6 +6,7 @@ import "uplot/dist/uPlot.min.css"; import { SegmentGroup } from "../../../../../components/segment-group"; import type { SubView } from "../../../../../components/sub-view/types"; import { useElementSize } from "../../../../../hooks/use-element-size"; +import { useLatest } from "../../../../../hooks/use-latest"; import { useStableCallback } from "../../../../../hooks/use-stable-callback"; import { PlaybackContext } from "../../../../../playback/context"; import { SimulationContext } from "../../../../../simulation/context"; @@ -540,7 +541,11 @@ function drawPlayhead(u: uPlot, frameIdx: number): void { // -- uPlot options builder ---------------------------------------------------- interface ChartOptions { + /** Store value for structural config (series, bands). */ store: StreamingStore; + /** Ref to the latest store — tooltip hooks read this so they always + * see fresh data even if the store is replaced after a restart. */ + storeRef: React.RefObject; chartType: TimelineChartType; hiddenPlaces: Set; size: { width: number; height: number }; @@ -552,6 +557,7 @@ interface ChartOptions { function buildUPlotOptions(opts: ChartOptions): uPlot.Options { const { store, + storeRef, chartType, hiddenPlaces, size, @@ -564,7 +570,16 @@ function buildUPlotOptions(opts: ChartOptions): uPlot.Options { let focused = -1; const updateTooltip = (u: uPlot) => { - const hit = resolveHoverTarget(u, store, chartType, hiddenPlaces, focused); + // Read from ref so tooltip always sees the latest store, even after + // simulation restart where the store object is replaced. + const currentStore = storeRef.current; + const hit = resolveHoverTarget( + u, + currentStore, + chartType, + hiddenPlaces, + focused, + ); if (!hit) { t.root.style.display = "none"; return; @@ -572,8 +587,10 @@ function buildUPlotOptions(opts: ChartOptions): uPlot.Options { positionTooltip(t, u, hit); }; - // Build series config + // Build series + bands config const series: uPlot.Series[] = [{ label: "Time" }]; + let bands: uPlot.Band[] | undefined; + if (chartType === "stacked") { const visible = store.places .filter((p) => !hiddenPlaces.has(p.placeId)) @@ -586,6 +603,15 @@ function buildUPlotOptions(opts: ChartOptions): uPlot.Options { width: 2, }); } + // Bands clip each series' fill to the region between it and the series + // below, preventing overlapping semi-transparent layers from compositing + // into progressively darker/muddier colors. + if (visible.length > 1) { + bands = []; + for (let i = 1; i < visible.length; i++) { + bands.push({ series: [i, i + 1] as [number, number] }); + } + } } else { for (const p of store.places) { series.push({ @@ -601,6 +627,7 @@ function buildUPlotOptions(opts: ChartOptions): uPlot.Options { width: size.width, height: size.height, series, + bands, pxAlign: false, padding: [4, 0, 0, null], cursor: { @@ -759,6 +786,7 @@ const UPlotChart: React.FC<{ const wrapperRef = useRef(null); const chartRef = useRef(null); const playheadFrameRef = useRef(currentFrameIndex); + const storeRef = useLatest(store); // -- Derived state ---------------------------------------------------------- @@ -792,6 +820,7 @@ const UPlotChart: React.FC<{ const opts = buildUPlotOptions({ store, + storeRef, chartType, hiddenPlaces, size, @@ -835,8 +864,7 @@ const UPlotChart: React.FC<{ // -- Effect 3: stream new data (no chart recreation) ------------------------ useEffect(() => { - // resetScales=false: our range functions handle bounds; skip redundant recalc - chartRef.current?.setData(data, false); + chartRef.current?.setData(data); }, [revision]); // -- Effect 4: playhead redraw --------------------------------------------- From 0179bc59ef1cec9521b4c5f5b547ece9f5ff5df3 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 15 Apr 2026 00:53:05 +0200 Subject: [PATCH 06/13] Lint --- .../Editor/panels/BottomPanel/subviews/simulation-timeline.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index 5675fc63040..4d55867cb0a 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -850,7 +850,6 @@ const UPlotChart: React.FC<{ // Recreate only when chart type, visible series, or size availability changes. // onScrub is stable (useStableCallback). Subsequent size changes trigger // setSize (Effect 2), not recreation. - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: see comment above }, [chartType, hiddenPlaces, store.places.length, hasSize]); // -- Effect 2: sync container size to existing chart ------------------------ From 46fdb7586493bf59a7d5ac02d090cdc8f0d7e9f2 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 15 Apr 2026 09:34:10 +0200 Subject: [PATCH 07/13] Memoize columnar data and remove dead store length check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI review fixes: - #10 Stacked data recomputed on every render — wrap data in useMemo keyed on revision/chartType/hiddenPlaces. The component opts out of React Compiler ("use no memo"), so without manual memoization the expensive buildStackedData ran on every playback frame even though Effect 3 only consumes it on revision changes - #11 Removed redundant store.length === 0 check inside the chart creation effect. The parent (SimulationTimelineContent) already gates on store.length === 0 and renders a "No simulation data" message, so UPlotChart only mounts when data exists. Comment clarifies the contract. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../subviews/simulation-timeline.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index 4d55867cb0a..3e991a94d6e 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -802,17 +802,27 @@ const UPlotChart: React.FC<{ setCurrentViewedFrame(Math.max(0, Math.min(idx, totalFrames - 1))); }); - // Columnar data from the store (React Compiler memoizes) - const data = - chartType === "stacked" - ? buildStackedData(store, hiddenPlaces) - : buildRunData(store, hiddenPlaces); + // Columnar data from the store. Manual useMemo because we opted out of + // React Compiler ("use no memo"), and buildStackedData allocates O(places × + // frames) per call. Without memoization it would recompute on every render + // (e.g. every playback frame), and the result would be silently discarded + // since Effect 3 only consumes it when `revision` changes. + // eslint-disable-next-line react-hooks/exhaustive-deps -- revision encodes store changes + const data = useMemo( + () => + chartType === "stacked" + ? buildStackedData(store, hiddenPlaces) + : buildRunData(store, hiddenPlaces), + [revision, chartType, hiddenPlaces], + ); // -- Effect 1: create/destroy uPlot on structural changes ------------------- useEffect(() => { + // Note: parent (SimulationTimelineContent) gates on store.length === 0, + // so this component only mounts once data is available. const wrapper = wrapperRef.current; - if (!wrapper || !size || store.length === 0) { + if (!wrapper || !size) { return; } From 30002ddbe124dc7b9d52aebbf27d40192ca2c045 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 15 Apr 2026 15:49:11 +0100 Subject: [PATCH 08/13] Remove unused d3-scale dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No source files import d3-scale — drop both the runtime dep and its @types package. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/@hashintel/petrinaut/package.json | 2 - yarn.lock | 60 +------------------------- 2 files changed, 2 insertions(+), 60 deletions(-) diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index 2e348dcc27d..69b8cc6e25b 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -44,7 +44,6 @@ "@hashintel/refractive": "workspace:^", "@monaco-editor/react": "4.8.0-rc.3", "@xyflow/react": "12.10.1", - "d3-scale": "4.0.2", "elkjs": "0.11.0", "fuzzysort": "3.1.0", "lodash-es": "4.18.1", @@ -67,7 +66,6 @@ "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", "@types/babel__standalone": "7.1.9", - "@types/d3-scale": "4.0.9", "@types/lodash-es": "4.17.12", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", diff --git a/yarn.lock b/yarn.lock index 074f616798c..7e97360b068 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7806,7 +7806,6 @@ __metadata: "@testing-library/dom": "npm:10.4.1" "@testing-library/react": "npm:16.3.2" "@types/babel__standalone": "npm:7.1.9" - "@types/d3-scale": "npm:4.0.9" "@types/lodash-es": "npm:4.17.12" "@types/react": "npm:19.2.7" "@types/react-dom": "npm:19.2.3" @@ -7814,7 +7813,6 @@ __metadata: "@vitejs/plugin-react": "npm:6.0.1" "@xyflow/react": "npm:12.10.1" babel-plugin-react-compiler: "npm:1.0.0" - d3-scale: "npm:4.0.2" elkjs: "npm:0.11.0" fuzzysort: "npm:3.1.0" jsdom: "npm:24.1.3" @@ -18690,7 +18688,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-scale@npm:*, @types/d3-scale@npm:4.0.9": +"@types/d3-scale@npm:*": version: 4.0.9 resolution: "@types/d3-scale@npm:4.0.9" dependencies: @@ -25546,15 +25544,6 @@ __metadata: languageName: node linkType: hard -"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3": - version: 3.2.4 - resolution: "d3-array@npm:3.2.4" - dependencies: - internmap: "npm:1 - 2" - checksum: 10c0/08b95e91130f98c1375db0e0af718f4371ccacef7d5d257727fe74f79a24383e79aba280b9ffae655483ffbbad4fd1dec4ade0119d88c4749f388641c8bf8c50 - languageName: node - linkType: hard - "d3-color@npm:1 - 3": version: 3.1.0 resolution: "d3-color@npm:3.1.0" @@ -25586,14 +25575,7 @@ __metadata: languageName: node linkType: hard -"d3-format@npm:1 - 3": - version: 3.1.0 - resolution: "d3-format@npm:3.1.0" - checksum: 10c0/049f5c0871ebce9859fc5e2f07f336b3c5bfff52a2540e0bac7e703fce567cd9346f4ad1079dd18d6f1e0eaa0599941c1810898926f10ac21a31fd0a34b4aa75 - languageName: node - linkType: hard - -"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1": +"d3-interpolate@npm:1 - 3, d3-interpolate@npm:^3.0.1": version: 3.0.1 resolution: "d3-interpolate@npm:3.0.1" dependencies: @@ -25602,19 +25584,6 @@ __metadata: languageName: node linkType: hard -"d3-scale@npm:4.0.2": - version: 4.0.2 - resolution: "d3-scale@npm:4.0.2" - dependencies: - d3-array: "npm:2.10.0 - 3" - d3-format: "npm:1 - 3" - d3-interpolate: "npm:1.2.0 - 3" - d3-time: "npm:2.1.1 - 3" - d3-time-format: "npm:2 - 4" - checksum: 10c0/65d9ad8c2641aec30ed5673a7410feb187a224d6ca8d1a520d68a7d6eac9d04caedbff4713d1e8545be33eb7fec5739983a7ab1d22d4e5ad35368c6729d362f1 - languageName: node - linkType: hard - "d3-selection@npm:2 - 3, d3-selection@npm:3, d3-selection@npm:^3.0.0": version: 3.0.0 resolution: "d3-selection@npm:3.0.0" @@ -25622,24 +25591,6 @@ __metadata: languageName: node linkType: hard -"d3-time-format@npm:2 - 4": - version: 4.1.0 - resolution: "d3-time-format@npm:4.1.0" - dependencies: - d3-time: "npm:1 - 3" - checksum: 10c0/735e00fb25a7fd5d418fac350018713ae394eefddb0d745fab12bbff0517f9cdb5f807c7bbe87bb6eeb06249662f8ea84fec075f7d0cd68609735b2ceb29d206 - languageName: node - linkType: hard - -"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3": - version: 3.1.0 - resolution: "d3-time@npm:3.1.0" - dependencies: - d3-array: "npm:2 - 3" - checksum: 10c0/a984f77e1aaeaa182679b46fbf57eceb6ebdb5f67d7578d6f68ef933f8eeb63737c0949991618a8d29472dbf43736c7d7f17c452b2770f8c1271191cba724ca1 - languageName: node - linkType: hard - "d3-timer@npm:1 - 3": version: 3.0.1 resolution: "d3-timer@npm:3.0.1" @@ -31823,13 +31774,6 @@ __metadata: languageName: node linkType: hard -"internmap@npm:1 - 2": - version: 2.0.3 - resolution: "internmap@npm:2.0.3" - checksum: 10c0/8cedd57f07bbc22501516fbfc70447f0c6812871d471096fad9ea603516eacc2137b633633daf432c029712df0baefd793686388ddf5737e3ea15074b877f7ed - languageName: node - linkType: hard - "interpret@npm:^1.0.0": version: 1.4.0 resolution: "interpret@npm:1.4.0" From c873ab2c2b066ea91a05b524a5a7f6fd5fb67144 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 15 Apr 2026 16:57:22 +0100 Subject: [PATCH 09/13] Fix y-axis label clipping in simulation timeline Swap chart padding from [4, 0, 0, null] to [0, 0, 4, null] so the bottom y-axis label isn't cut off; the top doesn't need padding since the ruler axis sits there. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Editor/panels/BottomPanel/subviews/simulation-timeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index 3e991a94d6e..3a8f58615ee 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -629,7 +629,7 @@ function buildUPlotOptions(opts: ChartOptions): uPlot.Options { series, bands, pxAlign: false, - padding: [4, 0, 0, null], + padding: [0, 0, 4, null], cursor: { lock: false, drag: { x: false, y: false, setScale: false }, From 03507fe86af7d177e0c7e6deda1e3a2a0d31edd3 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 15 Apr 2026 17:21:13 +0100 Subject: [PATCH 10/13] Collapse redundant chart wrapper divs Pass the chart-area className directly to UPlotChart and drop the inner absolute-positioned div. The defensive relative/absolute pattern is unnecessary because UPlotChart sizes its canvas in pixels via setSize, so it can't grow its parent. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../subviews/simulation-timeline.tsx | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index 3a8f58615ee..51b8e064ee2 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -31,11 +31,6 @@ const chartAreaStyle = css({ minHeight: "[0]", }); -const chartWrapperStyle = css({ - position: "absolute", - inset: "[0]", -}); - const legendContainerStyle = css({ display: "flex", flexWrap: "wrap", @@ -773,6 +768,7 @@ const UPlotChart: React.FC<{ revision: number; totalFrames: number; currentFrameIndex: number; + className?: string; }> = ({ store, chartType, @@ -780,6 +776,7 @@ const UPlotChart: React.FC<{ revision, totalFrames, currentFrameIndex, + className, }) => { "use no memo"; // imperative uPlot lifecycle const { setCurrentViewedFrame } = use(PlaybackContext); @@ -883,7 +880,7 @@ const UPlotChart: React.FC<{ chartRef.current?.redraw(false, false); }, [currentFrameIndex]); - return
; + return
; }; // -- Legend -------------------------------------------------------------------- @@ -963,18 +960,15 @@ const SimulationTimelineContent: React.FC = () => { return (
-
-
- -
-
+ Date: Wed, 15 Apr 2026 18:15:32 +0100 Subject: [PATCH 11/13] Reserve canvas space for the playhead pin Bump uPlot's right padding from 0 to 8px so the 12px-wide playhead pin isn't clipped by the canvas edge when the current frame is at the rightmost position. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Editor/panels/BottomPanel/subviews/simulation-timeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index 51b8e064ee2..17f2f94919e 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -624,7 +624,7 @@ function buildUPlotOptions(opts: ChartOptions): uPlot.Options { series, bands, pxAlign: false, - padding: [0, 0, 4, null], + padding: [0, 8, 4, null], cursor: { lock: false, drag: { x: false, y: false, setScale: false }, From 4b6d979e70ddb3e45571afd82f5b4e8850265ce7 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 15 Apr 2026 18:34:33 +0100 Subject: [PATCH 12/13] Fix restart revision bump and separator DPR scaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small correctness fixes from PR review: - When the simulation restarts (totalFrames drops), bump the revision counter immediately so React re-renders to pick up the empty store. Previously the bump only happened when subsequent fetched frames were pushed, so a cancelled/empty follow-up fetch left the chart displaying stale pre-restart data. - In the drawClear hook, scale the separator line's width and its sub-pixel offset by devicePixelRatio. uPlot's canvas uses physical pixels, so the previous hardcoded 1/0.5 values rendered at 0.5/0.25 CSS pixels on HiDPI displays — thinner than uPlot's own grid lines. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../panels/BottomPanel/subviews/simulation-timeline.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index 17f2f94919e..400533c5950 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -222,6 +222,7 @@ function useStreamingData(): { if (totalFrames < processedRef.current) { storeRef.current = createEmptyStore(store.places); processedRef.current = 0; + setRevision((r) => r + 1); } const startIndex = processedRef.current; @@ -680,12 +681,13 @@ function buildUPlotOptions(opts: ChartOptions): uPlot.Options { (u) => { const { ctx } = u; const { left: bx, width: bw, top: by } = u.bbox; + const dpr = devicePixelRatio; ctx.save(); ctx.strokeStyle = "#cbd5e1"; - ctx.lineWidth = 1; + ctx.lineWidth = dpr; ctx.beginPath(); - ctx.moveTo(bx, by - 0.5); - ctx.lineTo(bx + bw, by - 0.5); + ctx.moveTo(bx, by - 0.5 * dpr); + ctx.lineTo(bx + bw, by - 0.5 * dpr); ctx.stroke(); ctx.restore(); }, From 3a23cddf857f941cf241e9d85e512bd41967a450 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 15 Apr 2026 19:06:18 +0100 Subject: [PATCH 13/13] Lint --- .../Editor/panels/BottomPanel/subviews/simulation-timeline.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx index 400533c5950..736b028bfbb 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/subviews/simulation-timeline.tsx @@ -806,7 +806,6 @@ const UPlotChart: React.FC<{ // frames) per call. Without memoization it would recompute on every render // (e.g. every playback frame), and the result would be silently discarded // since Effect 3 only consumes it when `revision` changes. - // eslint-disable-next-line react-hooks/exhaustive-deps -- revision encodes store changes const data = useMemo( () => chartType === "stacked"