@pushwoosh/kit-charts 0.0.1

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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/ai-documentation.md +378 -0
  3. package/core/Chart/Axes.d.ts +19 -0
  4. package/core/Chart/Axes.js +204 -0
  5. package/core/Chart/Chart.d.ts +33 -0
  6. package/core/Chart/Chart.js +616 -0
  7. package/core/Chart/Chart.styles.d.ts +109 -0
  8. package/core/Chart/Chart.styles.js +227 -0
  9. package/core/Chart/ChartErrorBoundary.d.ts +15 -0
  10. package/core/Chart/ChartErrorBoundary.js +27 -0
  11. package/core/Chart/Hinted.d.ts +11 -0
  12. package/core/Chart/Hinted.js +63 -0
  13. package/core/Chart/Legend.d.ts +15 -0
  14. package/core/Chart/Legend.js +92 -0
  15. package/core/Chart/PlotDecorations.d.ts +17 -0
  16. package/core/Chart/PlotDecorations.js +172 -0
  17. package/core/Chart/StackLabels.d.ts +6 -0
  18. package/core/Chart/StackLabels.js +85 -0
  19. package/core/Chart/Titles.d.ts +6 -0
  20. package/core/Chart/Titles.js +67 -0
  21. package/core/Chart/Tooltip.d.ts +13 -0
  22. package/core/Chart/Tooltip.js +87 -0
  23. package/core/Chart/useSize.d.ts +5 -0
  24. package/core/Chart/useSize.js +45 -0
  25. package/core/animation/motion.d.ts +20 -0
  26. package/core/animation/motion.js +20 -0
  27. package/core/animation/useTweenedNumbers.d.ts +1 -0
  28. package/core/animation/useTweenedNumbers.js +51 -0
  29. package/core/data/points.d.ts +14 -0
  30. package/core/data/points.js +73 -0
  31. package/core/format/time.d.ts +2 -0
  32. package/core/format/time.js +38 -0
  33. package/core/geometry/path.d.ts +19 -0
  34. package/core/geometry/path.js +76 -0
  35. package/core/layout/legend-layout.d.ts +21 -0
  36. package/core/layout/legend-layout.js +103 -0
  37. package/core/layout/measure-text.d.ts +9 -0
  38. package/core/layout/measure-text.js +57 -0
  39. package/core/layout/metrics.d.ts +63 -0
  40. package/core/layout/metrics.js +66 -0
  41. package/core/layout/point-width.d.ts +11 -0
  42. package/core/layout/point-width.js +86 -0
  43. package/core/layout/title-layout.d.ts +30 -0
  44. package/core/layout/title-layout.js +99 -0
  45. package/core/model/build-model.d.ts +69 -0
  46. package/core/model/build-model.js +556 -0
  47. package/core/model/palette.d.ts +15 -0
  48. package/core/model/palette.js +17 -0
  49. package/core/options/normalize.d.ts +8 -0
  50. package/core/options/normalize.js +44 -0
  51. package/core/registry/registry.d.ts +5 -0
  52. package/core/registry/registry.js +32 -0
  53. package/core/scale/band.d.ts +10 -0
  54. package/core/scale/band.js +33 -0
  55. package/core/scale/color.d.ts +9 -0
  56. package/core/scale/color.js +69 -0
  57. package/core/scale/linear.d.ts +17 -0
  58. package/core/scale/linear.js +64 -0
  59. package/core/scale/time.d.ts +19 -0
  60. package/core/scale/time.js +187 -0
  61. package/core/stack/stack.d.ts +9 -0
  62. package/core/stack/stack.js +76 -0
  63. package/core/types/formatter.d.ts +59 -0
  64. package/core/types/formatter.js +1 -0
  65. package/core/types/geometry.d.ts +16 -0
  66. package/core/types/geometry.js +1 -0
  67. package/core/types/options.d.ts +331 -0
  68. package/core/types/options.js +2 -0
  69. package/core/types/renderer.d.ts +101 -0
  70. package/core/types/renderer.js +1 -0
  71. package/core/types/scale.d.ts +34 -0
  72. package/core/types/scale.js +1 -0
  73. package/index.d.ts +16 -0
  74. package/index.js +10 -0
  75. package/package.json +64 -0
  76. package/presets/AreaChart.d.ts +3 -0
  77. package/presets/AreaChart.js +13 -0
  78. package/presets/ColumnChart.d.ts +3 -0
  79. package/presets/ColumnChart.js +13 -0
  80. package/presets/LineChart.d.ts +3 -0
  81. package/presets/LineChart.js +13 -0
  82. package/presets/PieChart.d.ts +3 -0
  83. package/presets/PieChart.js +13 -0
  84. package/presets/StackedColumnChart.d.ts +7 -0
  85. package/presets/StackedColumnChart.js +31 -0
  86. package/presets/index.d.ts +5 -0
  87. package/presets/index.js +5 -0
  88. package/series/area/area.d.ts +2 -0
  89. package/series/area/area.js +57 -0
  90. package/series/area/index.d.ts +1 -0
  91. package/series/area/index.js +1 -0
  92. package/series/column/column.d.ts +2 -0
  93. package/series/column/column.js +163 -0
  94. package/series/column/index.d.ts +1 -0
  95. package/series/column/index.js +1 -0
  96. package/series/heatmap/heatmap.d.ts +2 -0
  97. package/series/heatmap/heatmap.js +172 -0
  98. package/series/heatmap/index.d.ts +1 -0
  99. package/series/heatmap/index.js +1 -0
  100. package/series/line/index.d.ts +1 -0
  101. package/series/line/index.js +1 -0
  102. package/series/line/line.d.ts +2 -0
  103. package/series/line/line.js +49 -0
  104. package/series/pie/index.d.ts +1 -0
  105. package/series/pie/index.js +1 -0
  106. package/series/pie/pie.d.ts +2 -0
  107. package/series/pie/pie.js +224 -0
  108. package/series/shared/DataLabels.d.ts +14 -0
  109. package/series/shared/DataLabels.js +124 -0
  110. package/series/shared/Markers.d.ts +9 -0
  111. package/series/shared/Markers.js +34 -0
  112. package/series/shared/color.d.ts +1 -0
  113. package/series/shared/color.js +19 -0
  114. package/series/shared/domain.d.ts +3 -0
  115. package/series/shared/domain.js +14 -0
  116. package/series/shared/hit.d.ts +3 -0
  117. package/series/shared/hit.js +33 -0
  118. package/series/shared/legend.d.ts +3 -0
  119. package/series/shared/legend.js +12 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pushwoosh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,378 @@
