| name | plot-internals |
| description | Deep Chart.js integration knowledge for the Plot panel: Worker-based rendering, dataset management, downsampling strategies, scale handling, and interaction patterns. |
Plot Internals Skill
Chart.js Worker Architecture
ChartRenderer.worker.ts
Chart.register(LineElement, PointElement, LineController, ...);
Comlink.expose({
async init(args: InitArgs): Promise<ChartRenderer> {
await fontLoaded;
return new ChartRenderer(args);
}
});
ChartRenderer Class
- Owns a single
Chart instance (Chart.js)
- Receives
UpdateAction messages from main thread
- Returns
Bounds (data range) and HoverElement[] (interaction)
- Manages scales, datasets, annotations all within the Worker
Dataset Building
TimestampDatasetsBuilderImpl (Worker)
class TimestampDatasetsBuilderImpl {
#seriesMap = new Map<string, SeriesData>();
handleMessages(messages: MessageEvent[]): void {
}
buildActions(): UpdateAction[] {
return this.#seriesMap.values().map(seriesData => ({
datasets: [{ data: seriesData.points }],
}));
}
}
Two Distinct Point Caps
There are two separate constants — don't conflate them:
| Constant | Value | File | Purpose |
|---|
MAX_CURRENT_DATUMS_PER_SERIES | 50_000 | panels/Plot/builders/TimestampDatasetsBuilderImpl.ts (also CustomDatasetsBuilderImpl.ts) | Accumulation cap for live/current data per series |
MAX_POINTS | 5_000 | components/TimeBasedChart/downsample.ts | Target rendered points across all series combined |
- When current data exceeds
MAX_CURRENT_DATUMS_PER_SERIES, the builder culls the oldest data via
splice, dropping the overflow plus an extra MAX_CURRENT_DATUMS_PER_SERIES * 0.25 so culling
isn't triggered every single append.
- The per-series render budget is
MAX_POINTS / numSeries — the 5,000 is shared across all signals.
Downsampling Algorithm (NOT LTTB)
Downsampling (components/TimeBasedChart/downsample.ts) is a custom stateful pixel-interval
min/max bucketing, not LTTB:
- The viewport is divided into intervals sized by
MINIMUM_PIXEL_DISTANCE = 3 pixels
- Each interval emits up to
POINTS_PER_INTERVAL = 4 points: first, last, min, and max
(intFirst, intLast, intMin, intMax)
- State (
DownsampleState) is carried across batches via a cursor so streaming data downsamples
incrementally without reprocessing consumed points
- Re-triggered when the viewport/zoom changes (interval sizing depends on pixel bounds)
⚠️ Do not describe this as LTTB / "Largest-Triangle-Three-Buckets". It is interval min/max
decimation keyed on pixel distance.
Range Source Flag
#hasRangeSource: boolean;
When #hasRangeSource is true:
- Builder receives preloaded block data (historical)
- Must merge block data with current-frame messages
- Datasets represent full recording, not just visible window
Scale Management
Time Axis (X)
TimeScale from Chart.js chartjs-adapter-luxon
- Handles nanosecond timestamps (converted to milliseconds for Chart.js)
- Zoom/pan interactions update visible time range
Value Axis (Y)
- Auto-scaling based on visible data range
- Per-series Y-axis support (left/right axis)
- Manual bounds via panel settings
Interaction Handling
Hover/Tooltip
const elements = await renderer.getElementsAtPixel({ x, y });
Zoom/Pan
- Handled via Chart.js annotation plugin
- Zoom: scroll wheel → update time range → PlotCoordinator notifies builder
- Pan: drag → update time range → same flow
Font Loading
const fontLoaded = loadDefaultFont();
Performance Considerations
- Worker isolation: Chart.js runs entirely in Worker — no main thread blocking
- OffscreenCanvas: Canvas operations don't trigger main-thread compositing
- Batch updates: Multiple dataset changes coalesced into single
chart.update()
- Lazy rendering: Chart only re-renders when data or options actually change
- Transfer optimization: Dataset arrays transferred (not copied) when possible
- Memory: 50k cap × ~32 bytes/point × N series = bounded memory usage