goro-charts 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,69 +5,118 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.0] — 2026-07-06
9
+
10
+ ### Behavior fixes
11
+
12
+ - **Data ownership contract (`copy`/`borrowed`).**
13
+ `setData` now **copies** the arrays by default (`'copy'`), making the chart
14
+ immune to external mutation. The `'borrowed'` (zero-copy) mode is available
15
+ as an opt-in but requires the caller to treat the arrays as immutable.
16
+ _(minor, behavior fix — the default semantics changed from borrowed to copy)_
17
+ - **Numeric input validation.**
18
+ Length mismatches, non-monotonic X, non-finite X (`Infinity`, `-Infinity`,
19
+ `NaN`), and non-finite Y (`±Infinity`) are now rejected with a descriptive
20
+ error naming the series and position — the same contract applies both in
21
+ snapshot mode (`setData`) and in streaming mode (`append`/`appendBatch`).
22
+ `NaN` in Y is the only accepted exception (see below). Inputs previously
23
+ accepted silently (or only with a `console.warn`) now throw.
24
+ _(minor, behavior fix)_
25
+ - **Non-monotonic append now throws.**
26
+ The previous `console.warn` was promoted to a thrown error to fail fast.
27
+ `appendBatch` validates the entire batch before pushing any sample — partial
28
+ batches never corrupt the ring state. _(minor, behavior fix)_
29
+ - **NaN in Y accepted and documented.**
30
+ `NaN` in Y is accepted, excluded from the extent computation, and reserved
31
+ for gap rendering in v1.6.0. Arrays where every Y is `NaN` produce a safe
32
+ degenerate range. _(minor, behavior fix)_
33
+
34
+ ### Added
35
+
36
+ - `DataOwnership = 'copy' | 'borrowed'` type and the optional `ownership`
37
+ parameter on `setData(index, x, y, ownership?)`.
38
+ - `npm run check:readme` script — extracts every `ts` block from the README
39
+ and typechecks it against the real exported types. Wired into CI.
40
+ - New `Check README examples` CI step (before Typecheck).
41
+ - Reserved (commented-out TODO) slot for `docs/assets/streaming.gif` in the
42
+ README's first fold — the `![…]` stays commented until the asset exists, so
43
+ a broken image is never published.
44
+
45
+ ### Changed
46
+
47
+ - `setData(index, x, y)` now takes an optional fourth argument `ownership`
48
+ (default `'copy'`).
49
+ - README signature-reference blocks (e.g. `new LineChart(canvas, opts?:
50
+ ChartOpts)`) are marked with `// signature` and skipped by `check:readme`,
51
+ keeping the readable form without breaking the semantic checking of the
52
+ runnable examples.
53
+
8
54
  ## [1.2.0] — 2026-07-06
9
55
 
10
56
  ### Behavior fixes
11
57
 