1
+ # @pushwoosh/kit-charts - AI Agent Documentation
2
+
3
+ ## Overview
4
+
5
+ React chart library rendering to SVG, with a Highcharts-shaped configuration
6
+ model.
7
+
8
+ **Current state:** all five v1 renderers are implemented — `line`, `column`,
9
+ `area`, `pie`, `heatmap` — with `normal` and `percent` stacking, grouped columns,
10
+ donuts via `innerSize`, data labels and stack labels. A series type with no
11
+ registered renderer is skipped: the chart draws its other series and logs which
12
+ module is missing.
13
+
14
+ `heatmap` takes `[x, row, value]` points, colours each cell through
15
+ `colorAxis` (`min`/`max` plus `stops`), and takes its rows from
16
+ `yAxis.categories`. A second y axis with `linkedTo` mirrors the first one's
17
+ geometry while showing its own labels. The colour axis itself is not drawn as a
18
+ legend yet, so a heatmap contributes no legend items.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install @pushwoosh/kit-charts
24
+ ```
25
+
26
+ ## Core Dependencies
27
+
28
+ - **react**: >= 18.0.0 (peer dependency)
29
+ - **styled-components**: >= 5.3.3 (peer dependency)
30
+ - **@pushwoosh/kit-constants**: runtime dependency — fonts, greys and tooltip
31
+ colours come from the design system tokens. The series palette is built in.
32
+
33
+ Scales, tick selection and time formatting are hand-written rather than taken
34
+ from d3, because size is the point of the library.
35
+
36
+ ## Public API
37
+
38
+ - `Chart` — the general component; takes `options`, optional `renderers`,
39
+ `isLoading`, `empty`, `loading`, `className`, `containerProps`.
40
+ - `LineChart`, `ColumnChart`, `AreaChart`, `PieChart` — presets that pin one renderer each.
41
+ - `StackedColumnChart` — `ColumnChart` with `stacking` on by default; takes a
42
+ `stacking` prop (`'normal'` | `'percent'`), and an explicit
43
+ `plotOptions.column.stacking` still wins.
44
+ - `line`, `column`, `area`, `pie`, `heatmap` — the renderers themselves, for
45
+ `Chart`'s `renderers` prop or for `registerSeries`.
46
+ - `registerSeries(...renderers)` / `resolveRenderers(local?)` / `getRenderer(map, type)`.
47
+ - `formatTime(pattern, epochMs)` — strftime-style, UTC.
48
+ - `DEFAULT_COLORS` and the other palette constants.
49
+ - All configuration and contract types.
50
+
51
+ ## Configuration Model
52
+
53
+ `chart`, `title`, `subtitle`, `xAxis`, `yAxis`, `colorAxis`, `legend`, `tooltip`,
54
+ `plotOptions`, `series`, `colors`, `meta`, `numberFormat`.
55
+
56
+ Three-level inheritance is preserved: `plotOptions.series` → `plotOptions[type]` →
57
+ the series' own options, later winning. Nested objects merge rather than replace.
58
+
59
+ Deliberate differences from Highcharts:
60
+
61
+ 1. `meta` replaces `custom`, typed via
62
+ `ChartOptions<TChartMeta, TSeriesMeta, TPointMeta>`.
63
+ 2. Formatters take a typed context **argument**; there is no `this`.
64
+ 3. Formatters return a `ReactNode`, never an HTML string. There is no `useHTML`.
65
+ 4. `TooltipPoint` exposes `prev` and `next` neighbours directly.
66
+ 5. Formatters see `ctx.chart.title` and `ctx.chart.meta`, not the option tree.
67
+ 6. No `credits` option.
68
+ 7. No `reflow`; resize is observed automatically.
69
+ 8. `chart.margin*` replaces the computed distance from that edge to the plot,
70
+ the same as Highcharts, rather than adding to it.
71
+ 9. `tooltip` defaults to `shape: 'callout'`: a dark bubble with a beak anchored
72
+ to the point, matching the product hint style. `shape: 'box'` or
73
+ `followPointer` gives the floating-near-the-cursor behaviour instead.
74
+ 10. A y axis with `categories` becomes a band axis. `reversed` puts the first
75
+ category on top; `linkedTo` takes the target axis' geometry and keeps its own
76
+ labels, which is how one grid carries two label columns.
77
+ 11. `min`/`max` rounding (`nice`) is automatic and per end, not a user-facing
78
+ option: an end left unset is rounded outward to a whole tick step; an end the
79
+ caller set explicitly is used as-is. `yAxis: { min: 0 }` still gets a rounded,
80
+ label-friendly top end instead of losing headroom to a raw data max.
81
+ 12. `states.inactive.opacity` is off unless set. Highcharts dims the series a
82
+ hover is not on by default; switching that on here would restyle every chart
83
+ already drawn.
84
+
85
+ `numberFormat` is the chart-wide number-to-text rule. Axis labels, data labels,
86
+ stack labels and the default tooltip all print through it, so a thousands
87
+ separator is configured once rather than in four callbacks. Anything with its
88
+ own `formatter` or `format` wins over it.
89
+
90
+ `allowDecimals: false` keeps every tick on an axis whole, as a count axis needs:
91
+ the step is chosen from the candidates that stay integral at its magnitude, and
92
+ never falls below 1.
93
+
94
+ `labels.allowOverlap` defaults to false on both axes and on data labels. A label
95
+ whose box would run into one already placed loses its text — Highcharts hides
96
+ the label and keeps the tick, and so do we. A data-label formatter returning an
97
+ element cannot be measured, so it is always drawn.
98
+
99
+ ## Plot bands and lines
100
+
101
+ `plotLines` and `plotBands` live on either axis. A band takes `from`/`to`, a line
102
+ takes `value`; on a category axis both address a category by name and a band
103
+ covers the whole slot. Both accept a `label`, and a `zIndex` of 5 or more draws
104
+ them over the series instead of under it — the value Highcharts users reach for.
105
+
106
+ ## Interaction
107
+
108
+ `onPointClick(point, event)` reports the point nearest a click inside the plot,
109
+ with its `meta`. Drill-down hangs off this; there is no per-point `events` object.
110
+
111
+ `chart.zoomType: 'x'` lets a drag across the plot narrow the x axis to what was
112
+ dragged over, with a reset button in the corner (`chart.resetZoomText` renames
113
+ it). The zoom is applied as a `min`/`max` override on the first x axis, so the
114
+ model is built from the options as usual. Only a continuous axis zooms, and a
115
+ drag shorter than a few pixels is treated as a click.
116
+
117
+ A horizontally scrolling plot (`chart.scrollablePlotArea.minWidth`) lifts its
118
+ left-hand y axes into a fixed layer, so the plot slides under the row labels
119
+ rather than taking them with it. A right-hand axis and a vertically scrolling
120
+ chart stay in the scrolling canvas.
121
+
122
+ Time values are epoch milliseconds and are formatted in UTC, so output does not
123
+ shift with the viewer's timezone.
124
+
125
+ ## Legend
126
+
127
+ `legendSymbol` on a series overrides the shape its item keys with — a line series
128
+ draws a dash by default, and a dot reads better when the legend is a plain key
129
+ above the plot rather than a swatch column beside it.
130
+
131
+ `layout` (`'horizontal' | 'vertical'`, default `'horizontal'`) chooses one row of
132
+ items or one item per row. `verticalAlign` (`'top' | 'middle' | 'bottom'`,
133
+ default `'bottom'`) picks which edge the legend takes: `'top'`/`'bottom'` reserve
134
+ height above/below the plot; `'middle'` has no plot edge to sit on, so it
135
+ reserves width and sits beside the plot instead — the same trade Highcharts
136
+ makes. `align` (`'left' | 'center' | 'right'`, default `'left'`) positions items
137
+ along whichever axis the legend occupies — the row when it's below/above, the
138
+ column when it's at the side. `x`/`y` shift the computed position by an offset.
139
+
140
+ Geometry lives in one module, `src/core/layout/legend-layout.ts`
141
+ (`measureLegend` reserves the space, `layoutLegend` places the items), so
142
+ `Chart` and the legend component never disagree on where it sits. A heatmap
143
+ draws no legend items — see Overview.
144
+
145
+ ## Data Shapes
146
+
147
+ A series accepts `number`, `null`, `[x, y]`, `[category, y]`, `[x, y, value]`, or
148
+ `{ x, y, value, name, color, meta }`. A bare number takes its x from the array
149
+ index. `null` is a gap; `NaN` and `Infinity` become gaps with a development
150
+ warning, never zero.
151
+
152
+ ## Extension Point
153
+
154
+ ```ts
155
+ interface SeriesRenderer<TSeries extends SeriesOptionsBase> {
156
+ readonly type: string;
157
+ readonly scales: { x: ScaleKind; y: ScaleKind };
158
+ extendDomain(series: TSeries, acc: DomainAcc): DomainAcc;
159
+ render(ctx: RenderContext<TSeries>): ReactNode;
160
+ hitTest(ctx: RenderContext<TSeries>, pointer: Point): PointHit[] | null;
161
+ legendItems(series: TSeries, seriesIndex: number, colors: readonly string[]): LegendItem[];
162
+ }
163
+ ```
164
+
165
+ `RenderContext` carries already-normalised `points`, the plot rectangle, the
166
+ scales, the resolved colour, hover state and stack values.
167
+ `extendDomain` is where a renderer widens an axis beyond its data — columns and
168
+ areas need the zero baseline included, a heatmap needs half a cell of slack at
169
+ both ends of x. `scales.y` may be a band scale (a categorical axis): a renderer
170
+ that needs numbers on y takes them through `continuousY(ctx)` and draws nothing
171
+ when the axis is categorical. `scales.color` is present when the options declare
172
+ a `colorAxis`.
173
+
174
+ Register before use:
175
+
176
+ ```ts
177
+ import { registerSeries } from '@pushwoosh/kit-charts';
178
+ import { line } from '@pushwoosh/kit-charts/series/line';
179
+
180
+ registerSeries(line);
181
+ ```
182
+
183
+ `registerSeries` is idempotent. Registering a *different* renderer under an
184
+ occupied type throws in development and overwrites in production. `Chart` also
185
+ accepts a `renderers` prop that takes priority over the global registry.
186
+
187
+ **Invariant:** `src/core` must never import from `src/series`. An eslint rule
188
+ enforces it. Breaking it puts every renderer into every consumer's bundle.
189
+
190
+ ## Error Handling
191
+
192
+ `Chart` wraps its body in an error boundary placed *outside* the component that
193
+ builds the model — a formatter throwing during that build would otherwise escape
194
+ and unmount the surrounding tree. A failed chart shows a fallback in its own box.
195
+
196
+ An unregistered series type is skipped with a development-time console error; the
197
+ rest of the chart still draws.
198
+
199
+ ## Build and configuration
200
+
201
+ The package is a *library* on `@pushwoosh/frontend-builder-engine`: `lib:build`
202
+ (tsc, then the engine's own babel pass for styled-components) and `lib:check-types`.
203
+ It therefore carries none of the application-template configuration — no
204
+ `babel.config.js` (the engine's babel pass runs with `configFile: false` and never
205
+ reads one), no `.browserslistrc` and no `.stylelintrc`. Only what something
206
+ actually reads is here: `tsconfig.json`, `playground/tsconfig.json`,
207
+ `eslint.config.mjs` (the engine's config plus this project's two rules),
208
+ `pushwoosh.config.js`, and `jest.config.js`.
209
+
210
+ `jest.config.js` is hand-written because the engine has no test runner. The
211
+ `playground` script calls webpack directly for the same reason in reverse: the
212
+ engine's `lib:command:playground` currently only prints the command it would run —
213
+ its spawn is commented out upstream — so deferring to it would start nothing.
214
+
215
+ ## Testing
216
+
217
+ `tests/fixtures/` is the golden corpus: every configuration the library must
218
+ render, named by capability. `renderableCases(implementedTypes)` filters it to
219
+ what the current renderers can draw, so tests do not fail on unwritten types.
220
+
221
+ Tests live outside `src/` so the build cannot ship them.
222
+
223
+ ## Legend
224
+
225
+ Legend items are buttons: clicking one hides or shows what it stands for, and the
226
+ chart rescales to what is left. Usually that is a series, but a renderer whose
227
+ legend keys *points* — a pie, one item per slice — sets `pointIndex` on its items,
228
+ and then a click takes out that slice alone and the rest share the circle out
229
+ again. The point stays in `ctx.points` with its index listed in
230
+ `ctx.hiddenPoints`, so the renderer can animate it away: a pie gives it no angle,
231
+ which closes it like a fan while the slices after it slide into the room it
232
+ leaves — the path keeps its command structure throughout, which is what lets the
233
+ browser interpolate it. Nothing counts a hidden point: not a total, not an axis
234
+ extent, not hit testing. Hidden series keep their legend entry — dimmed and
235
+ struck through — and stay mounted, which is what lets the change animate instead
236
+ of popping. How a series leaves is the renderer's call through `exit`: with
237
+ `'collapse'` the renderer redraws its shapes flat on their own baseline when
238
+ `ctx.hidden` is set — a column shrinks into the segment it grew from, so a stacked
239
+ one does not slide through its neighbour — and the fade waits for that to play.
240
+ `'fade'` is the default, for shapes with no baseline to fall onto: lines, areas,
241
+ pie slices, heatmap cells; they are excluded from the domains, the stacks, the
242
+ column slots and hit testing. Keyboard users get the same through Enter/Space and
243
+ a focus ring; `prefers-reduced-motion` turns the transitions off.
244
+
245
+ Switching off the *last* series would otherwise reshape the axes under it, and the
246
+ line would jump to the new scale before it finished fading — so with nothing on
247
+ show the chart holds the geometry of the last state that had something, and only
248
+ the opacity changes. Switching a series back on rebuilds from the data as usual.
249
+
250
+ `Chart` owns that state, seeded from `series[].visible` and reset when the series
251
+ list changes. `toggleSeries={false}` makes the legend a plain key again, and
252
+ `onSeriesToggle(seriesIndex, visible)` reports every click for consumers that
253
+ persist the choice.
254
+
255
+ ## Title
256
+
257
+ `title.text` and `subtitle.text` are drawn at the top of the chart — 18px semibold
258
+ and 12px muted by default, aligned by `align`, and the layout reserves their height.
259
+
260
+ `title.hint` adds an info disc after the title text: hovering or focusing it opens
261
+ the same dark callout a point uses, with the text wrapped. Escape closes it. Use it
262
+ for what the chart counts — the definition a reader needs and a subtitle has no room
263
+ for. `xAxis.labels.hint(ctx)` puts the same callout on a tick label, for a step
264
+ name that means nothing to a reader who has not been told.
265
+
266
+ `title.caption` is a muted note on the far end of the title's own row — the period
267
+ the figures cover, when they were counted. Only the main title draws one.
268
+
269
+ ## Animation
270
+
271
+ `series.animation` sets one series' own transition length — `{ duration: 500 }`,
272
+ or `false` for none. Everything left alone runs on the chart clock below.
273
+
274
+ Two mechanisms, chosen by the shape being moved.
275
+
276
+ **CSS transitions** carry shapes whose geometry interpolates correctly component by
277
+ component: the vertices of a line or an area, and the `x`/`y`/`width`/`height` of a
278
+ column. `SeriesLayer` in `Chart.styles.ts` owns those.
279
+
280
+ **A frame-by-frame tween** (`useTweenedNumbers`) carries shapes that are *generated*
281
+ from numbers, where blending the drawing instructions is not the same as blending
282
+ the shape. A pie is the case in point: interpolating an arc's endpoints drags them
283
+ along straight lines while the radius stays put, so the arc bulges, and the
284
+ large-arc flag flips halfway through into a visible jump. The pie therefore tweens
285
+ its angles and radius and regenerates the arc every frame — the same approach as
286
+ D3's `arcTween`, which interpolates `startAngle`/`endAngle` and re-runs the arc
287
+ generator, and as Highcharts, whose animator tweens the `start`, `end` and `r`
288
+ attributes of its arc symbol. A renderer that does this sets `animatesGeometry`, and
289
+ the chart then keeps CSS off its shapes so the two do not fight.
290
+
291
+ Both read their duration and easing from `src/core/animation/motion.ts`, so one
292
+ click moves everything at one speed: `MOTION_MS` with `MOTION_EASING`, the CSS
293
+ spelling of the `easeOutCubic` the tween uses. Hover feedback — the hint badge —
294
+ is the one exception at `HOVER_MS`, because an affordance should answer faster
295
+ than the chart does. `prefers-reduced-motion: reduce` switches both mechanisms
296
+ off.
297
+
298
+ ## Tooltip
299
+
300
+ `tooltip.table` lays the body out as a table instead: a header line, a heading
301
+ row, and one row per series with its swatch. Each column takes a `cell(point)`
302
+ returning a `ReactNode`. Use it when a point carries more than one figure — a
303
+ count and its share — and a single line would run them together. A tabular
304
+ tooltip defaults to a light card, because the dark hint chrome reads badly at
305
+ that size.
306
+
307
+ A column `title` is a string or a function of the hover, and a function that
308
+ returns `null` drops its column for that hover. That is what a column comparing
309
+ against the previous point needs: it names the date it compares with, and on the
310
+ first point there is no such date and no column.
311
+
312
+ Default shape is `callout`: a dark bubble with a beak anchored to the point,
313
+ flipping under the point when there is no room above and sliding sideways to stay
314
+ on screen while the beak keeps pointing at the point. `shape: 'box'` or
315
+ `followPointer: true` gives a box that trails the cursor instead.
316
+
317
+ The default body shows the x (dates through `dateFormat`), a swatch per series and
318
+ the value wrapped in `valuePrefix`/`valueSuffix`. `formatter` replaces the body and
319
+ returns a `ReactNode`; `ctx.points[].prev`/`.next` carry the neighbouring points, so
320
+ a delta needs no extra lookup. `shared: true` collects every series at the hovered x.
321
+
322
+ Golden cases: `tooltip/callout`, `tooltip/shared-formatter`.
323
+
324
+ ## Styling
325
+
326
+ The library ships no inline styles: everything visual is either an SVG
327
+ presentation attribute or a styled-component in `*.styles.ts`. Every layout
328
+ distance — label gaps, axis gutters, legend rows, tooltip padding — is a named
329
+ constant in `src/core/layout/metrics.ts`, not a number typed at the point of use;
330
+ that is what keeps the chrome consistent across components. Fonts, greys and
331
+ tooltip colours come from `@pushwoosh/kit-constants`, so charts match the product
332
+ without the consumer configuring anything.
333
+
334
+ ## Columns
335
+
336
+ `borderPosition: 'top'` strokes only the edge the value reaches — the top of a
337
+ bar counting up, the bottom of one counting down — instead of boxing the bar.
338
+ With `fillOpacity` on the body and `states.hover.fillOpacity` to deepen it, that
339
+ is the funnel look: the series keeps its real colour, so the legend swatch and
340
+ the tooltip dot stay readable, and only the bar is washed out.
341
+
342
+ A data label whose text carries `\n` is drawn as stacked lines, styled by
343
+ `style` for the first and `secondaryStyle` for the rest. It is one label: it
344
+ moves, measures and hides as one.
345
+
346
+ ## The device pixel grid
347
+
348
+ `useSize` floors the measured box, so the canvas is always a whole number of
349
+ pixels and never wider than the box that holds it. Browser zoom sizes a box in
350
+ fractions of a css pixel; storing the fraction left the canvas at its old width
351
+ whenever the box moved by less than half a pixel, and the container's
352
+ `overflow: hidden` then shaved the right edge.
353
+
354
+ `crisp(position, strokeWidth)` in `layout/metrics.ts` puts a stroke where the
355
+ device can draw it in one row: an odd stroke on a half pixel, an even one on a
356
+ whole. Grid lines, the x axis line and its ticks, and plot lines all go through
357
+ it. Without it a 1px line centred on a whole pixel is split across the two rows
358
+ either side and reads as two grey ones, and which lines land where changes at
359
+ every zoom step — the chart appears to change weight as the page zooms. Series
360
+ geometry is deliberately *not* snapped: it carries the data positions the tooltip
361
+ and hit-testing share.
362
+
363
+ Both rules are Highcharts', arithmetic included, and both work in css pixels:
364
+ neither library reads `devicePixelRatio` (Highcharts 12.6 does not mention it
365
+ once). So the snapping is exact at whole device ratios, and at the rest — 125%,
366
+ or any zoom out, where a 1px css line is thinner than a device pixel and grey
367
+ wherever it sits — it buys consistency rather than sharpness. What it does buy at
368
+ every ratio is that the geometry never changes: measured across zoom 50%–150%,
369
+ the canvas stays 688 in a 688.5 box and the grid stays on the same half pixels,
370
+ which is exactly what Highcharts emits under the same sweep.
371
+
372
+ ## Size
373
+
374
+ One runtime dependency (`@pushwoosh/kit-constants`, for design-system tokens);
375
+ SVG output; renderers are only bundled when imported, which is what the
376
+ `src/core` import restriction protects. There is no automated size gate —
377
+ adding a dependency or letting the core reach into `src/series` will not be
378
+ caught by CI.
@@ -0,0 +1,19 @@
1
+ import { type ReactElement } from 'react';
2
+ import { type AxisModel } from '../model/build-model';
3
+ import { type Rect } from '../types/geometry';
4
+ interface GridProps {
5
+ plot: Rect;
6
+ yAxis: AxisModel | undefined;
7
+ }
8
+ export declare function Grid({ plot, yAxis }: GridProps): ReactElement | null;
9
+ interface XAxisProps {
10
+ plot: Rect;
11
+ axis: AxisModel;
12
+ }
13
+ export declare function XAxis({ plot, axis }: XAxisProps): ReactElement | null;
14
+ interface YAxisProps {
15
+ plot: Rect;
16
+ axis: AxisModel;
17
+ }
18
+ export declare function YAxis({ plot, axis }: YAxisProps): ReactElement | null;
19
+ export {};
@@ -0,0 +1,204 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Hinted } from './Hinted';
3
+ import { DEFAULT_FONT_SIZE, measureText } from '../layout/measure-text';
4
+ import { AXIS_LABEL_GAP, AXIS_TITLE_GAP, crisp, TEXT_BASELINE_SHIFT, X_AXIS_LABEL_DROP, X_AXIS_TITLE_GAP } from '../layout/metrics';
5
+ import { DEFAULT_AXIS_COLOR, DEFAULT_GRID_COLOR, DEFAULT_MUTED_TEXT_COLOR } from '../model/palette';
6
+ const textProps = style => ({
7
+ fill: (style === null || style === void 0 ? void 0 : style.color) ?? DEFAULT_MUTED_TEXT_COLOR,
8
+ fontFamily: style === null || style === void 0 ? void 0 : style.fontFamily,
9
+ fontSize: (style === null || style === void 0 ? void 0 : style.fontSize) ?? DEFAULT_FONT_SIZE,
10
+ fontWeight: style === null || style === void 0 ? void 0 : style.fontWeight
11
+ });
12
+ const ANCHOR = {
13
+ left: 'start',
14
+ center: 'middle',
15
+ right: 'end'
16
+ };
17
+ /** A tick label, wrapped in its explanation when the axis gave it one. */
18
+ function TickLabel({
19
+ hint,
20
+ x,
21
+ y,
22
+ anchor,
23
+ style,
24
+ children
25
+ }) {
26
+ const text = _jsx("text", {
27
+ x: x,
28
+ y: y,
29
+ textAnchor: anchor,
30
+ ...textProps(style),
31
+ children: children
32
+ });
33
+ return hint ? _jsx(Hinted, {
34
+ text: hint,
35
+ children: text
36
+ }) : text;
37
+ }
38
+ export function Grid({
39
+ plot,
40
+ yAxis
41
+ }) {
42
+ // A hidden axis takes its grid with it: `visible: false` is how a chart that
43
+ // labels its bars instead of its axis — a funnel — gets a bare plot.
44
+ if (!yAxis || yAxis.options.visible === false || yAxis.options.gridLineWidth === 0) {
45
+ return null;
46
+ }
47
+ const color = yAxis.options.gridLineColor ?? DEFAULT_GRID_COLOR;
48
+ const width = yAxis.options.gridLineWidth ?? 1;
49
+ const {
50
+ scale
51
+ } = yAxis;
52
+ // A categorical axis draws its grid on the row boundaries rather than through
53
+ // the middle of a row, so that a row sits inside one cell of the grid.
54
+ const positions = scale.kind === 'band' ? [...scale.domain().map(value => scale.map(value)), plot.y + plot.height] : yAxis.ticks.map(tick => tick.position);
55
+ return _jsx("g", {
56
+ "aria-hidden": true,
57
+ children: positions.map(position => _jsx("line", {
58
+ x1: plot.x,
59
+ x2: plot.x + plot.width,
60
+ y1: crisp(position, width),
61
+ y2: crisp(position, width),
62
+ stroke: color,
63
+ strokeWidth: width
64
+ }, `grid-${position}`))
65
+ });
66
+ }
67
+ function AxisTitle({
68
+ title,
69
+ x,
70
+ y,
71
+ anchor,
72
+ defaultRotation
73
+ }) {
74
+ var _title$style;
75
+ if (!(title !== null && title !== void 0 && title.text) || ((_title$style = title.style) === null || _title$style === void 0 ? void 0 : _title$style.display) === 'none') {
76
+ return null;
77
+ }
78
+ const rotation = title.rotation ?? defaultRotation;
79
+ const at = {
80
+ x: x + (title.x ?? 0),
81
+ y: y + (title.y ?? 0)
82
+ };
83
+ return _jsx("text", {
84
+ x: at.x,
85
+ y: at.y,
86
+ textAnchor: rotation === 0 ? anchor : 'middle',
87
+ transform: rotation === 0 ? undefined : `rotate(${rotation} ${at.x} ${at.y})`,
88
+ ...textProps(title.style),
89
+ children: title.text
90
+ });
91
+ }
92
+ const titleY = (options, plot) => {
93
+ var _options$title;
94
+ const align = ((_options$title = options.title) === null || _options$title === void 0 ? void 0 : _options$title.align) ?? 'middle';
95
+ if (align === 'high') {
96
+ return plot.y;
97
+ }
98
+ if (align === 'low') {
99
+ return plot.y + plot.height;
100
+ }
101
+ return plot.y + plot.height / 2;
102
+ };
103
+ export function XAxis({
104
+ plot,
105
+ axis
106
+ }) {
107
+ var _options$labels, _options$labels2, _options$title2;
108
+ const {
109
+ options
110
+ } = axis;
111
+ if (options.visible === false) {
112
+ return null;
113
+ }
114
+ const onTop = options.opposite === true;
115
+ const offset = options.offset ?? 0;
116
+ const direction = onTop ? -1 : 1;
117
+ const baseline = onTop ? plot.y - offset : plot.y + plot.height + offset;
118
+ const lineColor = options.lineColor ?? DEFAULT_AXIS_COLOR;
119
+ const tickLength = options.tickLength ?? 0;
120
+ const tickWidth = options.tickWidth ?? 1;
121
+ const labelsEnabled = ((_options$labels = options.labels) === null || _options$labels === void 0 ? void 0 : _options$labels.enabled) !== false;
122
+ const labelY = baseline + direction * (tickLength + (onTop ? AXIS_LABEL_GAP : X_AXIS_LABEL_DROP)) + (((_options$labels2 = options.labels) === null || _options$labels2 === void 0 ? void 0 : _options$labels2.y) ?? 0);
123
+ return _jsxs("g", {
124
+ children: [options.lineWidth === 0 ? null : _jsx("line", {
125
+ x1: plot.x,
126
+ x2: plot.x + plot.width,
127
+ y1: crisp(baseline, options.lineWidth ?? 1),
128
+ y2: crisp(baseline, options.lineWidth ?? 1),
129
+ stroke: lineColor,
130
+ strokeWidth: options.lineWidth ?? 1
131
+ }), axis.ticks.map(tick => {
132
+ var _options$labels3, _options$labels4;
133
+ return _jsxs("g", {
134
+ children: [tickLength > 0 && tickWidth > 0 ? _jsx("line", {
135
+ x1: crisp(tick.position, tickWidth),
136
+ x2: crisp(tick.position, tickWidth),
137
+ y1: crisp(baseline, tickWidth),
138
+ y2: crisp(baseline + direction * tickLength, tickWidth),
139
+ stroke: options.tickColor ?? lineColor,
140
+ strokeWidth: tickWidth
141
+ }) : null, labelsEnabled && tick.label ? _jsx(TickLabel, {
142
+ hint: tick.hint,
143
+ x: tick.position + (((_options$labels3 = options.labels) === null || _options$labels3 === void 0 ? void 0 : _options$labels3.x) ?? 0),
144
+ y: labelY,
145
+ anchor: tick.anchor,
146
+ style: (_options$labels4 = options.labels) === null || _options$labels4 === void 0 ? void 0 : _options$labels4.style,
147
+ children: tick.label
148
+ }) : null]
149
+ }, `x-${tick.value}`);
150
+ }), _jsx(AxisTitle, {
151
+ title: options.title,
152
+ x: plot.x + plot.width / 2,
153
+ y: labelY + direction * ((((_options$title2 = options.title) === null || _options$title2 === void 0 ? void 0 : _options$title2.offset) ?? 0) + X_AXIS_TITLE_GAP),
154
+ anchor: "middle",
155
+ defaultRotation: 0
156
+ })]
157
+ });
158
+ }
159
+ export function YAxis({
160
+ plot,
161
+ axis
162
+ }) {
163
+ var _options$labels5, _options$labels6, _options$title3, _options$title4;
164
+ const {
165
+ options
166
+ } = axis;
167
+ if (options.visible === false) {
168
+ return null;
169
+ }
170
+ const labelsEnabled = ((_options$labels5 = options.labels) === null || _options$labels5 === void 0 ? void 0 : _options$labels5.enabled) !== false;
171
+ const onRight = options.opposite === true;
172
+ const offset = options.offset ?? 0;
173
+ // `offset` pushes the whole axis — labels and title — away from the plot.
174
+ const edge = onRight ? plot.x + plot.width + AXIS_LABEL_GAP + offset : plot.x - AXIS_LABEL_GAP - offset;
175
+ const anchor = (((_options$labels6 = options.labels) === null || _options$labels6 === void 0 ? void 0 : _options$labels6.align) && ANCHOR[options.labels.align]) ?? (onRight ? 'start' : 'end');
176
+ const columnWidth = labelsEnabled ? axis.ticks.reduce((widest, tick) => {
177
+ var _options$labels7;
178
+ return Math.max(widest, measureText(tick.label, (_options$labels7 = options.labels) === null || _options$labels7 === void 0 ? void 0 : _options$labels7.style));
179
+ }, 0) : 0;
180
+ // Where the labels grow away from the plot, the title has to clear the whole
181
+ // column of them or it lands on top of the numbers.
182
+ const outwards = onRight ? anchor !== 'end' : anchor !== 'start';
183
+ const titleShift = outwards ? Math.ceil(columnWidth * (anchor === 'middle' ? 0.5 : 1)) + AXIS_TITLE_GAP : 0;
184
+ const titleX = onRight ? edge + titleShift + (((_options$title3 = options.title) === null || _options$title3 === void 0 ? void 0 : _options$title3.offset) ?? 0) : edge - titleShift - (((_options$title4 = options.title) === null || _options$title4 === void 0 ? void 0 : _options$title4.offset) ?? 0);
185
+ return _jsxs("g", {
186
+ children: [labelsEnabled ? axis.ticks.map(tick => {
187
+ var _options$labels8, _options$labels9;
188
+ return tick.label ? _jsx(TickLabel, {
189
+ x: edge + (((_options$labels8 = options.labels) === null || _options$labels8 === void 0 ? void 0 : _options$labels8.x) ?? 0),
190
+ y: tick.position + TEXT_BASELINE_SHIFT,
191
+ anchor: anchor,
192
+ hint: tick.hint,
193
+ style: (_options$labels9 = options.labels) === null || _options$labels9 === void 0 ? void 0 : _options$labels9.style,
194
+ children: tick.label
195
+ }, `y-${tick.value}`) : null;
196
+ }) : null, _jsx(AxisTitle, {
197
+ title: options.title,
198
+ x: titleX,
199
+ y: titleY(options, plot),
200
+ anchor: anchor,
201
+ defaultRotation: onRight ? 90 : -90
202
+ })]
203
+ });
204
+ }
@@ -0,0 +1,33 @@
1
+ import { type HTMLAttributes, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode } from 'react';
2
+ import { type ChartOptions } from '../types/options';
3
+ import { type AnySeriesRenderer } from '../types/renderer';
4
+ /** The point a click landed on, as handed to `onPointClick`. */
5
+ export interface ClickedPoint<TPointMeta = unknown> {
6
+ seriesIndex: number;
7
+ seriesName: string;
8
+ pointIndex: number;
9
+ x: number | string;
10
+ y: number | null;
11
+ value: number | null;
12
+ color: string;
13
+ meta: TPointMeta;
14
+ }
15
+ export interface ChartProps<TChartMeta = unknown, TSeriesMeta = unknown> {
16
+ options: ChartOptions<TChartMeta, TSeriesMeta>;
17
+ renderers?: readonly AnySeriesRenderer[];
18
+ isLoading?: boolean;
19
+ empty?: ReactNode;
20
+ loading?: ReactNode;
21
+ className?: string;
22
+ containerProps?: HTMLAttributes<HTMLDivElement>;
23
+ /** Set false to make the legend a plain key again. */
24
+ toggleSeries?: boolean;
25
+ /**
26
+ * Called after a legend click with the visibility it lands on. `pointIndex` is
27
+ * set when the item stood for a single point, as a pie's items do.
28
+ */
29
+ onSeriesToggle?: (seriesIndex: number, visible: boolean, pointIndex?: number) => void;
30
+ /** Called with the point nearest a click inside the plot; drill-down hangs off this. */
31
+ onPointClick?: (point: ClickedPoint, event: ReactMouseEvent<SVGSVGElement>) => void;
32
+ }
33
+ export declare function Chart<TChartMeta = unknown, TSeriesMeta = unknown>(props: ChartProps<TChartMeta, TSeriesMeta>): ReactElement;