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 (
-