@rokkit/chart 1.8.0 → 1.8.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.
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ type Row = Record<string, unknown>
3
+
4
+ type Props = {
5
+ /** The plotted rows. */
6
+ rows: Row[]
7
+ /** Column fields, in order (`tableColumns` in lib/plot/spec.js). */
8
+ columns: string[]
9
+ /** The chart's title — names the table and becomes its caption. */
10
+ title?: string
11
+ }
12
+
13
+ let { rows, columns, title = '' }: Props = $props()
14
+ </script>
15
+
16
+ <!-- The chart's data as a table: in the DOM for screen readers, never visible. -->
17
+ {#if rows.length > 0 && columns.length > 0}
18
+ <table class="plot-sr-table" aria-label={title || 'Chart data'}>
19
+ {#if title}
20
+ <caption>{title}</caption>
21
+ {/if}
22
+ <thead>
23
+ <tr>
24
+ {#each columns as col (col)}
25
+ <th scope="col">{col}</th>
26
+ {/each}
27
+ </tr>
28
+ </thead>
29
+ <tbody>
30
+ {#each rows as row, i (i)}
31
+ <tr>
32
+ {#each columns as col (col)}
33
+ <td>{row[col] ?? ''}</td>
34
+ {/each}
35
+ </tr>
36
+ {/each}
37
+ </tbody>
38
+ </table>
39
+ {/if}
40
+
41
+ <style>
42
+ /* Visually hidden — in DOM for screen readers, not visible */
43
+ .plot-sr-table {
44
+ position: absolute;
45
+ width: 1px;
46
+ height: 1px;
47
+ padding: 0;
48
+ margin: -1px;
49
+ overflow: hidden;
50
+ clip: rect(0, 0, 0, 0);
51
+ white-space: nowrap;
52
+ border: 0;
53
+ }
54
+ </style>
@@ -0,0 +1,12 @@
1
+ type Row = Record<string, unknown>;
2
+ type Props = {
3
+ /** The plotted rows. */
4
+ rows: Row[];
5
+ /** Column fields, in order (`tableColumns` in lib/plot/spec.js). */
6
+ columns: string[];
7
+ /** The chart's title — names the table and becomes its caption. */
8
+ title?: string;
9
+ };
10
+ declare const DataTable: import("svelte").Component<Props, {}, "">;
11
+ type DataTable = ReturnType<typeof DataTable>;
12
+ export default DataTable;
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ import type { Component } from 'svelte'
3
+ import type { PlotSpec, PlotHelpers } from '../lib/plot/types.js'
4
+ import { specGeomProps } from '../lib/plot/spec.js'
5
+ import Bar from '../geoms/Bar.svelte'
6
+ import Line from '../geoms/Line.svelte'
7
+ import Area from '../geoms/Area.svelte'
8
+ import Point from '../geoms/Point.svelte'
9
+ import Arc from '../geoms/Arc.svelte'
10
+ import Box from '../geoms/Box.svelte'
11
+ import Violin from '../geoms/Violin.svelte'
12
+ import Heatmap from '../geoms/Heatmap.svelte'
13
+ import Candlestick from '../geoms/Candlestick.svelte'
14
+ import Waterfall from '../geoms/Waterfall.svelte'
15
+ import Hexbin from '../geoms/Hexbin.svelte'
16
+ import Ribbon from '../geoms/Ribbon.svelte'
17
+ import Radar from '../geoms/Radar.svelte'
18
+ import Rule from '../geoms/Rule.svelte'
19
+ import Region from '../geoms/Region.svelte'
20
+ import Hull from '../geoms/Hull.svelte'
21
+ import Contour from '../geoms/Contour.svelte'
22
+
23
+ type Props = {
24
+ /** The chart spec; its `geoms` are drawn, and its channels are what each one inherits. */
25
+ spec?: PlotSpec
26
+ /** `helpers.geoms` replaces a built-in (or an annotation) by type name. */
27
+ helpers?: PlotHelpers
28
+ }
29
+
30
+ let { spec = undefined, helpers = {} }: Props = $props()
31
+
32
+ // Geom component resolver for spec-driven mode. Typed as a lookup table, not the union of
33
+ // its members: a spec names geoms by string at runtime, exactly like `helpers.geoms`, and the
34
+ // union of every geom's Props has no single shape a generic call site can satisfy.
35
+ // `any` is the honest type for a heterogeneous table resolved by name — the same one the
36
+ // public `PlotHelpers.geoms` already declares in lib/plot/types.js.
37
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
+ const GEOM_COMPONENTS: Record<string, Component<any>> = {
39
+ bar: Bar,
40
+ line: Line,
41
+ area: Area,
42
+ point: Point,
43
+ arc: Arc,
44
+ box: Box,
45
+ violin: Violin,
46
+ heatmap: Heatmap,
47
+ candlestick: Candlestick,
48
+ waterfall: Waterfall,
49
+ hexbin: Hexbin,
50
+ ribbon: Ribbon,
51
+ // Radar reads the generic x/y/color this path passes via its own aliases, and takes
52
+ // its axis order from `options.axes` — see Radar.svelte's Props.
53
+ radar: Radar,
54
+ hull: Hull,
55
+ contour: Contour
56
+ }
57
+
58
+ // Plane annotations read no data, so they take ONLY their own `props` — never the spec's
59
+ // field channels: a Region's `x`/`y` are ranges, and handing it the field name 'instability'
60
+ // would be a range of one string. A helper registered under the same name is a geom again.
61
+ const ANNOTATIONS = { rule: Rule, region: Region }
62
+
63
+ function resolveGeomComponent(type: string) {
64
+ return helpers?.geoms?.[type] ?? GEOM_COMPONENTS[type]
65
+ }
66
+ </script>
67
+
68
+ <!-- Keyed by position, not type: two regions in one spec are normal, and a type key would be a
69
+ duplicate that aborts the whole render. An unknown type renders nothing. -->
70
+ {#each spec?.geoms ?? [] as geomSpec, i (`${geomSpec.type}-${i}`)}
71
+ {@const Annotation = ANNOTATIONS[geomSpec.type as keyof typeof ANNOTATIONS]}
72
+ {@const GeomComponent = resolveGeomComponent(geomSpec.type)}
73
+ {#if Annotation && !helpers?.geoms?.[geomSpec.type]}
74
+ <Annotation {...geomSpec.props ?? {}} />
75
+ {:else if GeomComponent}
76
+ <GeomComponent {...specGeomProps(geomSpec, spec)} />
77
+ {/if}
78
+ {/each}
@@ -0,0 +1,11 @@
1
+ import type { Component } from 'svelte';
2
+ import type { PlotSpec, PlotHelpers } from '../lib/plot/types.js';
3
+ type Props = {
4
+ /** The chart spec; its `geoms` are drawn, and its channels are what each one inherits. */
5
+ spec?: PlotSpec;
6
+ /** `helpers.geoms` replaces a built-in (or an annotation) by type name. */
7
+ helpers?: PlotHelpers;
8
+ };
9
+ declare const SpecGeoms: Component<Props, {}, "">;
10
+ type SpecGeoms = ReturnType<typeof SpecGeoms>;
11
+ export default SpecGeoms;
package/dist/Plot.svelte CHANGED
@@ -1,30 +1,16 @@
1
1
  <script lang="ts">
2
2
  import { getContext } from 'svelte'
3
- import type { Component, Snippet } from 'svelte'
3
+ import type { Snippet } from 'svelte'
4
4
  import { defaultPreset } from './lib/preset.js'
5
5
  import type { PlotSpec, PlotHelpers } from './lib/plot/types.js'
6
+ import { resolvePlotConfig, resolveChrome, tableColumns } from './lib/plot/spec.js'
6
7
  import PlotSurface from './PlotSurface.svelte'
7
8
  import Axis from './Plot/Axis.svelte'
8
9
  import Grid from './Plot/Grid.svelte'
9
10
  import Legend from './Plot/Legend.svelte'
10
11
  import Tooltip from './Plot/Tooltip.svelte'
11
- import Bar from './geoms/Bar.svelte'
12
- import Line from './geoms/Line.svelte'
13
- import Area from './geoms/Area.svelte'
14
- import Point from './geoms/Point.svelte'
15
- import Arc from './geoms/Arc.svelte'
16
- import Box from './geoms/Box.svelte'
17
- import Violin from './geoms/Violin.svelte'
18
- import Heatmap from './geoms/Heatmap.svelte'
19
- import Candlestick from './geoms/Candlestick.svelte'
20
- import Waterfall from './geoms/Waterfall.svelte'
21
- import Hexbin from './geoms/Hexbin.svelte'
22
- import Ribbon from './geoms/Ribbon.svelte'
23
- import Radar from './geoms/Radar.svelte'
24
- import Rule from './geoms/Rule.svelte'
25
- import Region from './geoms/Region.svelte'
26
- import Hull from './geoms/Hull.svelte'
27
- import Contour from './geoms/Contour.svelte'
12
+ import SpecGeoms from './Plot/SpecGeoms.svelte'
13
+ import DataTable from './Plot/DataTable.svelte'
28
14
  import Highlight from './geoms/Highlight.svelte'
29
15
  import Trend from './geoms/Trend.svelte'
30
16
 
@@ -122,102 +108,43 @@
122
108
  const chartPresetCtx = getContext<ChartPresetCtx | undefined>('chart-preset')
123
109
  const chartPreset = $derived(chartPresetCtx?.current ?? defaultPreset)
124
110
 
125
- const gridValue = $derived(spec?.grid ?? grid)
126
- const showGrid = $derived(gridValue !== false)
127
- const gridLines = $derived(gridValue === true || gridValue === false ? 'auto' : gridValue)
128
- const showLegend = $derived(spec?.legend ?? legend)
129
- const chartTitle = $derived(spec?.title ?? title)
130
- const chartSummary = $derived(spec?.summary ?? summary)
111
+ // Spec-over-prop precedence lives in lib/plot/spec.js — one table, not a `??` per field.
112
+ const chrome = $derived(
113
+ resolveChrome(spec, { grid, legend, title, summary, x, y })
114
+ )
131
115
 
132
116
  // Accessible data table — screen reader fallback
133
117
  const tableData = $derived(spec?.data ?? data)
134
- const tableColumns = $derived.by(() => {
135
- // Dedupe: a channel may repeat (e.g. x === color), and the table's keyed each
136
- // requires unique column names.
137
- const cols = [
138
- ...new Set(
139
- [spec?.x, spec?.y, spec?.color ?? spec?.fill].filter((c): c is string => Boolean(c))
140
- )
141
- ]
142
- if (cols.length > 0) return cols
143
- const first = tableData[0]
144
- return first ? Object.keys(first) : []
145
- })
118
+ const columns = $derived(tableColumns(spec, tableData))
146
119
 
147
120
  // Config for the shared PlotSurface (which owns PlotState, the responsive width, context,
148
121
  // animation, patterns, and zoom). `width` is the fallback — PlotSurface observes the container.
149
122
  function buildPlotConfig() {
150
- return {
151
- data: spec?.data ?? data,
152
- width: spec?.width ?? width,
153
- height: spec?.height ?? height,
154
- mode,
155
- margin,
156
- channels: spec ? { x: spec.x, y: spec.y, color: spec.color ?? spec.fill } : {},
157
- labels: spec?.labels ?? {},
158
- helpers,
159
- xDomain: spec?.xDomain ?? xDomain,
160
- yDomain: spec?.yDomain ?? yDomain,
161
- colorDomain: spec?.colorDomain,
162
- colorScale: spec?.colorScale,
163
- colorScheme: spec?.colorScheme,
164
- colorMidpoint: spec?.colorMidpoint,
165
- orientation: orientation ?? spec?.orientation,
166
- axisOrigin: spec?.axisOrigin ?? axisOrigin,
167
- axisOffset: spec?.axisOffset ?? axisOffset,
168
- sort: spec?.sort,
169
- continuousCategory: spec?.continuousCategory,
170
- chartPreset,
171
- onselect,
172
- selectable
173
- }
174
- }
175
-
176
- // Geoms from spec (spec-driven API)
177
- const specGeoms = $derived(spec?.geoms ?? [])
178
-
179
- const overlayX = $derived(spec?.x ?? x)
180
- const overlayY = $derived(spec?.y ?? y)
181
-
182
- // Geom component resolver for spec-driven mode. Typed as a lookup table, not the union of
183
- // its members: a spec names geoms by string at runtime, exactly like `helpers.geoms`, and the
184
- // union of every geom's Props has no single shape a generic call site can satisfy.
185
- // `any` is the honest type for a heterogeneous table resolved by name — the same one the
186
- // public `PlotHelpers.geoms` already declares in lib/plot/types.js.
187
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
- const GEOM_COMPONENTS: Record<string, Component<any>> = {
189
- bar: Bar,
190
- line: Line,
191
- area: Area,
192
- point: Point,
193
- arc: Arc,
194
- box: Box,
195
- violin: Violin,
196
- heatmap: Heatmap,
197
- candlestick: Candlestick,
198
- waterfall: Waterfall,
199
- hexbin: Hexbin,
200
- ribbon: Ribbon,
201
- // Radar reads the generic x/y/color this path passes via its own aliases, and takes
202
- // its axis order from `options.axes` — see Radar.svelte's Props.
203
- radar: Radar,
204
- hull: Hull,
205
- contour: Contour
206
- }
207
-
208
- // Plane annotations read no data, so they take ONLY their own `props` — never the spec's
209
- // field channels: a Region's `x`/`y` are ranges, and handing it the field name 'instability'
210
- // would be a range of one string.
211
- const ANNOTATIONS = { rule: Rule, region: Region }
212
-
213
- function resolveGeomComponent(type: string) {
214
- return helpers?.geoms?.[type] ?? GEOM_COMPONENTS[type]
123
+ return resolvePlotConfig(
124
+ spec,
125
+ {
126
+ data,
127
+ width,
128
+ height,
129
+ mode,
130
+ margin,
131
+ helpers,
132
+ xDomain,
133
+ yDomain,
134
+ orientation,
135
+ axisOrigin,
136
+ axisOffset,
137
+ onselect,
138
+ selectable
139
+ },
140
+ chartPreset
141
+ )
215
142
  }
216
143
  </script>
217
144
 
218
145
  <div class="plot-root">
219
- {#if chartTitle}
220
- <div class="plot-title" data-plot-title>{chartTitle}</div>
146
+ {#if chrome.title}
147
+ <div class="plot-title" data-plot-title>{chrome.title}</div>
221
148
  {/if}
222
149
 
223
150
  <!-- The shared root shell (PlotState, responsive width, context, animation, patterns, zoom).
@@ -226,57 +153,33 @@
226
153
  config={buildPlotConfig()}
227
154
  {animate}
228
155
  {zoom}
229
- ariaLabel={chartTitle || 'Chart visualization'}
230
- summary={chartSummary}
156
+ ariaLabel={chrome.title || 'Chart visualization'}
157
+ summary={chrome.summary}
231
158
  bind:selected
232
159
  >
233
160
  <!-- Grid (behind everything) -->
234
- {#if showGrid}
235
- <Grid lines={gridLines} {xTicks} {yTicks} />
161
+ {#if chrome.showGrid}
162
+ <Grid lines={chrome.gridLines} {xTicks} {yTicks} />
236
163
  {/if}
237
164
 
238
165
  <!-- Declarative children (geom components) -->
239
166
  {@render children?.()}
240
167
 
241
- <!-- Spec-driven geoms. Keyed by position, not type: two regions in one spec are normal, and
242
- a type key would be a duplicate that aborts the whole render. -->
243
- {#each specGeoms as geomSpec, i (`${geomSpec.type}-${i}`)}
244
- {@const Annotation = ANNOTATIONS[geomSpec.type as keyof typeof ANNOTATIONS]}
245
- {@const GeomComponent = resolveGeomComponent(geomSpec.type)}
246
- {#if Annotation && !helpers?.geoms?.[geomSpec.type]}
247
- <Annotation {...geomSpec.props ?? {}} />
248
- {:else if GeomComponent}
249
- <GeomComponent
250
- x={geomSpec.x ?? spec?.x}
251
- y={geomSpec.y ?? spec?.y}
252
- color={geomSpec.color ?? spec?.color}
253
- fill={geomSpec.fill ?? spec?.fill}
254
- pattern={geomSpec.pattern}
255
- symbol={geomSpec.symbol}
256
- stat={geomSpec.stat}
257
- label={geomSpec.label}
258
- options={{
259
- ...(spec?.stack !== undefined ? { stack: spec.stack } : {}),
260
- ...(spec?.orientation !== undefined ? { orientation: spec.orientation } : {}),
261
- ...(geomSpec.options ?? {})
262
- }}
263
- {...geomSpec.props ?? {}}
264
- />
265
- {/if}
266
- {/each}
168
+ <!-- Spec-driven geoms -->
169
+ <SpecGeoms {spec} {helpers} />
267
170
 
268
171
  <!-- Axes -->
269
172
  {#if axes}
270
173
  <Axis
271
174
  type="x"
272
- label={spec?.labels?.[spec?.x ?? ''] ?? ''}
175
+ label={chrome.xLabel}
273
176
  format={xFormat}
274
177
  ticks={xTicks}
275
178
  {minorTicks}
276
179
  />
277
180
  <Axis
278
181
  type="y"
279
- label={spec?.labels?.[spec?.y ?? ''] ?? ''}
182
+ label={chrome.yLabel}
280
183
  format={yFormat}
281
184
  ticks={yTicks}
282
185
  {minorTicks}
@@ -284,16 +187,16 @@
284
187
  {/if}
285
188
 
286
189
  {#if trend !== null && trend !== undefined}
287
- <Trend x={overlayX} y={overlayY} {trend} />
190
+ <Trend x={chrome.overlayX} y={chrome.overlayY} {trend} />
288
191
  {/if}
289
192
 
290
193
  {#if (highlight !== null && highlight !== undefined) || selectable || selected.length}
291
- <Highlight x={overlayX} y={overlayY} {highlight} {label} />
194
+ <Highlight x={chrome.overlayX} y={chrome.overlayY} {highlight} {label} />
292
195
  {/if}
293
196
 
294
197
  <!-- HTML overlays — outside the svg but INSIDE the plot-state context. -->
295
198
  {#snippet overlay()}
296
- {#if showLegend}
199
+ {#if chrome.showLegend}
297
200
  <Legend labels={spec?.labels ?? {}} />
298
201
  {/if}
299
202
 
@@ -301,29 +204,7 @@
301
204
  <Tooltip {tooltip} />
302
205
  {/if}
303
206
 
304
- {#if tableData.length > 0 && tableColumns.length > 0}
305
- <table class="plot-sr-table" aria-label={chartTitle || 'Chart data'}>
306
- {#if chartTitle}
307
- <caption>{chartTitle}</caption>
308
- {/if}
309
- <thead>
310
- <tr>
311
- {#each tableColumns as col (col)}
312
- <th scope="col">{col}</th>
313
- {/each}
314
- </tr>
315
- </thead>
316
- <tbody>
317
- {#each tableData as row, i (i)}
318
- <tr>
319
- {#each tableColumns as col (col)}
320
- <td>{row[col] ?? ''}</td>
321
- {/each}
322
- </tr>
323
- {/each}
324
- </tbody>
325
- </table>
326
- {/if}
207
+ <DataTable rows={tableData} {columns} title={chrome.title} />
327
208
  {/snippet}
328
209
  </PlotSurface>
329
210
  </div>
@@ -341,17 +222,4 @@
341
222
  text-align: center;
342
223
  margin-bottom: 4px;
343
224
  }
344
-
345
- /* Visually hidden — in DOM for screen readers, not visible */
346
- .plot-sr-table {
347
- position: absolute;
348
- width: 1px;
349
- height: 1px;
350
- padding: 0;
351
- margin: -1px;
352
- overflow: hidden;
353
- clip: rect(0, 0, 0, 0);
354
- white-space: nowrap;
355
- border: 0;
356
- }
357
225
  </style>
@@ -1,4 +1,4 @@
1
- import type { Component, Snippet } from 'svelte';
1
+ import type { Snippet } from 'svelte';
2
2
  import type { PlotSpec, PlotHelpers } from './lib/plot/types.js';
3
3
  type Row = Record<string, unknown>;
4
4
  type Method = string | number | {
@@ -59,6 +59,6 @@ type Props = {
59
59
  animate?: boolean;
60
60
  children?: Snippet;
61
61
  };
62
- declare const Plot: Component<Props, {}, "selected">;
62
+ declare const Plot: import("svelte").Component<Props, {}, "selected">;
63
63
  type Plot = ReturnType<typeof Plot>;
64
64
  export default Plot;
@@ -55,7 +55,7 @@
55
55
  {#if arcs.length > 0}
56
56
  <g data-plot-geom="arc" transform="translate({w / 2}, {h / 2})">
57
57
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
58
- {#each arcs as arc (arc.key)}
58
+ {#each arcs as arc (arc.id)}
59
59
  <path
60
60
  d={arc.d}
61
61
  fill={arc.fill}
@@ -91,26 +91,22 @@
91
91
  const effectiveOrientation = $derived(options.orientation ?? plotState.orientation)
92
92
  const seriesField = $derived(fill ?? color)
93
93
 
94
- let dimmedByKey = $state<Record<string, boolean>>({})
95
-
96
- $effect(() => {
97
- if (!cf) {
98
- dimmedByKey = {}
99
- return
100
- }
101
- // cf.version is a $state counter that increments on every filter mutation.
102
- // Reading it here establishes a reactive dependency so the effect re-runs
103
- // whenever any filter changes — including changes from sibling FilterBars.
94
+ /** Bars the crossfilter dims, by key. A derivation, so it needs no effect writing state. */
95
+ const dimmedByKey = $derived.by((): Record<string, boolean> => {
96
+ if (!cf) return {}
97
+ // cf.version is a $state counter that increments on every filter mutation. Reading it
98
+ // makes this re-derive whenever any filter changes — including a sibling FilterBar's.
104
99
  void cf.version
105
- const next: Record<string, boolean> = {}
100
+ const dimmed: Record<string, boolean> = {}
106
101
  for (const bar of bars) {
107
- const dimmedByX = x ? cf.isDimmed(x, bar.data[x]) : false
108
- const dimmedByY = y ? cf.isDimmed(y, bar.data[y]) : false
109
- next[bar.key] = dimmedByX || dimmedByY
102
+ dimmed[bar.key] = (x ? cf.isDimmed(x, bar.data[x]) : false) || (y ? cf.isDimmed(y, bar.data[y]) : false)
110
103
  }
111
- dimmedByKey = next
104
+ return dimmed
112
105
  })
113
106
 
107
+ /** Whether a bar takes clicks and keys at all. */
108
+ const activatable = $derived(filterable || Boolean(onselect) || keyboard || plotState.interactive)
109
+
114
110
  function handleBarClick(barX: unknown) {
115
111
  if (!filterable || !x || !cf) return
116
112
  cf.toggleCategorical(x, barX)
@@ -130,20 +126,19 @@
130
126
  )
131
127
  }
132
128
 
133
- function handleBarActivate(bar: { data: Row }, event: MouseEvent) {
129
+ /**
130
+ * A bar activated by click, Enter or Space. A filterable bar FILTERS — it does not also emit
131
+ * `onselect`, the rule the keyboard path always had and a click did not; otherwise it
132
+ * selects. The plot-level selection (`selectBar`) runs either way.
133
+ */
134
+ function activate(bar: { data: Row }, event: MouseEvent | KeyboardEvent) {
134
135
  if (filterable && x) handleBarClick(bar.data[x])
135
- onselect?.(bar.data)
136
+ else onselect?.(bar.data)
136
137
  selectBar(bar, event)
137
138
  }
138
139
 
139
140
  function handleBarKeyActivate(bar: { data: Row }, event: KeyboardEvent) {
140
- if (event.key !== 'Enter' && event.key !== ' ') return
141
- if (filterable && x) {
142
- handleBarClick(bar.data[x])
143
- } else {
144
- onselect?.(bar.data)
145
- }
146
- selectBar(bar, event)
141
+ if (event.key === 'Enter' || event.key === ' ') activate(bar, event)
147
142
  }
148
143
  </script>
149
144
 
@@ -165,14 +160,10 @@
165
160
  data-plot-category={bar.data[x ?? '']}
166
161
  data-dimmed={dimmedByKey[bar.key] ? true : undefined}
167
162
  style:cursor={filterable || onselect || plotState.interactive ? 'pointer' : undefined}
168
- onclick={filterable || onselect || keyboard || plotState.interactive
169
- ? (e) => handleBarActivate(bar, e)
170
- : undefined}
171
- onkeydown={filterable || onselect || keyboard || plotState.interactive
172
- ? (e) => handleBarKeyActivate(bar, e)
173
- : undefined}
174
- role={filterable || onselect || keyboard || plotState.interactive ? 'button' : 'graphics-symbol'}
175
- tabindex={filterable || onselect || keyboard || plotState.interactive ? 0 : undefined}
163
+ onclick={activatable ? (e) => activate(bar, e) : undefined}
164
+ onkeydown={activatable ? (e) => handleBarKeyActivate(bar, e) : undefined}
165
+ role={activatable ? 'button' : 'graphics-symbol'}
166
+ tabindex={activatable ? 0 : undefined}
176
167
  use:keyboardNav={keyboard}
177
168
  aria-label="{bar.data[x ?? '']}: {bar.data[y ?? '']}"
178
169
  onmouseenter={() => plotState.setHovered(bar.data)}
@@ -13,6 +13,7 @@ export function buildArcMarks({ data, plot, channels, options, alpha, type }: {
13
13
  type?: string;
14
14
  }): {
15
15
  alpha: number;
16
+ id: string;
16
17
  d: string | null;
17
18
  fill: string;
18
19
  stroke: string;
@@ -7,7 +7,11 @@
7
7
  * @param {number} height
8
8
  * @param {{ innerRadius?: number }} opts
9
9
  * @param {Map<unknown, string>} [patterns]
10
- * @returns {{ d: string | null, fill: string, stroke: string, key: unknown, patternId: string | null, pct: number, centroid: [number, number], data: Record<string, unknown> }[]}
10
+ * `key` is the slice's category — what picks its colour and pattern — so it repeats when rows
11
+ * share a category, and is undefined without a colour field. `id` is unique per slice (the key
12
+ * plus its occurrence), for keyed rendering; stable across updates while categories are unique.
13
+ *
14
+ * @returns {{ id: string, d: string | null, fill: string, stroke: string, key: unknown, patternId: string | null, pct: number, centroid: [number, number], data: Record<string, unknown> }[]}
11
15
  */
12
16
  export function buildArcs(data: Record<string, unknown>[], channels: {
13
17
  color?: string;
@@ -16,6 +20,7 @@ export function buildArcs(data: Record<string, unknown>[], channels: {
16
20
  }, colors: Map<any, any>, width: number, height: number, opts?: {
17
21
  innerRadius?: number;
18
22
  }, patterns?: Map<unknown, string>): {
23
+ id: string;
19
24
  d: string | null;
20
25
  fill: string;
21
26
  stroke: string;
@@ -11,7 +11,11 @@ import { literalColor, markEntry } from '../colors.js'
11
11
  * @param {number} height
12
12
  * @param {{ innerRadius?: number }} opts
13
13
  * @param {Map<unknown, string>} [patterns]
14
- * @returns {{ d: string | null, fill: string, stroke: string, key: unknown, patternId: string | null, pct: number, centroid: [number, number], data: Record<string, unknown> }[]}
14
+ * `key` is the slice's category — what picks its colour and pattern — so it repeats when rows
15
+ * share a category, and is undefined without a colour field. `id` is unique per slice (the key
16
+ * plus its occurrence), for keyed rendering; stable across updates while categories are unique.
17
+ *
18
+ * @returns {{ id: string, d: string | null, fill: string, stroke: string, key: unknown, patternId: string | null, pct: number, centroid: [number, number], data: Record<string, unknown> }[]}
15
19
  */
16
20
  export function buildArcs(data, channels, colors, width, height, opts = {}, patterns) {
17
21
  const { color: lf, y: yf } = channels
@@ -32,6 +36,7 @@ export function buildArcs(data, channels, colors, width, height, opts = {}, patt
32
36
  // Label radius: midpoint between inner and outer (or 70% out for solid pie)
33
37
  const labelRadius = innerRadius > 0 ? (innerRadius + radius) / 2 : radius * 0.65
34
38
  const labelArc = arc().innerRadius(labelRadius).outerRadius(labelRadius)
39
+ const seen = new Map()
35
40
  return slices.map((slice, i) => {
36
41
  // With a literal color there's no category field, so key each slice by index to keep
37
42
  // them distinct (patterns, which are field-keyed, don't apply to a single literal fill).
@@ -41,7 +46,10 @@ export function buildArcs(data, channels, colors, width, height, opts = {}, patt
41
46
  !lit && key !== null && key !== undefined && patterns?.has(key) ? toPatternId(String(key)) : null
42
47
  const pct = Math.round(((slice.endAngle - slice.startAngle) / total) * 100)
43
48
  const [cx, cy] = labelArc.centroid(slice)
49
+ const occurrence = seen.get(key) ?? 0
50
+ seen.set(key, occurrence + 1)
44
51
  return {
52
+ id: `${String(key)}#${occurrence}`,
45
53
  d: arcGen(slice),
46
54
  fill: colorEntry.fill,
47
55
  stroke: colorEntry.stroke,
@@ -43,11 +43,15 @@
43
43
  * Fills in the derived fields shared by every axis, regardless of which of the two
44
44
  * accepted shorthand forms it started from.
45
45
  *
46
- * @param {string|AxisSpec} entry
46
+ * Only an object is an AxisSpec; any other value — a string, or a number / boolean category
47
+ * read from the data — is the axis key itself. (Treating every non-string as a spec spread a
48
+ * numeric category into `{}`, keying every numeric axis `undefined`.)
49
+ *
50
+ * @param {string|number|AxisSpec} entry
47
51
  * @returns {ResolvedAxis}
48
52
  */
49
53
  function normaliseAxis(entry) {
50
- const spec = typeof entry === 'string' ? { key: entry } : entry
54
+ const spec = typeof entry === 'object' ? entry : { key: entry }
51
55
  const label = spec.label ?? spec.key
52
56
  return {
53
57
  ...spec,
@@ -101,7 +105,8 @@ function axisKeysInData(data, axisField) {
101
105
  const seen = new Set()
102
106
  for (const row of data) {
103
107
  const key = row?.[axisField]
104
- if (key !== undefined) seen.add(key)
108
+ // A missing category — undefined or null — names no axis.
109
+ if (key !== undefined && key !== null) seen.add(key)
105
110
  }
106
111
  return [...seen]
107
112
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The config `PlotSurface` builds its `PlotState` from.
3
+ *
4
+ * @param {Record<string, any> | undefined} spec
5
+ * @param {Record<string, any>} props - the component's props
6
+ * @param {unknown} chartPreset - the resolved preset (context, else the default)
7
+ */
8
+ export function resolvePlotConfig(spec: Record<string, any> | undefined, props: Record<string, any>, chartPreset: unknown): {
9
+ [k: string]: any;
10
+ };
11
+ /**
12
+ * The chrome around the marks: grid, legend, title, summary, axis labels, and the fields the
13
+ * trend / highlight overlays draw on.
14
+ *
15
+ * `grid`: `false` hides it; `true` is 'auto'; `'x' | 'y' | 'both'` names the lines.
16
+ *
17
+ * @param {Record<string, any> | undefined} spec
18
+ * @param {Record<string, any>} props
19
+ */
20
+ export function resolveChrome(spec: Record<string, any> | undefined, props: Record<string, any>): {
21
+ showGrid: boolean;
22
+ gridLines: any;
23
+ showLegend: any;
24
+ title: any;
25
+ summary: any;
26
+ overlayX: any;
27
+ overlayY: any;
28
+ xLabel: any;
29
+ yLabel: any;
30
+ };
31
+ /**
32
+ * The accessible table's columns: the spec's channels (deduped — a table's keyed each needs
33
+ * unique names, and `x === color` is normal), else the first row's keys.
34
+ *
35
+ * @param {Record<string, any> | undefined} spec
36
+ * @param {Record<string, unknown>[]} rows
37
+ * @returns {string[]}
38
+ */
39
+ export function tableColumns(spec: Record<string, any> | undefined, rows: Record<string, unknown>[]): string[];
40
+ /**
41
+ * The props for one `spec.geoms` entry: the spec's channels unless the geom names its own,
42
+ * the spec's stack / orientation under the geom's options, and the geom's `props` over all.
43
+ *
44
+ * @param {Record<string, any>} geomSpec
45
+ * @param {Record<string, any> | undefined} spec
46
+ */
47
+ export function specGeomProps(geomSpec: Record<string, any>, spec: Record<string, any> | undefined): any;
48
+ /**
49
+ * The PlotState config fields read straight from spec and/or props, in config order.
50
+ * @type {Array<[string, keyof typeof SOURCES]>}
51
+ */
52
+ export const PLOT_CONFIG_FIELDS: Array<[string, keyof typeof SOURCES]>;
53
+ declare namespace SOURCES {
54
+ function spec(key: any, spec: any): any;
55
+ function prop(key: any, _spec: any, props: any): any;
56
+ function specFirst(key: any, spec: any, props: any): any;
57
+ function propFirst(key: any, spec: any, props: any): any;
58
+ }
59
+ export {};
@@ -0,0 +1,131 @@
1
+ /**
2
+ * How `PlotChart` reads its inputs: a `spec` object and component props, which overlap.
3
+ *
4
+ * The rule is "a spec value overrides its prop", with one declared exception — `orientation`,
5
+ * where the prop wins so a wrapper can force direction over a shared spec. Written as a table
6
+ * so the exception is visible rather than one `??` in twenty turned the other way. Pure: the
7
+ * component derives from these, and they are tested without rendering.
8
+ */
9
+
10
+ /** Where a config field comes from. A null/undefined value falls through to the other source. */
11
+ const SOURCES = {
12
+ spec: (key, spec) => spec?.[key],
13
+ prop: (key, _spec, props) => props[key],
14
+ specFirst: (key, spec, props) => spec?.[key] ?? props[key],
15
+ propFirst: (key, spec, props) => props[key] ?? spec?.[key]
16
+ }
17
+
18
+ /**
19
+ * The PlotState config fields read straight from spec and/or props, in config order.
20
+ * @type {Array<[string, keyof typeof SOURCES]>}
21
+ */
22
+ export const PLOT_CONFIG_FIELDS = [
23
+ ['data', 'specFirst'],
24
+ ['width', 'specFirst'],
25
+ ['height', 'specFirst'],
26
+ ['mode', 'prop'],
27
+ ['margin', 'prop'],
28
+ ['helpers', 'prop'],
29
+ ['xDomain', 'specFirst'],
30
+ ['yDomain', 'specFirst'],
31
+ ['colorDomain', 'spec'],
32
+ ['colorScale', 'spec'],
33
+ ['colorScheme', 'spec'],
34
+ ['colorMidpoint', 'spec'],
35
+ ['orientation', 'propFirst'],
36
+ ['axisOrigin', 'specFirst'],
37
+ ['axisOffset', 'specFirst'],
38
+ ['sort', 'spec'],
39
+ ['continuousCategory', 'spec'],
40
+ ['onselect', 'prop'],
41
+ ['selectable', 'prop']
42
+ ]
43
+
44
+ /**
45
+ * The config `PlotSurface` builds its `PlotState` from.
46
+ *
47
+ * @param {Record<string, any> | undefined} spec
48
+ * @param {Record<string, any>} props - the component's props
49
+ * @param {unknown} chartPreset - the resolved preset (context, else the default)
50
+ */
51
+ export function resolvePlotConfig(spec, props, chartPreset) {
52
+ const config = Object.fromEntries(
53
+ PLOT_CONFIG_FIELDS.map(([key, source]) => [key, SOURCES[source](key, spec, props)])
54
+ )
55
+ // A spec names its channels; the prop-driven form has none here (children declare them).
56
+ config.channels = spec ? { x: spec.x, y: spec.y, color: spec.color ?? spec.fill } : {}
57
+ config.labels = spec?.labels ?? {}
58
+ config.chartPreset = chartPreset
59
+ return config
60
+ }
61
+
62
+ /** An axis label: the spec's label for the field on that axis, else blank. */
63
+ const axisLabel = (spec, field) => spec?.labels?.[field ?? ''] ?? ''
64
+
65
+ /**
66
+ * The chrome around the marks: grid, legend, title, summary, axis labels, and the fields the
67
+ * trend / highlight overlays draw on.
68
+ *
69
+ * `grid`: `false` hides it; `true` is 'auto'; `'x' | 'y' | 'both'` names the lines.
70
+ *
71
+ * @param {Record<string, any> | undefined} spec
72
+ * @param {Record<string, any>} props
73
+ */
74
+ export function resolveChrome(spec, props) {
75
+ const grid = spec?.grid ?? props.grid
76
+ return {
77
+ showGrid: grid !== false,
78
+ gridLines: typeof grid === 'boolean' ? 'auto' : grid,
79
+ showLegend: spec?.legend ?? props.legend,
80
+ title: spec?.title ?? props.title,
81
+ summary: spec?.summary ?? props.summary,
82
+ overlayX: spec?.x ?? props.x,
83
+ overlayY: spec?.y ?? props.y,
84
+ xLabel: axisLabel(spec, spec?.x),
85
+ yLabel: axisLabel(spec, spec?.y)
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The accessible table's columns: the spec's channels (deduped — a table's keyed each needs
91
+ * unique names, and `x === color` is normal), else the first row's keys.
92
+ *
93
+ * @param {Record<string, any> | undefined} spec
94
+ * @param {Record<string, unknown>[]} rows
95
+ * @returns {string[]}
96
+ */
97
+ export function tableColumns(spec, rows) {
98
+ const channels = [spec?.x, spec?.y, spec?.color ?? spec?.fill].filter(Boolean)
99
+ if (channels.length > 0) return [...new Set(channels)]
100
+ return rows[0] ? Object.keys(rows[0]) : []
101
+ }
102
+
103
+ /** Spec-level options every geom inherits — only when the spec sets them. */
104
+ function inheritedOptions(spec) {
105
+ const options = {}
106
+ if (spec?.stack !== undefined) options.stack = spec.stack
107
+ if (spec?.orientation !== undefined) options.orientation = spec.orientation
108
+ return options
109
+ }
110
+
111
+ /**
112
+ * The props for one `spec.geoms` entry: the spec's channels unless the geom names its own,
113
+ * the spec's stack / orientation under the geom's options, and the geom's `props` over all.
114
+ *
115
+ * @param {Record<string, any>} geomSpec
116
+ * @param {Record<string, any> | undefined} spec
117
+ */
118
+ export function specGeomProps(geomSpec, spec) {
119
+ return {
120
+ x: geomSpec.x ?? spec?.x,
121
+ y: geomSpec.y ?? spec?.y,
122
+ color: geomSpec.color ?? spec?.color,
123
+ fill: geomSpec.fill ?? spec?.fill,
124
+ pattern: geomSpec.pattern,
125
+ symbol: geomSpec.symbol,
126
+ stat: geomSpec.stat,
127
+ label: geomSpec.label,
128
+ options: { ...inheritedOptions(spec), ...(geomSpec.options ?? {}) },
129
+ ...(geomSpec.props ?? {})
130
+ }
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/chart",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "type": "module",
5
5
  "description": "Data-driven chart components",
6
6
  "repository": {