diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index f5a4408993c..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", @@ -52,6 +51,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", @@ -66,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/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/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/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 304cefa0a4b..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 @@ -1,9 +1,13 @@ import { css } from "@hashintel/ds-helpers/css"; -import { scaleLinear } from "d3-scale"; -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 { useLatest } from "../../../../../hooks/use-latest"; +import { useStableCallback } from "../../../../../hooks/use-stable-callback"; import { PlaybackContext } from "../../../../../playback/context"; import { SimulationContext } from "../../../../../simulation/context"; import { @@ -12,158 +16,51 @@ 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", flexDirection: "column", height: "[100%]", - gap: "[8px]", + paddingTop: "[4px]", }); -const chartRowStyle = css({ - display: "flex", +const chartAreaStyle = css({ + position: "relative", flex: "[1]", - minHeight: "[60px]", - gap: "[4px]", + minHeight: "[0]", }); -const yAxisStyle = css({ - position: "relative", +const legendContainerStyle = css({ display: "flex", - flexDirection: "column", - alignItems: "flex-end", - fontSize: "[10px]", + flexWrap: "wrap", + gap: "[12px]", + fontSize: "[11px]", 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", + paddingY: "3", + paddingX: "3", }); -const playheadStyle = css({ - position: "absolute", - top: "[0]", - bottom: "[0]", - width: "[1px]", - pointerEvents: "none", +const legendItemStyle = css({ 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]", + gap: "[4px]", + cursor: "pointer", + userSelect: "none", + transition: "[opacity 0.15s ease]", + _hover: { + opacity: 1, }, }); -/** - * 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 legendColorStyle = css({ + width: "[10px]", + height: "[10px]", + borderRadius: "[2px]", }); const tooltipStyle = css({ - position: "fixed", + position: "absolute", pointerEvents: "none", backgroundColor: "[rgba(0, 0, 0, 0.85)]", color: "neutral.s00", @@ -174,8 +71,7 @@ const tooltipStyle = css({ zIndex: "[1000]", whiteSpace: "nowrap", boxShadow: "[0 2px 8px rgba(0, 0, 0, 0.25)]", - transform: "translate(-50%, -100%)", - marginTop: "[-8px]", + display: "none", }); const tooltipLabelStyle = css({ @@ -184,7 +80,7 @@ const tooltipLabelStyle = css({ gap: "[6px]", }); -const tooltipColorDotStyle = css({ +const tooltipDotStyle = css({ width: "[8px]", height: "[8px]", borderRadius: "[50%]", @@ -196,34 +92,8 @@ const tooltipValueStyle = css({ marginLeft: "[4px]", }); -const legendContainerStyle = css({ - display: "flex", - flexWrap: "wrap", - gap: "[12px]", - fontSize: "[11px]", - color: "[#666]", - paddingTop: "[4px]", -}); - -const legendItemStyle = css({ - display: "flex", - alignItems: "center", - gap: "[4px]", - cursor: "pointer", - userSelect: "none", - transition: "[opacity 0.15s ease]", - _hover: { - opacity: 1, - }, -}); - -const legendColorStyle = css({ - width: "[10px]", - height: "[10px]", - borderRadius: "[2px]", -}); +// -- Constants ---------------------------------------------------------------- -// Default color palette for places without a specific color const DEFAULT_COLORS = [ "#3b82f6", // blue "#ef4444", // red @@ -240,9 +110,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,1051 +135,809 @@ 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: [], - }); + 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], + ); - // 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 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; + setRevision((r) => r + 1); } - 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, placeMeta]); - 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(); - let maxValue: number; + return [store.columns[0]!, ...series] as uPlot.AlignedData; +} - 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; - } - 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); - } +// -- Tooltip DOM (mutated imperatively in cursor hook — no React renders) ----- - // Use D3 to create a nice scale - const scale = scaleLinear().domain([0, maxValue]).nice(); - const niceDomain = scale.domain(); - const yMax = niceDomain[1] ?? maxValue; +interface TooltipNodes { + root: HTMLDivElement; + dot: HTMLDivElement; + name: HTMLSpanElement; + value: HTMLSpanElement; + time: HTMLDivElement; + frame: HTMLDivElement; +} - // Get tick values (aim for 3-5 ticks based on the range) - const ticks = scale.ticks(4); +function createTooltip(): TooltipNodes { + const root = document.createElement("div"); + root.className = tooltipStyle; - return { - yMax, - ticks, - toPercent: (value: number) => 100 - (value / yMax) * 100, - }; - }, [compartmentData, chartType, hiddenPlaces]); -}; + const label = document.createElement("div"); + label.className = tooltipLabelStyle; -/** - * Y-axis component that displays tick labels. - */ -const YAxis: React.FC<{ scale: YAxisScale }> = ({ scale }) => { - return ( -
- {scale.ticks.map((tick) => ( - - {tick} - - ))} -
- ); -}; + const dot = document.createElement("div"); + dot.className = tooltipDotStyle; -/** - * Tooltip component for displaying token count on hover. - */ -const ChartTooltip: React.FC<{ tooltip: TooltipState | null }> = ({ - tooltip, -}) => { - if (!tooltip?.visible) { - return null; - } + const name = document.createElement("span"); - return ( -
-
-
- {tooltip.placeName} - {tooltip.value} -
-
- {tooltip.time.toFixed(3)}s -
-
- Frame {tooltip.frameIndex} -
-
- ); -}; + const value = document.createElement("span"); + value.className = tooltipValueStyle; -/** - * Shared playhead indicator component for timeline charts. - */ -const PlayheadIndicator: React.FC<{ totalFrames: number }> = ({ - totalFrames, -}) => { - const { currentFrameIndex } = use(PlaybackContext); - const frameIndex = currentFrameIndex; + label.append(dot, name, value); - return ( -
-
-
-
- ); -}; + const time = document.createElement("div"); + time.style.cssText = "font-size:10px;opacity:0.8;margin-top:2px"; -/** - * Shared legend component for timeline charts. - */ -const TimelineLegend: React.FC<{ - compartmentData: CompartmentData[]; - hiddenPlaces: Set; - hoveredPlaceId: string | null; - onToggleVisibility: (placeId: string) => void; - onHover: (placeId: string | null) => void; -}> = ({ - compartmentData, - hiddenPlaces, - hoveredPlaceId, - onToggleVisibility, - onHover, -}) => ( -
- {compartmentData.map((data) => { - const isHidden = hiddenPlaces.has(data.placeId); - const isHovered = hoveredPlaceId === data.placeId; - const isDimmed = hoveredPlaceId && !isHovered; + const frame = document.createElement("div"); + frame.style.cssText = "font-size:9px;opacity:0.6;margin-top:2px"; - return ( -
onToggleVisibility(data.placeId)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onToggleVisibility(data.placeId); - } - }} - onMouseEnter={() => onHover(data.placeId)} - onMouseLeave={() => onHover(null)} - onFocus={() => onHover(data.placeId)} - onBlur={() => onHover(null)} - style={{ - opacity: isHidden ? 0.4 : isDimmed ? 0.6 : 1, - textDecoration: isHidden ? "line-through" : "none", - }} - > -
- {data.placeName} -
- ); - })} -
-); + root.append(label, time, frame); -interface ChartProps { - compartmentData: CompartmentData[]; - frameTimes: number[]; - legendState: LegendState; - yAxisScale: YAxisScale; - onTooltipChange: (tooltip: TooltipState | null) => void; - onPlaceHover: (placeId: string | null) => void; + return { root, dot, name, value, time, frame }; } /** - * 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. + * 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. */ -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); +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; +} - const chartRef = useRef(null); - const isDraggingRef = useRef(false); +// -- Hover target resolution (shared by tooltip) ----------------------------- - // Track locally hovered place (from SVG path hover via event delegation) - const [localHoveredPlaceId, setLocalHoveredPlaceId] = useState( - null, - ); +interface HoverHit { + place: PlaceMeta; + value: number; + idx: number; + time: number; +} - const { hiddenPlaces, hoveredPlaceId } = legendState; +/** + * 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, + focusedSeriesIdx: number, +): HoverHit | null { + const idx = u.cursor.idx; + if (idx == null || idx < 0 || store.length === 0) { + return null; + } - // Use local hover if available, otherwise fall back to legend hover - const activeHoveredPlaceId = localHoveredPlaceId ?? hoveredPlaceId; + let placeIdx: number; + let value: number; - // Calculate chart dimensions and scales - const chartMetrics = useMemo(() => { - if (compartmentData.length === 0 || totalFrames === 0) { + 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; + } - 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], - ); + const place = store.places[placeIdx]; + if (!place) { + return null; + } - // Handle mouse interaction for scrubbing - const handleScrub = useCallback( - (event: React.MouseEvent) => { - const frameIndex = getFrameFromEvent(event); - if (frameIndex !== null) { - setCurrentViewedFrame(frameIndex); - } - }, - [getFrameFromEvent, setCurrentViewedFrame], - ); + return { place, value, idx, time: store.columns[0]![idx] ?? 0 }; +} - // 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; - } +// -- Tooltip positioning (edge-clamped inside u.over) ------------------------- + +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; + } - const frameIndex = getFrameFromEvent(event); - if (frameIndex === null) { - onTooltipChange(null); - return; - } + let top = cy - th - margin; + if (top < 0) { + top = Math.min(cy + margin, oh - th); + } - const placeData = compartmentData.find( - (data) => data.placeId === hoveredId, - ); - if (!placeData || hiddenPlaces.has(hoveredId)) { - onTooltipChange(null); - return; - } + t.root.style.left = `${left}px`; + t.root.style.top = `${top}px`; +} - 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, - ], - ); +// -- Playhead drawing (Logic Pro-style pin) ----------------------------------- - /** - * 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; - }, - [], - ); +/** 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 handleMouseDown = useCallback( - (event: React.MouseEvent) => { - isDraggingRef.current = true; - handleScrub(event); - }, - [handleScrub], - ); + 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(); +} - /** - * 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); - } +// -- uPlot options builder ---------------------------------------------------- - // Event delegation: extract placeId from the event target - const placeId = getPlaceIdFromEvent(event); +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 }; + onScrub: (frameIndex: number) => void; + getPlayheadFrame: () => number; + tooltip: TooltipNodes; +} - // Only update state if hover target changed - if (placeId !== localHoveredPlaceId) { - setLocalHoveredPlaceId(placeId); - onPlaceHover(placeId); +function buildUPlotOptions(opts: ChartOptions): uPlot.Options { + const { + store, + storeRef, + 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) => { + // 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; + } + positionTooltip(t, u, hit); + }; + + // 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)) + .reverse(); + for (const p of visible) { + series.push({ + label: p.placeName, + stroke: p.color, + fill: `color-mix(in srgb, ${p.color} 53%, transparent)`, + 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({ + label: p.placeName, + stroke: p.color, + width: 2, + show: !hiddenPlaces.has(p.placeId), + }); + } + } - // Update tooltip with current hover state - updateTooltip(event, placeId ?? hoveredPlaceId); + return { + width: size.width, + height: size.height, + series, + bands, + pxAlign: false, + padding: [0, 8, 4, null], + cursor: { + lock: false, + drag: { x: false, y: false, setScale: false }, + focus: { prox: 16 }, + bind: { + mousedown: (u, _targ, handler) => (e: MouseEvent) => { + handler(e); + if (u.cursor.left != null && u.cursor.left >= 0) { + onScrub(u.posToIdx(u.cursor.left)); + } + 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; + }, + }, }, - [ - handleScrub, - getPlaceIdFromEvent, - localHoveredPlaceId, - onPlaceHover, - updateTooltip, - hoveredPlaceId, + legend: { show: false }, + focus: { alpha: chartType === "stacked" ? 1 : 0.3 }, + axes: [ + { + show: true, + side: 0, + size: 26, + font: "10px system-ui", + stroke: "#475569", + grid: { stroke: "#f3f4f6", width: 1 }, + ticks: { stroke: "#cbd5e1", width: 1, size: 6 }, + values: (_u, vals) => vals.map((v) => `${v}s`), + }, + { + show: true, + size: 54, + font: "10px system-ui", + stroke: "#999", + grid: { stroke: "#f3f4f6", width: 1, dash: [4, 4] }, + ticks: { stroke: "#e5e7eb", width: 1 }, + }, ], - ); - - 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 ")}`; + scales: { + 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)], + }, }, - [chartMetrics], - ); + hooks: { + drawClear: [ + (u) => { + const { ctx } = u; + const { left: bx, width: bw, top: by } = u.bbox; + const dpr = devicePixelRatio; + ctx.save(); + ctx.strokeStyle = "#cbd5e1"; + ctx.lineWidth = dpr; + ctx.beginPath(); + ctx.moveTo(bx, by - 0.5 * dpr); + ctx.lineTo(bx + bw, by - 0.5 * dpr); + ctx.stroke(); + ctx.restore(); + }, + ], + setSeries: [ + (u, sIdx) => { + focused = sIdx ?? -1; + updateTooltip(u); + }, + ], + setCursor: [(u) => updateTooltip(u)], + draw: [(u) => drawPlayhead(u, getPlayheadFrame())], + }, + }; +} - if (totalFrames === 0 || compartmentData.length === 0 || !chartMetrics) { - return null; - } +// -- Ruler scrubbing (extracted from chart effect) ---------------------------- - // Filter visible data once - const visibleData = compartmentData.filter( - (data) => !hiddenPlaces.has(data.placeId), - ); +/** + * 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)); + } + }; - return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- interactive chart SVG - - {/* Background grid lines */} - - - + 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); + }; +} - {/* 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 */} - - - ))} - - ); -}; +// -- uPlot chart 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, +const UPlotChart: React.FC<{ + store: StreamingStore; + chartType: TimelineChartType; + hiddenPlaces: Set; + revision: number; + totalFrames: number; + currentFrameIndex: number; + className?: string; +}> = ({ + store, + chartType, + hiddenPlaces, + revision, + totalFrames, + currentFrameIndex, + className, }) => { - "use no memo"; // Complex chart with manual memoization — compiler cannot preserve existing useMemo/useCallback patterns - const { totalFrames } = use(SimulationContext); + "use no memo"; // imperative uPlot lifecycle const { setCurrentViewedFrame } = use(PlaybackContext); + const wrapperRef = useRef(null); + const chartRef = useRef(null); + const playheadFrameRef = useRef(currentFrameIndex); + const storeRef = useLatest(store); + + // -- 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; + + // 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))); + }); - const chartRef = useRef(null); - const isDraggingRef = useRef(false); - - // Track locally hovered place (from SVG path hover via event delegation) - const [localHoveredPlaceId, setLocalHoveredPlaceId] = useState( - null, + // 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. + const data = useMemo( + () => + chartType === "stacked" + ? buildStackedData(store, hiddenPlaces) + : buildRunData(store, hiddenPlaces), + [revision, chartType, hiddenPlaces], ); - const { hiddenPlaces, hoveredPlaceId } = legendState; - - // Use local hover if available, otherwise fall back to legend hover - const activeHoveredPlaceId = localHoveredPlaceId ?? hoveredPlaceId; + // -- Effect 1: create/destroy uPlot on structural changes ------------------- - // 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, - }); + 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) { + return; } - 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], - ); + const tooltip = createTooltip(); - // 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 opts = buildUPlotOptions({ + store, + storeRef, + chartType, + hiddenPlaces, + size, + onScrub, + getPlayheadFrame: () => playheadFrameRef.current, + tooltip, + }); - const frameIndex = getFrameFromEvent(event); - if (frameIndex === null) { - onTooltipChange(null); - return; - } + chartRef.current?.destroy(); - // 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; - } + // eslint-disable-next-line new-cap -- uPlot's constructor is lowercase by convention + const u = new uPlot(opts, data, wrapper); + chartRef.current = u; - 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, - ], - ); + // 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); - /** - * 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 cleanupRuler = attachRulerScrubbing(u, onScrub); - const handleMouseDown = useCallback( - (event: React.MouseEvent) => { - isDraggingRef.current = true; - handleScrub(event); - }, - [handleScrub], - ); + return () => { + cleanupRuler(); + u.destroy(); + chartRef.current = null; + }; + // Recreate only when chart type, visible series, or size availability changes. + // onScrub is stable (useStableCallback). Subsequent size changes trigger + // setSize (Effect 2), not recreation. + }, [chartType, hiddenPlaces, store.places.length, hasSize]); - /** - * 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); - } + // -- Effect 2: sync container size to existing chart ------------------------ - // Event delegation: extract placeId from the event target - const placeId = getPlaceIdFromEvent(event); + useEffect(() => { + if (chartRef.current && size && size.width > 0 && size.height > 0) { + chartRef.current.setSize(size); + } + }, [size]); - // Only update state if hover target changed - if (placeId !== localHoveredPlaceId) { - setLocalHoveredPlaceId(placeId); - onPlaceHover(placeId); - } + // -- Effect 3: stream new data (no chart recreation) ------------------------ - // Update tooltip with current hover state - updateTooltip(event, placeId ?? hoveredPlaceId); - }, - [ - handleScrub, - getPlaceIdFromEvent, - localHoveredPlaceId, - onPlaceHover, - updateTooltip, - hoveredPlaceId, - ], - ); + useEffect(() => { + chartRef.current?.setData(data); + }, [revision]); - 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 ""; - } + // -- Effect 4: playhead redraw --------------------------------------------- - // 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}`; - }); + useEffect(() => { + playheadFrameRef.current = currentFrameIndex; + chartRef.current?.redraw(false, false); + }, [currentFrameIndex]); - const basePoints = baseValues - .map((value, index) => { - const x = chartMetrics.xScale(index, width); - const y = chartMetrics.yScale(value, height); - return `${x},${y}`; - }) - .reverse(); + return
; +}; - return `M ${topPoints.join(" L ")} L ${basePoints.join(" L ")} Z`; - }, - [chartMetrics], - ); +// -- Legend -------------------------------------------------------------------- - if (totalFrames === 0 || compartmentData.length === 0 || !chartMetrics) { - return null; - } +const TimelineLegend: React.FC<{ + places: PlaceMeta[]; + hiddenPlaces: Set; + onToggleVisibility: (placeId: string) => void; +}> = ({ places, hiddenPlaces, onToggleVisibility }) => ( +
+ {places.map((p) => { + const isHidden = hiddenPlaces.has(p.placeId); - return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- interactive chart SVG - - {/* Background grid lines */} - - - + return ( +
onToggleVisibility(p.placeId)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onToggleVisibility(p.placeId); + } + }} + style={{ + opacity: isHidden ? 0.4 : 1, + textDecoration: isHidden ? "line-through" : "none", + }} + > +
+ {p.placeName} +
+ ); + })} +
+); - {/* Stacked areas - render from bottom to top */} - {/* CSS handles opacity/dimming via data-place-id and data-hovered attributes */} - {stackedData.map((data) => ( - - ))} - - ); -}; +// -- Main component ----------------------------------------------------------- -/** - * 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) => { + const togglePlaceVisibility = (placeId: string) => { setHiddenPlaces((prev) => { const next = new Set(prev); if (next.has(placeId)) { @@ -1311,17 +947,9 @@ const SimulationTimelineContent: React.FC = () => { } return next; }); - }, []); + }; - 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 +961,26 @@ 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", @@ -1381,4 +989,5 @@ export const simulationTimelineSubView: SubView = { "View the simulation timeline with compartment time-series. Click/drag to scrub through frames.", component: SimulationTimelineContent, renderHeaderAction: () => , + noPadding: true, }; diff --git a/yarn.lock b/yarn.lock index 1ea45a77693..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" @@ -7830,6 +7828,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" @@ -18689,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: @@ -25545,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" @@ -25585,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: @@ -25601,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" @@ -25621,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" @@ -31822,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" @@ -45230,6 +45175,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"