refactor: columnar typed-array chart store - #1936
Conversation
Replaces the chart store's row-object model - a `ChartState` with a wide-open `[index: string]: any` holding `Readonly<ChartData>[]`, where every value was a `number | Date` union - with a closed, explicitly typed state backed by columnar `Float64Array` buffers. `src/util/chart-buffer.ts` is the new core: a capacity-bounded buffer whose live window is `[offset, offset + count)` over a `time` array and one column per series. Appends are amortised O(1) with no steady-state allocation, expiry is a binary search that advances `offset` (was a linear `findIndex` plus an O(n) `splice`), and `chartBufferSource()` hands ECharts `subarray()` views directly via its keyed-columns `dataset.source` format - so there is no per-render materialisation. Vue 2 cannot observe typed array writes, so a `revision` counter is the single reactive change signal; the arrays themselves are `markRaw`'d so the strict-mode deep watcher does not enumerate every element. For a typical 8-sensor printer at the default 1200-sample retention this cuts the thermal bucket from ~550-600 KB of row objects and `Date`s to ~165 KB, and removes essentially all allocation on the 1 Hz chart path. Also fixes a pre-existing bug: ECharts derived its dimension names from `chartData[0]`, and `initSeries` ran once behind an `initialized` flag, so any sensor or `#target`/`#power`/`#speed` column that first appeared after the initial sample was never charted for the rest of the session. Series are now built incrementally from the buffer's column list. Supporting changes: - `src/store/charts/thermal-columns.ts` centralises the `<sensor>#<sub>` column convention that was duplicated across six files. - `src/util/chart-tooltip.ts` centralises the positional `param.value` lookup that keyed columns require, shared by all three formatters. - `smoothChartData` becomes `smoothChartSource`, operating on typed array views; unsmoothed columns pass through as views. - `setInitCharts` no longer `Object.assign`s an arbitrary Moonraker DB document onto store state - only `selectedLegends` is read. - Retention for the system, MCU and sensor buckets is now `Globals.CHART_SYSTEM_RETENTION` rather than a literal repeated in eight places. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
Move ChartBuffer and ChartDataSource into the charts store types, matching how MoveStore lives in store/gcodePreview/types. Drop the ThermalColumn alias and the four per-bucket column unions, which were all aliases of string used only at their declaration, and remove ChartBuffer's now-pointless generic parameter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
initTempStore built a row object and a Date per sample, and padded every field up to the retention with copies of its oldest value, mutating the socket payload to do it. Replace that with a pure builder that writes the buffer columns directly. Short history is now right-aligned rather than padded, so a fresh boot no longer draws a flat line back across data that was never recorded. The chart's x-axis window is unaffected. Key selection moves from state.printer.printer to printer/getChartableSensors, matching handleAddChartEntry, so history no longer creates columns that get no series and no live updates. Values are rounded to 2dp for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
Derive `ChartTooltipParam` from echarts' `DefaultLabelFormatterCallbackParams` via `Pick` rather than redeclaring the three fields by hand, so they cannot drift from what echarts actually passes the tooltip formatter. Type `historyFields` with a `HistoryField` alias on the declaration instead of `as const satisfies …`, dropping the trailing 120-char type and pointing typo errors at the offending element. Normalize guard clauses to braced bodies and rename loop counters to `index`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
`machine.proc_stats` runs in the identify bootstrap, so on every reconnect its response carries Moonraker's rolling backlog of `moonraker_stats`. Those were appended one sample at a time behind the samples already in the buffer, leaving it as [pre-drop live … T_drop][T_drop - 30min … now]. That breaks the monotonic ordering `dropExpired` assumes, so which samples expire became arbitrary, and the chart drew a line doubling back across the pre-drop window with every overlapping sample duplicated. Treat the array form as a history load, mirroring `initTempStore`: build the buffer column-major from the newest `retention` samples and replace the bucket in one commit, rather than ~1800 appends each bumping `revision` and rescanning for expiry. The single-sample notification form still appends as before. `setResetChartStore` is deliberately untouched - it is shared with `resetKlippy`, which does not re-fetch proc stats, so clearing the bucket there would blank the chart with nothing to refill it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
Braced guard bodies, chained calls broken onto their own lines, and blank lines around statements - matching the pass already applied to the chart utils. No behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
There was a problem hiding this comment.
Pull request overview
Refactors Fluidd’s charting pipeline from row-object ChartData[] into a typed, columnar ChartBuffer backed by Float64Array columns, enabling allocation-free appends/expiry and keyed-column ECharts datasets. This also aligns history generation with the live chart path and fixes reconnect/backlog ordering issues (notably Moonraker proc_stats).
Changes:
- Introduces
src/util/chart-buffer.tsand migrates chart state to fixed, explicitly-typed buffers (thermal,moonraker,klipper,memory,diagnostics, plusmcus/sensorsmaps). - Switches chart rendering to ECharts “keyed columns” sources, adds shared tooltip helpers, and ports smoothing/history logic to typed-array views.
- Adds unit coverage for the new buffer, smoothing, and history builders.
Reviewed changes
Copilot reviewed 30 out of 31 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/util/chart-tooltip.ts | Adds shared helpers for positional tooltip value/dimension lookup with keyed-column datasets. |
| src/util/chart-smoothing.ts | Ports smoothing from row objects to typed-array column views sourced from ChartBuffer. |
| src/util/chart-buffer.ts | New core columnar buffer (append/expiry/source views/ROC helpers) for chart data. |
| src/util/tests/chart-smoothing.spec.ts | Updates smoothing tests to operate on buffers and column views. |
| src/util/tests/chart-buffer.spec.ts | Adds unit tests for buffer behavior (append/expiry/resize/source caching/ROC). |
| src/typings/moonraker.proc_stats.d.ts | Widens moonraker_stats type to support single stat or backlog array. |
| src/store/server/actions.ts | Treats proc_stats backlog as a replace/init (not append) and rounds load consistently. |
| src/store/printer/actions.ts | Uses chart retention getter and emits diagnostics samples as {time, values} (NaN gaps). |
| src/store/charts/types.ts | Replaces open-ended chart state with typed buffers and a ChartEntryPayload union. |
| src/store/charts/thermal-history.ts | New builder converting Moonraker temp store history into a ChartBuffer. |
| src/store/charts/thermal-columns.ts | Centralizes <sensor> / <sensor>#<sub> naming and parsing for thermal columns. |
| src/store/charts/state.ts | Initializes fixed buckets with typed buffers and standardized retentions. |
| src/store/charts/mutations.ts | Reworks mutations to append to/rescale buffers and to init only persisted legends. |
| src/store/charts/moonraker-history.ts | New builder converting proc_stats backlog into a ChartBuffer (rounded, ms timestamps). |
| src/store/charts/getters.ts | Updates base tooltip formatter to use positional tooltip lookups for keyed columns. |
| src/store/charts/actions.ts | Replaces legacy init logic with typed history-buffer builders (thermal + moonraker). |
| src/store/charts/tests/thermal-history.spec.ts | Adds unit tests validating thermal history buffer construction and behavior. |
| src/store/charts/tests/moonraker-history.spec.ts | Adds unit tests validating Moonraker backlog buffer behavior and monotonicity. |
| src/store/chart_helpers.ts | Migrates chart entry creation to {bucket,id,time,values} and thermal column conventions. |
| src/globals.ts | Adds Globals.CHART_SYSTEM_RETENTION to remove repeated literals across system charts. |
| src/components/widgets/thermals/ThermalChart.vue | Migrates thermal chart rendering to buffer-based keyed columns and incremental series creation. |
| src/components/widgets/thermals/TemperatureTargets.vue | Updates legend toggles to new column naming and uses buffer ROC helper. |
| src/components/widgets/system/SystemMemoryChart.vue | Updates inline memory chart to use chartBufferSource keyed-column dataset. |
| src/components/widgets/system/SystemLoadChart.vue | Updates inline system load chart to use chartBufferSource keyed-column dataset. |
| src/components/widgets/system/MoonrakerLoadChart.vue | Updates inline Moonraker load chart to use chartBufferSource keyed-column dataset. |
| src/components/widgets/system/McuLoadChart.vue | Updates MCU load inline chart to source from charts.mcus[id] buffer. |
| src/components/widgets/system/KlipperLoadChart.vue | Updates inline Klipper load chart to use chartBufferSource keyed-column dataset. |
| src/components/widgets/sensors/SensorChart.vue | Updates sensor inline chart to source from charts.sensors[id] buffer. |
| src/components/widgets/diagnostics/DiagnosticsCard.vue | Updates diagnostics chart/tooltips to keyed columns and rounds tooltip values consistently. |
| src/components/ui/AppInlineChart.vue | Changes inline chart data prop to a keyed-column source and updates last-value extraction. |
| src/components/ui/AppChart.vue | Changes chart data prop to a keyed-column source and simplifies dataset updates. |
Suppressed comments (1)
src/util/chart-buffer.ts:191
chartBufferSourcebuildssourceas a normal object and then assigns dynamic keys. If any column name is__proto__(sensor names are runtime data),source[key] = ...can mutate the object prototype and break ECharts/tooltips. Buildsourcewith a null prototype before assigning dynamic keys.
const source: ChartDataSource = {
date: time.subarray(offset, offset + count)
}
for (const key in columns) {
source[key] = columns[key].subarray(offset, offset + count)
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
From PR review.
`chartBufferRateOfChange` divided by the time delta without checking it,
so two trailing samples sharing a timestamp yielded Infinity (or NaN),
rendered verbatim in the temperature table. Equal timestamps now return
0, alongside the existing single-sample guard.
`handleMcuStatsChange` had the same exposure on its bandwidth maths, via
a delta that is 0 when two MCU updates land in the same millisecond; the
delta is now clamped to 1ms. Pre-existing, but the file was already
being touched.
`ChartBuffer.columns` becomes a `Map`. Column names are runtime sensor
ids, and the membership test was `key in columns`, which is true for
`constructor`, `toString` and every other Object.prototype member - so a
sensor with such a name never got a column created and its data was
silently dropped. A plain assignment of `__proto__` would also have
reparented the map. `Map` has own-key semantics, guarantees insertion
order even for integer-like names, and cannot be polluted.
Vue 2 cannot observe a `Map`, so the `defineColumn` callback and the
`Vue.set` wiring in `setChartEntry` are gone. Nothing depended on that
reactivity: column discovery runs in `initSeries`, driven by
`@Watch('chartRevision')`, and `chartBufferSource` reads `revision` too.
The new `chartBufferColumn` accessor absorbs the create-on-miss paths.
Also corrects the `buildThermalHistoryBuffer` comment, which claimed the
timeline ends at `endTime` when the newest sample sits at `endTime - 1000`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 31 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/util/chart-buffer.ts:193
chartBufferSourceassignssource[key] = …for runtime-derived column names. If a column name is__proto__, this mutates the returned object's prototype (e.g. to aFloat64Array) and ECharts/key enumeration can start seeing numeric indices via the prototype chain. This is observable JS behavior and can corrupt/slow tooltip/series logic. Buildsourceas a null-prototype dictionary (or define properties) so all column keys are treated as ordinary data keys.
for (const [key, column] of columns) {
source[key] = column.subarray(offset, offset + count)
}
src/store/charts/mutations.ts:54
setResetChartStoreis invoked on reconnect/klippy reset (charts/resetChartStore), but it currently only resetsthermalandready. This leaves other chart buckets (klipper,memory,moonraker,diagnostics,mcus,sensors) carrying stale pre-drop samples into the new session, contradicting the action name/usage and risking mixed timelines after a reconnect. Reset the full charts state while preservingselectedLegends.
setResetChartStore (state) {
const { thermal, ready } = defaultState()
Object.assign(state, {
thermal,
`d749ae94` dropped the fabricated lead-in from the thermal history load, on the reasoning that the padding bought no axis stability because the x-axis window spans the retention regardless. That reasoning was backwards: spanning the retention regardless is precisely why the padding mattered. Against a freshly-started Moonraker, with only a few minutes of backlog, the chart drew a stub in the corner of a full-width window and looked frozen until the retention filled. `buildThermalHistoryBuffer` again holds each sensor's oldest reading across the unfilled part of the window, so `count` is the retention whenever there is any source at all. The column-major load stays - one `fill` per column rather than the old per-field array spread - so the performance work in that commit is unaffected. Cleanups found by a review pass over the branch: `chartBufferRateOfChange` hoists `last`, which was loop-invariant, and walks back only for `first`. `reallocate` NaN-fills just the tail rather than the whole array it is about to overwrite. `createTimeColumn` becomes `allocate`, the generic allocator it always was, and `chartBufferLastTime`/`chartBufferLastValue` accept `undefined` like `chartBufferSource` does. `smoothChartSource` takes a `ChartDataSource` instead of a `ChartBuffer` - it only ever needed the source and a count, and `count` is `date.length` - which drops the util's dependency on the store types. `resolveBuffer` collapses five pass-through cases to `default`, and the duplicated mcu/sensor blocks to one; `setChartEntry` no longer repeats the retention check that `resizeChartBuffer` already makes. The live `notify_proc_stat_update` path and the backlog load now share `moonrakerChartSample`, so the cpu_usage bound, the seconds-to-ms conversion and the rounding cannot drift between them. `ThermalChart` probes `columns` directly instead of copying the Map into a Set on every tick, and its tooltip formatter returns early rather than wrapping its body in an `if`. `AppInlineChart` drops a ternary that guarded an index lookup already safe at -1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 31 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/store/charts/thermal-history.ts:90
- PR description says short thermal history is no longer padded and “simply starts where the real data starts”, but
buildThermalHistoryBufferstill fills the lead-in with the oldest reading (target.fill(...)), which draws a flat segment that didn’t occur. Either update the PR description to match this behavior, or change the history mapping to leave the lead-in as gaps (NaN) so the chart starts at the first real sample.
// Hold the oldest reading across the lead-in so a short history still
// fills the window - the chart's x-axis spans the retention regardless.
target.fill(decimalRound(values[from], 2), 0, to)
`chartBufferSource` built its keyed-columns object from a plain literal, so `source[key] = …` for a column named `__proto__` invoked the `Object.prototype.__proto__` setter instead of creating an own property. The column silently vanished from the dataset — the same own-key bug `constructor` and `toString` hit before `ChartBuffer.columns` became a `Map`, just on the other side of the boundary. Build the source on a null prototype, and carry that through `smoothChartSource`'s copy so `result[key] = smoothed` can't hit the setter either. Verified echarts is fine with it: keyed-columns detection uses `hasOwn(data, key)` and `each(data, …)` → `Object.keys`. Nothing escaped the per-revision object, so this was not a prototype-pollution vector; the impact was one empty series. Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
`AGENTS.md` had no entry for the chart store, while the parallel G-code `MoveStore` has a detailed one. Add a `### Charts` section covering the parts that aren't inferable from the code: the ring-buffer window and its accessors, `revision` as the sole reactive signal, NaN-not-gaps, why `columns` is a `Map` and why `chartBufferSource` builds on a null prototype, `date` as dimension 0, positional tooltip params, the monotonic-time requirement that forces history loads to replace rather than append, and the thermal lead-in padding together with the x-axis pin that makes it load-bearing. Also tighten the socket state machine bullet: `charts/resetChartStore` clears only the thermal bucket, not the whole module, and that is deliberate - `resetKlippy` shares it and a klippy restart does not re-fetch `machine.proc_stats`. No user-facing docs changed; nothing under `docs/docs/` describes behaviour this branch alters. Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
Replaces the chart store's row-object model — a
ChartStatewith a wide-open[index: string]: anyholdingReadonly<ChartData>[], where every value was anumber | Dateunion — with a closed, explicitly typed state backed by columnarFloat64Arraybuffers.src/util/chart-buffer.tsis the new core: a capacity-bounded buffer whose live window is[offset, offset + count)over atimearray and one column per series. Appends are amortised O(1) with no steady-state allocation, expiry is a binary search that advancesoffset(was a linearfindIndexplus an O(n)splice), andchartBufferSource()hands EChartssubarray()views directly via its keyed-columnsdataset.sourceformat — so there is no per-render materialisation. Vue 2 cannot observe typed array writes, so arevisioncounter is the single reactive change signal; the arrays themselves aremarkRaw'd so the strict-mode deep watcher does not enumerate every element.For a typical 8-sensor printer at the default 1200-sample retention this cuts the thermal bucket from ~550–600 KB of row objects and
Dates to ~165 KB, and removes essentially all allocation on the 1 Hz chart path.Bugs fixed along the way
chartData[0], andinitSeriesran once behind aninitializedflag, so any sensor or#target/#power/#speedcolumn that first appeared after the initial sample stayed invisible for the rest of the session. Series are now built incrementally from the buffer's column list.initTempStoreselected sensors byprinter[key] != nullwhile the live path usesprinter/getChartableSensors, so history created columns that no series ever rendered and no update ever filled. It also skipped thedecimalRound(v, 2)the live path applies. Both now match.machine.proc_statsruns in the identify bootstrap, so every reconnect appended Moonraker's ~30 min backlog behind the samples already in the buffer, breaking the monotonic orderingdropExpiredassumes. The array form is now treated as a history load and replaces the bucket in a single commit.Thermal history lead-in
An intermediate commit on this branch (
d749ae94) removed the lead-in padding from the history load, reasoning that because the x-axis window is pinned to the retention regardless, repeating the oldest sample bought no axis stability. That was wrong, and its commit message still asserts it — spanning the retention regardless is exactly why the padding matters. Against a freshly-started Moonraker, with only a few minutes of backlog, the chart drew a stub in the corner of a full-width window and appeared frozen until the retention filled. Bisected and confirmed against a live instance.buildThermalHistoryBufferagain holds each sensor's oldest reading across the unfilled part of the window. The column-major load introduced by that commit is unaffected — onefillper column rather than the old per-field array spread.Also
src/store/charts/thermal-columns.tscentralises the<sensor>#<sub>column convention that was duplicated across six files.src/util/chart-tooltip.tscentralises the positionalparam.valuelookup that keyed columns require, shared by all three formatters. Its param type is derived from echarts' ownDefaultLabelFormatterCallbackParamsrather than hand-declared.smoothChartDatabecomessmoothChartSource, operating on typed array views and taking aChartDataSource— it only ever needed the source and a count — so the util no longer depends on the store types. Unsmoothed columns pass through as views.ChartBuffer.columnsis aMap, not a plain object: column names are runtime sensor ids, andkey in columnswas true forconstructor,toStringand every otherObject.prototypemember, so a sensor with such a name silently lost its data.notify_proc_stat_updatepath and the backlog load sharemoonrakerChartSample, so thecpu_usagebound, the seconds-to-ms conversion and the rounding cannot drift between them.setInitChartsno longerObject.assigns an arbitrary Moonraker DB document onto store state — onlyselectedLegendsis read.Globals.CHART_SYSTEM_RETENTIONrather than a literal repeated in eight places.resolveBuffercollapses five pass-through cases todefaultand merges the duplicated mcu/sensor blocks;setChartEntrydrops a retention checkresizeChartBufferalready makes;chartBufferRateOfChangehoists a loop-invariant;reallocateNaN-fills only the tail it isn't about to overwrite;ThermalChartprobes the columnMapdirectly instead of copying it into aSeteach tick.Testing
chart-buffer,chart-smoothing,thermal-historyandmoonraker-historyhave unit specs (460 tests total). Manual verification against a live printer is still worth doing for the reconnect and klippy-restart paths, which unit tests can't reach; the lead-in regression above was found that way, not by the suite.🤖 Generated with Claude Code