12
- - **Crosshair sync por valor X, não por pixel.**
13
- Gráficos com tamanhos, margens e domínios diferentes agora sincronizam
14
- corretamente: o valor de dado é convertido de pixel na origem e de volta
15
- a pixel no target via pxToX/xToPx. Valor fora do domínio oculta o
16
- crosshair no target. _(minor, behavior fix)_
17
- - **Stacking separa positivos e negativos.**
18
- Positivos acumulam num track ascendente, negativos num track descendente,
19
- sem se cancelarem. Em desenvolvimento, séries do mesmo `stack` com eixos
20
- ou comprimentos divergentes geram aviso descritivo. _(minor, behavior fix)_
21
- - **`renderedPointCount` renomeado para `windowPointCount`.**
22
- A métrica antiga mentia: retornava o total de pontos na janela, não os
23
- efetivamente desenhados (o renderer decima para ~2·plotW colunas no
24
- regime denso). Agora duas métricas honestas: `windowPointCount`
25
- (volume de dados) e `drawnPointCount` (estimativa pós-decimação).
26
- Quem usava `renderedPointCount` deve migrar para `windowPointCount`.
58
+ - **Crosshair sync by X value, not by pixel.**
59
+ Charts with different sizes, margins, and domains now sync correctly: the
60
+ data value is converted from pixel at the origin and back to pixel at the
61
+ target via pxToX/xToPx. A value outside the domain hides the crosshair on
62
+ the target. _(minor, behavior fix)_
63
+ - **Stacking separates positives and negatives.**
64
+ Positives accumulate on an ascending track, negatives on a descending track,
65
+ without cancelling each other out. In development, series in the same `stack`
66
+ with divergent axes or lengths emit a descriptive warning.
67
+ _(minor, behavior fix)_
68
+ - **`renderedPointCount` renamed to `windowPointCount`.**
69
+ The old metric lied: it returned the total points in the window, not those
70
+ actually drawn (the renderer decimates to ~2·plotW columns in the dense
71
+ regime). There are now two honest metrics: `windowPointCount` (data volume)
72
+ and `drawnPointCount` (post-decimation estimate). Anyone using
73
+ `renderedPointCount` should migrate to `windowPointCount`.
27
74
  _(minor, behavior fix)_
28
75
 
29
76
  ### Added
30
77
 
31
- - `unsync(other)` — remove sincronização bidirecional de crosshair.
32
- - `drawnPointCount` — estimativa de segmentos realmente desenhados após
33
- decimação (útil para verificar que a decimação está ativa).
34
- - Stacking: validação de alinhamento entre séries do mesmo grupo (eixo e
35
- comprimento) em desenvolvimento.
78
+ - `unsync(other)` — removes bidirectional crosshair synchronization.
79
+ - `drawnPointCount` — estimate of segments actually drawn after decimation
80
+ (useful to verify that decimation is active).
81
+ - Stacking: alignment validation between series in the same group (axis and
82
+ length) in development.
36
83
 
37
84
  ### Changed
38
85
 
39
- - `injectCursor` (privado) agora recebe valor X em vez de pixel — alinhado
40
- com a sincronização por valor. `notifySyncCrosshair` envia valor em vez
41
- de coordenada de tela.
42
- - `destroy` agora remove o chart de todos os peers sincronizados antes de
43
- limpar as stores.
44
- - `accumulateStackGroup` retorna `{ posCum, negCum }` separados em vez de
45
- uma única acumulação líquida.
86
+ - `injectCursor` (private) now receives an X value instead of a pixel —
87
+ aligned with value-based sync. `notifySyncCrosshair` sends a value instead
88
+ of a screen coordinate.
89
+ - `destroy` now removes the chart from all synced peers before clearing the
90
+ stores.
91
+ - `accumulateStackGroup` returns separate `{ posCum, negCum }` instead of a
92
+ single net accumulation.
46
93
 
47
94
  ## [1.1.0] — 2026-07-06
48
95
 
49
96
  ### Behavior fixes
50
97
 
51
- - **`yMin`/`yMax` sentinel: `0` agora é um bound legítimo.**
52
- Antes `yMin: 0` e `yMax: 0` eram tratados como "auto" (descartados). Agora só
53
- caem em domínio automático quando `undefined`. `yMin: 0` ancorado é o caso mais
54
- comum e finalmente funciona. _(minor, behavior fix)_
55
- - **Teclado: navegação por ponto de dado, não por pixel.**
56
- As setas movem o crosshair de ponto em ponto (lógico) da primeira série
57
- não-vazia. `Shift+seta` avança 10 pontos. Antes navegava por pixel, sem
58
- ancorar em dados reais. _(minor, behavior fix)_
59
- - **`prefers-reduced-motion` não para mais o streaming.**
60
- Antes desligava `autoDraw`, interrompendo o repaint coalescido de dados ao
61
- vivo. Agora sinaliza uma flag para suprimir animações visuais (quando
62
- houverem) sem afetar a atualização contínua do gráfico. _(minor, behavior fix)_
98
+ - **`yMin`/`yMax` sentinel: `0` is now a legitimate bound.**
99
+ Previously `yMin: 0` and `yMax: 0` were treated as "auto" (discarded). They
100
+ now fall back to an automatic domain only when `undefined`. An anchored
101
+ `yMin: 0` is the most common case and finally works. _(minor, behavior fix)_
102
+ - **Keyboard: navigation by data point, not by pixel.**
103
+ The arrow keys move the crosshair point by point (logical) across the first
104
+ non-empty series. `Shift+arrow` advances 10 points. It previously navigated
105
+ by pixel, without anchoring to real data. _(minor, behavior fix)_
106
+ - **`prefers-reduced-motion` no longer stops streaming.**
107
+ It previously turned off `autoDraw`, interrupting the coalesced repaint of
108
+ live data. It now only sets a flag to suppress visual animations (when
109
+ present) without affecting the chart's continuous updates.
110
+ _(minor, behavior fix)_
63
111
 
