Pre-M3 Performance

Worker Execution + Spec Curve Hover

2026-03-26 Design Approved Scope: Two independent performance items from the roadmap

Item 1: Web Worker Streaming Execution

Problem

runPipeline() in src/ui/store/pipeline.ts:115 calls executePipeline() synchronously on the main thread. Both CSV parsing (PapaParse) and statistical computation freeze the UI until complete.

The Worker infrastructure (stats.worker.ts, protocol.ts) is fully implemented but disconnected.

Design

New file: src/workers/worker-manager.ts

Singleton class owning Worker lifecycle and orchestrating execution.

init(): void
loadDataset(csv: string): Promise<Dataset>
executePipeline(pipeline, skipNodeIds, onNodeComplete, onNodeError): Promise<void>
terminate(): void

Request/response correlation: Each message has an id field (already in the protocol). WorkerManager maintains a Map<string, { resolve, reject }> of pending requests. Worker onmessage handler looks up the pending promise by id and resolves/rejects it.

Fallback: If new Worker() throws (e.g., test environments), fall back to synchronous executePipeline() on main thread with a console warning.

Data Flow

CSV loading (currently freezes UI):

User drops CSV FileReader.readAsText() Worker: parseCSV() DATASET_LOADED Store updates

Pipeline execution (currently synchronous):

runPipeline() topoSort (main) Worker: node 1 store update Worker: node 2 store update ...

Changes to src/ui/store/data.ts

loadCSVFile() delegates CSV parsing to the Worker. No more parseCSV() call on main thread.

Changes to src/ui/store/pipeline.ts

runPipeline(dataset) becomes async:

  1. Mark all nodes as running, set executing: true (unchanged)
  2. Call workerManager.executePipeline() with streaming callbacks:
    • onComplete(nodeId, result)set() to update that node to complete
    • onError(nodeId, error)set() to update that node to error
  3. When promise resolves, set executing: false

Changes to src/core/pipeline/executor.ts

No changes to Worker files

The existing protocol and Worker implementation already support exactly this flow. LOAD_DATASET accepts CSV string, parses it, responds with Dataset. EXECUTE_NODE accepts a node + inputs, executes, responds with result.

Testing


Item 2: Spec Curve Hover Performance

Problem

spec-curve.tsx includes hoveredCellCoordinate in renderPlot’s dependency array. On every hover change:

mouseMove store update useCallback recreated ChartContainer useEffect 2× Plot.plot() rebuild

Two full Observable Plot SVG renders per mousemove event (~60Hz) just to change opacity values. Partial fix in place (memoized data, no-op guards) but SVG rebuild still happens.

Design

Remove hoveredCellCoordinate from renderPlot dependencies

Change dependency array from [data, hoveredCellCoordinate] to [data]. SVGs rebuild only when underlying data changes.

Tag SVG elements with data-coordinate

After each Plot.plot() call, post-process the returned SVG:

  1. Use the existing sortedColumns map (sort index → coordinate)
  2. Query mark elements:
    • Estimate panel: circle (dots), line (CI rules)
    • Indicator panel: rect (cells)
  3. Bucket elements by x-position (cx or x attribute), sort by x, assign data-coordinate from sortedColumns[i].coordinate. Positions are deterministic because Plot uses the same linear scale with a shared xDomain.

Hover effect via direct DOM mutation

const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  const container = containerRef.current;
  if (!container) return;
  const elements = container.querySelectorAll('[data-coordinate]');
  for (const el of elements) {
    const coord = el.getAttribute('data-coordinate');
    const match = hoveredCellCoordinate === null
      || coord === hoveredCellCoordinate;
    (el as HTMLElement).style.opacity = match ? '' : '0.25';
  }
}, [hoveredCellCoordinate]);

Remove opacity from Plot mark data

Lines 99 and 106 currently compute per-mark opacity based on hoveredCellCoordinate. Remove these — all marks render at full opacity. Hover highlighting is handled entirely by DOM mutation.

No changes to chart-container.tsx

ChartContainer stays unchanged. It re-renders when renderPlot changes, which now only happens on data changes.

After fix:

mouseMove store update useEffect: set style.opacity

Zero SVG rebuilds. Direct DOM mutation on <200 elements is effectively free.

Testing


Execution Order

These two items are independent and can be implemented in parallel. Recommended sequence: Worker first (higher impact), then spec curve hover (polish).

Files Changed

FileChange
src/workers/worker-manager.ts NEW Worker singleton + streaming orchestration
src/ui/store/pipeline.ts runPipeline → async, uses WorkerManager
src/ui/store/data.ts loadCSVFile → delegates CSV parsing to Worker
src/core/pipeline/executor.ts Export topologicalSort
src/ui/components/charts/spec-curve.tsx Remove hover from renderPlot deps, add DOM mutation effect, tag SVG elements