64
112
  ### Changed
65
113
 
66
- - `ResolvedOpts.yMin` / `ResolvedOpts.yMax` agora são `number | undefined`.
67
- O sentinela "auto" mudou de `0` para `undefined`. Código que dependia do
68
- comportamento antigo (ex.: checagens `yMin !== 0`) deve usar `yMin !== undefined`.
69
- - `prefers-reduced-motion` adiciona listener `change` em runtime para reavaliar
70
- a preferência sem recriar o gráfico. O listener é removido em `destroy()`.
114
+ - `ResolvedOpts.yMin` / `ResolvedOpts.yMax` are now `number | undefined`.
115
+ The "auto" sentinel changed from `0` to `undefined`. Code that relied on the
116
+ old behavior (e.g. `yMin !== 0` checks) should use `yMin !== undefined`.
117
+ - `prefers-reduced-motion` adds a runtime `change` listener to re-evaluate the
118
+ preference without recreating the chart. The listener is removed in
119
+ `destroy()`.
71
120
 
72
121
  ## [1.0.0]
73
122
 
package/README.md CHANGED
@@ -7,6 +7,10 @@
7
7
 
8
8
  **[Live demo →](https://stefanelloisaac.github.io/goro-charts/)**
9
9
 
10
+ <!-- TODO: gravar e adicionar docs/assets/streaming.gif, depois descomentar:
11
+ ![Live streaming demo](docs/assets/streaming.gif)
12
+ -->
13
+
10
14
  Minimal high-performance chart engine. **Canvas 2D only. Zero runtime dependencies. Framework-agnostic.** Inspired by [uPlot](https://github.com/leeoniya/uPlot) — small, fast, and covers only what you need.
11
15
 
12
16
  - **LineChart** — batched polyline with per-pixel-column min/max decimation
@@ -59,6 +63,7 @@ chart.appendBatch(1, [1, 2, 3], [39, 40, 39]);
59
63
  Every chart holds one or more series. Each series owns its visual identity:
60
64
 
61
65
  ```ts
66
+ // signature
62
67
  import type { SeriesConfig } from 'goro-charts';
63
68
 
64
69
  interface SeriesConfig {
@@ -94,6 +99,7 @@ When `series` is omitted from `ChartOpts` a single default series is created aut
94
99
  ### `LineChart`
95
100
 
96
101
  ```ts
102
+ // signature
97
103
  new LineChart(canvas, opts?: ChartOpts)
98
104
  ```
99
105
 
@@ -102,6 +108,7 @@ Each series is drawn as a single batched polyline. When the dataset is dense (mo
102
108
  ### `AreaChart`
103
109
 
104
110
  ```ts
111
+ // signature
105
112
  new AreaChart(canvas, opts?: ChartOpts)
106
113
  ```
107
114
 
@@ -123,6 +130,7 @@ const chart = new AreaChart(canvas, {
123
130
  ### `ScatterChart`
124
131
 
125
132
  ```ts
133
+ // signature
126
134
  new ScatterChart(canvas, opts?: ChartOpts)
127
135
  ```
128
136
 
@@ -131,9 +139,10 @@ Each series is drawn as filled circles, one per sampled point. When the dataset
131
139
  ```ts
132
140
  const chart = new ScatterChart(canvas, {
133
141
  series: [
134
- { name: 'Packets', color: '#f07167', pointRadius: 3.5 },
142
+ { name: 'Packets', color: '#f07167' },
135
143
  { name: 'Errors', color: '#ffb454', dash: [6, 3] },
136
144
  ],
145
+ pointRadius: 3.5,
137
146
  maxPoints: 5000,
138
147
  autoDraw: true,
139
148
  });
@@ -145,18 +154,33 @@ const chart = new ScatterChart(canvas, {
145
154
 
146
155
  Every data method takes a **series index** as the first argument. `setMaxPoints()`, `clear()`, and `draw()` operate on all series.
147
156
 
148
- | Method | Signature | Description |
149
- | -------------- | ------------------------------------------------------------- | ------------------------------------------------------------------ |
150
- | `setData` | `(seriesIndex, x: Float64Array, y: Float64Array)` | Snapshot: replace a series. O(n) extent. |
151
- | `append` | `(seriesIndex, x: number, y: number)` | Ring: append one point. O(1) amortized. |
152
- | `appendBatch` | `(seriesIndex, xs: ArrayLike<number>, ys: ArrayLike<number>)` | Ring: append a batch. O(k). |
153
- | `setMaxPoints` | `(maxPoints: number)` | Resize the streaming window for all series. |
154
- | `clear` | `()` | Empty all series and reset the grid domain. |
155
- | `draw` | `()` | Manual paint. No-op when clean and no crosshair. |
156
- | `suspendDraw` | `()` | Pause rAF-coalesced drawing. Nestable — pair with `resumeDraw()`. |
157
- | `resumeDraw` | `()` | Resume after matching `suspendDraw()`. Draws immediately if dirty. |
158
- | `toImage` | `()` | Export the canvas as a PNG data URL. |
159
- | `destroy` | `()` | Detach observers, release buffers. |
157
+ | Method | Signature | Description |
158
+ | -------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
159
+ | `setData` | `(seriesIndex, x: Float64Array, y: Float64Array, ownership?: 'copy' \| 'borrowed')` | Snapshot: replace a series. O(n) extent. Ownership: `'copy'` (default, safe) copies arrays; `'borrowed'` keeps caller's reference (must be treated as immutable). |
160
+ | `append` | `(seriesIndex, x: number, y: number)` | Ring: append one point. O(1) amortized. |
161
+ | `appendBatch` | `(seriesIndex, xs: ArrayLike<number>, ys: ArrayLike<number>)` | Ring: append a batch. O(k). |
162
+ | `setMaxPoints` | `(maxPoints: number)` | Resize the streaming window for all series. |
163
+ | `clear` | `()` | Empty all series and reset the grid domain. |
164
+ | `draw` | `()` | Manual paint. No-op when clean and no crosshair. |
165
+ | `suspendDraw` | `()` | Pause rAF-coalesced drawing. Nestable — pair with `resumeDraw()`. |
166
+ | `resumeDraw` | `()` | Resume after matching `suspendDraw()`. Draws immediately if dirty. |
167
+ | `toImage` | `()` | Export the canvas as a PNG data URL. |
168
+ | `destroy` | `()` | Detach observers, release buffers. |
169
+
170
+ ### Ownership (`copy` vs `borrowed`)
171
+
172
+ `setData` accepts an optional fourth argument to control data ownership:
173
+
174
+ - **`'copy'`** (default) — the chart copies your arrays into fresh buffers. You may reuse or mutate the originals freely after the call. This is the safe default.
175
+ - **`'borrowed'`** — the chart keeps your arrays by reference to avoid allocation. The caller **must** treat the arrays as immutable for as long as the chart holds them; mutating them externally leads to undefined behaviour.
176
+
177
+ ```ts
178
+ // Safe default — caller can mutate the originals later
179
+ chart.setData(0, x, y);
180
+
181
+ // Zero-copy — caller must not mutate x or y after this call
182
+ chart.setData(0, x, y, 'borrowed');
183
+ ```
160
184
 
161
185
  ### Read-only properties
162
186
 
@@ -335,13 +359,13 @@ flat regardless of window size.
335
359
  Pre-built `DARK` and `LIGHT` colour presets ready to spread over constructor options.
336
360
 
337
361
  ```ts
338
- import { LineChart, DARK, LIGHT } from 'goro-charts'
362
+ import { LineChart, DARK, LIGHT } from 'goro-charts';
339
363
 
340
364
  // Dark (default) — explicit
341
- new LineChart(canvas, { ...DARK, series: [...] })
365
+ new LineChart(canvas, { ...DARK, series: [/* ... */] });
342
366
 
343
367
  // Light theme
344
- new LineChart(canvas, { ...LIGHT, series: [...] })
368
+ new LineChart(canvas, { ...LIGHT, series: [/* ... */] });
345
369
  ```
346
370
 
347
371
  ---
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { SeriesStore } from '../data/series-store.js';
22
22
  import { Surface } from '../render/surface.js';
23
- import type { ChartOpts, ResolvedOpts, SeriesConfig, SeriesView, PlotRect } from '../types.js';
23
+ import type { ChartOpts, ResolvedOpts, SeriesConfig, SeriesView, PlotRect, DataOwnership } from '../types.js';
24
24
  import type { SeriesHit } from '../render/crosshair.js';
25
25
  /** Abstract base for all chart types. */
26
26
  export declare abstract class ChartBase {
@@ -122,27 +122,28 @@ export declare abstract class ChartBase {
122
122
  /**
123
123
  * Snapshot mode: replace the series at `index` (O(n) extent).
124
124
  * @param index - Series index (0-based)
125
- * @param x - X values, must be monotonically increasing
126
- * @param y - Y values, must have the same length as x
127
- * @throws {Error} if `index` is out of range, or x/y length mismatch
125
+ * @param x - X values, must be finite and monotonically increasing
126
+ * @param y - Y values, may contain NaN (reserved for gaps v1.6.0); non-finite other than NaN is rejected
127
+ * @param ownership - `'copy'` (default): store copies the arrays; `'borrowed'`: store keeps caller's arrays by reference (must be treated as immutable)
128
+ * @throws {Error} if `index` is out of range, length mismatch, empty, non-finite X, or non-monotonic X
128
129
  */
129
- setData(index: number, x: Float64Array<ArrayBufferLike>, y: Float64Array<ArrayBufferLike>): void;
130
+ setData(index: number, x: Float64Array<ArrayBufferLike>, y: Float64Array<ArrayBufferLike>, ownership?: DataOwnership): void;
130
131
  /**
131
132
  * Ring mode: append one sample to series `index`.
132
133
  * @param index - Series index (0-based)
133
- * @param x - X value (must be monotonically increasing)
134
- * @param y - Y value
134
+ * @param x - X value (must be finite and monotonically increasing)
135
+ * @param y - Y value (NaN is allowed — reserved for gaps v1.6.0)
135
136
  * @throws {Error} if ring mode is not active (maxPoints not set)
136
- * @throws {Error} if `index` is out of range
137
+ * @throws {Error} if `index` is out of range, x is not finite, x is non-monotonic, or y is non-finite (NaN allowed)
137
138
  */
138
139
  append(index: number, x: number, y: number): void;
139
140
  /**
140
141
  * Ring mode: append a batch of samples to series `index`.
141
142
  * @param index - Series index (0-based)
142
- * @param xs - X values (must be monotonically increasing)
143
- * @param ys - Y values, must have the same length as xs
143
+ * @param xs - X values (must be finite and monotonically increasing)
144
+ * @param ys - Y values (NaN is allowed reserved for gaps v1.6.0)
144
145
  * @throws {Error} if ring mode is not active
145
- * @throws {Error} if `index` is out of range or xs/ys length mismatch
146
+ * @throws {Error} if `index` is out of range, length mismatch, non-finite X, non-monotonic X, or non-finite Y (NaN allowed)
146
147
  */
147
148
  appendBatch(index: number, xs: ArrayLike<number>, ys: ArrayLike<number>): void;
148
149
  /**
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * The store is purely about data: it never touches the canvas or scheduling.
11
11
  */
12
- import type { SeriesView } from '../types.js';
12
+ import type { SeriesView, DataOwnership } from '../types.js';
13
13
  /** Owns the series data and computes/caches its extents. */
14
14
  export declare class SeriesStore implements SeriesView {
15
15
  xArr: Float64Array<ArrayBufferLike>;
@@ -32,19 +32,36 @@ export declare class SeriesStore implements SeriesView {
32
32
  /**
33
33
  * Snapshot mode: replace the whole series. Extents computed once in O(n).
34
34
  * Disables any active ring mode.
35
+ *
36
+ * By default (`ownership: 'copy'`) the store copies the caller's arrays into
37
+ * fresh buffers — the caller may mutate the originals freely after the call.
38
+ * Pass `'borrowed'` to keep the caller's arrays by reference (must be treated
39
+ * as immutable for as long as the chart holds them).
40
+ *
35
41
  * @throws {Error} if x and y have different lengths
36
42
  * @throws {Error} if x is empty
43
+ * @throws {Error} if any x value is not finite, or x is not monotonically increasing
44
+ * @throws {Error} if any y value is not finite (NaN in Y is allowed — reserved for gaps v1.6.0)
37
45
  */
38
- setData(x: Float64Array<ArrayBufferLike>, y: Float64Array<ArrayBufferLike>): void;
46
+ setData(x: Float64Array<ArrayBufferLike>, y: Float64Array<ArrayBufferLike>, ownership?: DataOwnership): void;
39
47
  /**
40
48
  * Ring mode: append one sample (x must be monotonically increasing).
41
49
  * @throws {Error} if ring mode is not active (maxPoints not set)
50
+ * @throws {Error} if x is not finite
51
+ * @throws {Error} if x is < last x (not monotonically increasing)
52
+ * @throws {Error} if y is not finite (NaN in Y is allowed — reserved for gaps v1.6.0)
42
53
  */
43
54
  append(x: number, y: number): void;
44
55
  /**
45
56
  * Ring mode: append a batch of parallel samples. O(k).
57
+ *
58
+ * The entire batch is validated before any sample is pushed, so a partial
59
+ * batch never corrupts the ring.
60
+ *
46
61
  * @throws {Error} if ring mode is not active
47
62
  * @throws {Error} if xs and ys have different lengths
63
+ * @throws {Error} if any x is not finite, or xs are not monotonically increasing
64
+ * @throws {Error} if any y is not finite (NaN in Y is allowed — reserved for gaps v1.6.0)
48
65
  */
49
66
  appendBatch(xs: ArrayLike<number>, ys: ArrayLike<number>): void;
50
67
  /** Resize the streaming window, keeping the most recent samples. */
@@ -55,6 +72,12 @@ export declare class SeriesStore implements SeriesView {
55
72
  get lastValue(): number;
56
73
  physOf(logical: number): number;
57
74
  bracketLogical(target: number): number;
75
+ /**
76
+ * Validate snapshot data before accepting it into the store.
77
+ * Checks length, non-empty, finite + monotonic X, and finite Y.
78
+ * @throws {Error} with position-aware message on any violation.
79
+ */
80
+ private validateSnapshot;
58
81
  private requireRing;
59
82
  /** Point the store's logical window at the ring's physical storage. */
60
83
  private bindRing;