@matthieumordrel/chart-studio 0.2.5 → 0.4.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.
Files changed (48) hide show
  1. package/README.md +402 -12
  2. package/dist/core/chart-builder-controls.mjs +141 -0
  3. package/dist/core/dashboard.types.d.mts +220 -0
  4. package/dist/core/data-label-defaults.mjs +74 -0
  5. package/dist/core/data-model.types.d.mts +196 -0
  6. package/dist/core/dataset-builder.types.d.mts +51 -0
  7. package/dist/core/dataset-chart-metadata.d.mts +8 -0
  8. package/dist/core/dataset-chart-metadata.mjs +4 -0
  9. package/dist/core/date-range-presets.mjs +1 -1
  10. package/dist/core/define-dashboard.d.mts +8 -0
  11. package/dist/core/define-dashboard.mjs +156 -0
  12. package/dist/core/define-data-model.d.mts +11 -0
  13. package/dist/core/define-data-model.mjs +327 -0
  14. package/dist/core/define-dataset.d.mts +13 -0
  15. package/dist/core/define-dataset.mjs +111 -0
  16. package/dist/core/index.d.mts +17 -0
  17. package/dist/core/infer-columns.mjs +28 -2
  18. package/dist/core/materialized-view.mjs +580 -0
  19. package/dist/core/materialized-view.types.d.mts +223 -0
  20. package/dist/core/model-chart.mjs +242 -0
  21. package/dist/core/model-chart.types.d.mts +199 -0
  22. package/dist/core/model-inference.mjs +169 -0
  23. package/dist/core/model-inference.types.d.mts +71 -0
  24. package/dist/core/pipeline.mjs +32 -1
  25. package/dist/core/schema-builder.mjs +28 -158
  26. package/dist/core/schema-builder.types.d.mts +2 -49
  27. package/dist/core/types.d.mts +59 -8
  28. package/dist/core/use-chart-options.d.mts +35 -8
  29. package/dist/core/use-chart-resolvers.mjs +13 -3
  30. package/dist/core/use-chart.d.mts +16 -12
  31. package/dist/core/use-chart.mjs +136 -34
  32. package/dist/core/use-dashboard.d.mts +190 -0
  33. package/dist/core/use-dashboard.mjs +551 -0
  34. package/dist/index.d.mts +10 -3
  35. package/dist/index.mjs +5 -2
  36. package/dist/ui/chart-canvas.d.mts +11 -4
  37. package/dist/ui/chart-canvas.mjs +45 -34
  38. package/dist/ui/chart-context.d.mts +2 -0
  39. package/dist/ui/chart-context.mjs +2 -0
  40. package/dist/ui/chart-filters-panel.d.mts +1 -1
  41. package/dist/ui/chart-filters-panel.mjs +163 -37
  42. package/dist/ui/chart-group-by-selector.mjs +4 -4
  43. package/dist/ui/chart-time-bucket-selector.mjs +1 -1
  44. package/dist/ui/chart-toolbar-overflow.mjs +5 -13
  45. package/dist/ui/chart-toolbar.mjs +1 -1
  46. package/package.json +1 -1
  47. package/dist/core/define-chart-schema.d.mts +0 -38
  48. package/dist/core/define-chart-schema.mjs +0 -39
package/README.md CHANGED
@@ -18,7 +18,7 @@ Use this if you already have your own design system or chart renderer.
18
18
  You get:
19
19
 
20
20
  - `useChart`
21
- - optional `schema` via `defineChartSchema`
21
+ - optional `schema` via `defineDataset(...).chart(...)`
22
22
  - transformed chart data
23
23
  - filtering, grouping, metrics, and time bucketing logic
24
24
 
@@ -98,9 +98,344 @@ export function JobsChart() {
98
98
  ## How It Works
99
99
 
100
100
  1. Pass your raw data to `useChart()`.
101
- 2. Add an optional `schema` with `defineChartSchema<Row>()...` when you need labels, type overrides, derived columns, or control restrictions (allowed metrics, groupings, chart types, etc.).
101
+ 2. Add an optional `schema` with `defineDataset<Row>().chart(...)` when you need labels, type overrides, derived columns, or control restrictions (allowed metrics, groupings, chart types, etc.).
102
102
  3. Either render your own UI from the returned state, or use the components from `@matthieumordrel/chart-studio/ui`.
103
103
 
104
+ ## Stable Single-Chart Contract
105
+
106
+ For the simple case, the public contract is:
107
+
108
+ - `useChart({ data })` stays the zero-config path
109
+ - `useChart({ data, schema })` is the explicit single-chart path
110
+ - `defineDataset<Row>().chart(...)` is the single explicit way to build that schema
111
+ - `.columns(...)` is the authoring entry point: override raw fields, exclude fields, and add derived columns
112
+ - raw fields you do not mention in `.columns(...)` still infer normally unless you exclude them
113
+ - `xAxis`, `groupBy`, `filters`, `metric`, `chartType`, `timeBucket`, and `connectNulls` restrict that one chart's public controls
114
+ - `inputs` is an additive escape hatch for externally controlled data-scope state; it does not replace the simple `useChart({ data })` or `useChart({ data, schema })` path
115
+ - pass the builder directly to `useChart(...)`, or call `.build()` if you need the plain schema object
116
+
117
+ ## Choose the Right Boundary
118
+
119
+ There are two different scaling paths in `chart-studio`:
120
+
121
+ ### Independent dataset(s)
122
+
123
+ Stay on the single-chart path when each chart reads one flat row shape, even if
124
+ one screen renders several unrelated charts.
125
+
126
+ Use:
127
+
128
+ - `useChart({ data })` for zero-config charts
129
+ - `defineDataset<Row>().chart(...)` when one chart owns its own explicit contract
130
+ - `defineDataset<Row>()` when several charts should reuse one row contract
131
+ - `useChart({ sources: [...] })` only for source-switching inside one chart
132
+
133
+ Stop here when:
134
+
135
+ - charts do not need relationship-aware shared filters
136
+ - datasets are already flattened for the charts that consume them
137
+ - each chart can execute honestly against one dataset at a time
138
+
139
+ ### Related dashboard
140
+
141
+ Move up to the model + dashboard path when several datasets are structurally
142
+ related and you want shared filters, referential validation, or safe
143
+ lookup-preserving cross-dataset fields.
144
+
145
+ Recommended path for new dashboard work:
146
+
147
+ 1. `defineDataModel().dataset(...).infer(...)`
148
+ 2. `model.chart(...)` for lookup-preserving charts
149
+ 3. `model.materialize(...)` only when the chart grain changes
150
+ 4. `defineDashboard(model)`
151
+ 5. `useDashboard(...)`
152
+
153
+ ## Authoring Layers
154
+
155
+ ### 1. Single-chart explicit path
156
+
157
+ Use `defineDataset<Row>().chart(...)` when one chart owns its own explicit contract:
158
+
159
+ ```tsx
160
+ const schema = defineDataset<Job>()
161
+ .columns((c) => [
162
+ c.date('createdAt'),
163
+ c.category('ownerName'),
164
+ c.number('salary')
165
+ ])
166
+ .chart()
167
+ .xAxis((x) => x.allowed('createdAt'))
168
+ .metric((m) => m.aggregate('salary', 'sum'))
169
+
170
+ const chart = useChart({ data: jobs, schema })
171
+ ```
172
+
173
+ This is the single explicit path for declaring a chart schema. The dataset
174
+ owns the column contract, and `.chart(...)` layers chart-specific control
175
+ restrictions on top.
176
+
177
+ ### 2. Dataset-first reuse
178
+
179
+ Use `defineDataset<Row>()` when several charts should share one `.columns(...)`
180
+ contract and one optional declared key:
181
+
182
+ ```tsx
183
+ import { defineDataset, useChart } from '@matthieumordrel/chart-studio'
184
+
185
+ const jobs = defineDataset<Job>()
186
+ .key('id')
187
+ .columns((c) => [
188
+ c.field('id'),
189
+ c.field('ownerId'),
190
+ c.date('createdAt', { label: 'Created' }),
191
+ c.number('salary', { format: 'currency' }),
192
+ c.derived.category('salaryBand', {
193
+ label: 'Salary Band',
194
+ accessor: (row) => (row.salary >= 100_000 ? 'High' : 'Base')
195
+ })
196
+ ])
197
+
198
+ const jobsByMonth = jobs
199
+ .chart('jobsByMonth')
200
+ .xAxis((x) => x.allowed('createdAt').default('createdAt'))
201
+ .groupBy((g) => g.allowed('salaryBand'))
202
+ .metric((m) => m.count().aggregate('salary', 'sum'))
203
+
204
+ const chart = useChart({ data: jobsData, schema: jobsByMonth })
205
+ ```
206
+
207
+ Rules for the dataset-first path:
208
+
209
+ - dataset `.columns(...)` is the canonical reusable meaning of columns
210
+ - `dataset.chart(...)` provides the chart-definition surface for control restrictions
211
+ - `dataset.chart(...)` inherits dataset columns, so charts do not reopen `.columns(...)`
212
+ - declared dataset keys can be validated at runtime with `dataset.validateData(data)` or `validateDatasetData(dataset, data)`
213
+
214
+ ### 3. Model-level linked data
215
+
216
+ Use `defineDataModel()` when datasets are related and you want the model to own
217
+ safe inference, validation, and reusable dashboard semantics:
218
+
219
+ ```tsx
220
+ import {
221
+ defineDataModel,
222
+ defineDataset,
223
+ } from '@matthieumordrel/chart-studio'
224
+
225
+ const hiringModel = defineDataModel()
226
+ .dataset('jobs', defineDataset<Job>()
227
+ .key('id')
228
+ .columns((c) => [
229
+ c.date('createdAt'),
230
+ c.category('status'),
231
+ c.number('salary', { format: 'currency' }),
232
+ ]))
233
+ .dataset('owners', defineDataset<Owner>()
234
+ .key('id')
235
+ .columns((c) => [
236
+ c.category('name', { label: 'Owner' }),
237
+ c.category('region'),
238
+ ]))
239
+ .infer({
240
+ relationships: true,
241
+ attributes: true,
242
+ })
243
+
244
+ const jobsByOwner = hiringModel.chart('jobsByOwner', (chart) =>
245
+ chart
246
+ .xAxis((x) => x.allowed('jobs.createdAt', 'jobs.owner.name').default('jobs.owner.name'))
247
+ .filters((f) => f.allowed('jobs.status', 'jobs.owner.region'))
248
+ .metric((m) =>
249
+ m
250
+ .aggregate('jobs.salary', 'avg')
251
+ .defaultAggregate('jobs.salary', 'avg'))
252
+ .chartType((t) => t.allowed('bar', 'line').default('bar'))
253
+ )
254
+
255
+ hiringModel.validateData({
256
+ jobs: jobsData,
257
+ owners: ownersData,
258
+ })
259
+ ```
260
+
261
+ What the model can infer today:
262
+
263
+ - obvious one-hop lookup relationships from one dataset into another
264
+ - reusable shared-filter attributes backed by those relationships
265
+ - safe lookup-preserving model chart fields such as `jobs.owner.name`
266
+ - the base dataset for a model chart when every qualified field is anchored to
267
+ the same dataset id
268
+
269
+ How to prepare data for safe inference:
270
+
271
+ - declare one real key per dataset with `.key(...)`; single-column lookup keys
272
+ work best
273
+ - for the common case, use lookup datasets keyed by `id` and foreign keys named
274
+ `<singularDatasetId>Id` such as `ownerId`, `teacherId`, or `customerId`
275
+ - if a lookup key is already named `somethingId`, that same field name can be
276
+ inferred as a foreign key candidate on related datasets
277
+ - give lookup datasets a visible label-like column such as `name`, `title`, or
278
+ `label` when you want inferred shared filters to feel good by default
279
+ - leaving a raw field out of `.columns(...)` is not exclusion; use
280
+ `exclude(...)` only when you want that field removed from the chart contract
281
+
282
+ Important limits of the current model layer:
283
+
284
+ - inference is conservative; ambiguous candidates are ignored until you declare
285
+ `.relationship(...)` or suppress a false positive with
286
+ `.infer({ exclude: ['datasetId.columnId'] })`
287
+ - many-to-many stays explicit through `association(...)`
288
+ - model-aware charts allow one lookup hop only; they do not infer row-expanding
289
+ traversal
290
+ - if you use unqualified field ids, or fields anchored to multiple datasets,
291
+ add `.from('datasetId')`
292
+ - `validateData(...)` hard-fails on duplicate declared keys, orphan foreign keys, and malformed association edges
293
+ - charts still execute against one flat dataset at a time
294
+ - lookup-preserving model charts compile into the same explicit runtime core;
295
+ expanded chart grains still require `model.materialize(...)`
296
+ - explicit `.relationship(...)`, `.attribute(...)`, and `.association(...)`
297
+ remain available when inference is not enough
298
+ - linked metrics do not exist yet
299
+
300
+ ### 4. Materialized views
301
+
302
+ Use `model.materialize(...)` when one chart truly needs a flat cross-dataset
303
+ analytic grain:
304
+
305
+ ```tsx
306
+ const jobsWithOwner = hiringModel.materialize('jobsWithOwner', (m) =>
307
+ m
308
+ .from('jobs')
309
+ .join('owner', { relationship: 'jobs.ownerId -> owners.id' })
310
+ .grain('job')
311
+ )
312
+
313
+ const rows = jobsWithOwner.materialize({
314
+ jobs: jobsData,
315
+ owners: ownersData,
316
+ })
317
+
318
+ const chart = useChart({
319
+ data: rows,
320
+ schema: jobsWithOwner
321
+ .chart('jobsByOwner')
322
+ .xAxis((x) => x.allowed('ownerName').default('ownerName'))
323
+ .groupBy((g) => g.allowed('ownerRegion').default('ownerRegion'))
324
+ .metric((m) => m.aggregate('salary', 'sum').defaultAggregate('salary', 'sum')),
325
+ })
326
+ ```
327
+
328
+ If you need a many-to-many chart grain such as `job-skill`, declare an explicit
329
+ `association(...)` on the model and then expand through it with
330
+ `.throughAssociation(...).grain(...)`.
331
+
332
+ Rules for materialized views:
333
+
334
+ - `materialize(...)` is explicit; the model does not become a hidden query engine
335
+ - prefer `model.chart(...)` for safe lookup-preserving fields; reach for `materialize(...)` when the chart grain actually changes
336
+ - `grain(...)` is required so the output row grain stays visible
337
+ - `.join(...)` is for lookup-style joins that preserve the base grain
338
+ - `.throughRelationship(...)` and `.throughAssociation(...)` are the explicit row-expanding paths
339
+ - many-to-many flattening stays visible because `association(...)` and `throughAssociation(...)` are both opt-in
340
+ - related-table columns reuse the linked dataset definitions, so you do not need repeated per-dataset derived columns like `ownerName`, `ownerRegion`, or `skillName`
341
+ - the materialized view is chartable like a normal dataset and exposes `materialize(data)` for explicit reuse and caching
342
+
343
+ ### 5. Dashboard composition
344
+
345
+ Use `defineDashboard(model)` when several reusable charts belong to one
346
+ dashboard:
347
+
348
+ ```tsx
349
+ import {
350
+ DashboardProvider,
351
+ defineDashboard,
352
+ useDashboard,
353
+ useDashboardChart,
354
+ } from '@matthieumordrel/chart-studio'
355
+
356
+ const hiringDashboard = defineDashboard(hiringModel)
357
+ .chart('jobsByOwner', jobsByOwner)
358
+ .sharedFilter('owner')
359
+ .build()
360
+
361
+ function HiringOverview() {
362
+ const dashboard = useDashboard({
363
+ definition: hiringDashboard,
364
+ data: {
365
+ jobs: jobsData,
366
+ owners: ownersData,
367
+ },
368
+ })
369
+
370
+ return (
371
+ <DashboardProvider dashboard={dashboard}>
372
+ <HiringChart />
373
+ </DashboardProvider>
374
+ )
375
+ }
376
+
377
+ function HiringChart() {
378
+ const jobsChart = useDashboardChart(hiringDashboard, 'jobsByOwner')
379
+
380
+ return (
381
+ <Chart chart={jobsChart}>
382
+ <ChartCanvas />
383
+ </Chart>
384
+ )
385
+ }
386
+ ```
387
+
388
+ Rules for dashboard composition:
389
+
390
+ - `defineDashboard(model)` is intentionally thin: chart registration, shared-filter selection, and optional dashboard-local shared filters
391
+ - dashboard charts may come from `dataset.chart(...)`, `model.chart(...)`, or `model.materialize(...).chart(...)`
392
+ - chart registration is explicit by id
393
+ - `useDashboard(...)` is the runtime boundary; it resolves model-aware charts and explicit materialized views against real data
394
+ - pass the explicit dashboard runtime into dashboard hooks, or inside a matching `DashboardProvider` pass the dashboard definition
395
+ - `useDashboardChart(...)` resolves the reusable chart by id and keeps React in charge of placement
396
+ - `useDashboardDataset(...)` exposes the globally filtered rows for non-chart consumers like KPI cards or tables
397
+
398
+ ### 6. Shared dashboard filters
399
+
400
+ Shared dashboard filters layer on top of dashboard composition.
401
+
402
+ Reuse model-level relationship semantics when the same filter concept should
403
+ work across several dashboards:
404
+
405
+ ```tsx
406
+ const dashboard = defineDashboard(hiringModel)
407
+ .chart('jobsByOwner', jobsByOwner)
408
+ .sharedFilter('owner')
409
+ ```
410
+
411
+ Add one-off dashboard-local filters when the concept is specific to one
412
+ dashboard:
413
+
414
+ ```tsx
415
+ const dashboard = defineDashboard(hiringModel)
416
+ .chart('jobsByOwner', jobsByOwner)
417
+ .sharedFilter('status', {
418
+ kind: 'select',
419
+ source: { dataset: 'jobs', column: 'status' },
420
+ })
421
+ .sharedFilter('activityDate', {
422
+ kind: 'date-range',
423
+ targets: [
424
+ { dataset: 'jobs', column: 'createdAt' },
425
+ ],
426
+ })
427
+ ```
428
+
429
+ Rules for shared dashboard filters:
430
+
431
+ - `sharedFilter('owner')` can reuse either an inferred model attribute or an explicit one
432
+ - the model may infer useful attributes, but the dashboard still decides which ones become visible shared filters
433
+ - shared filters are explicit; nothing is guessed from chart configs
434
+ - shared filters narrow dataset slices before chart-local `useChart(...)` filters run
435
+ - local and global filters compose by intersection
436
+ - when a shared filter targets the same chart-local column, the dashboard owns that filter for that chart by default
437
+ - cross-dataset ambiguity requires an explicit model `attribute(...)` or explicit target choice
438
+
104
439
  ## Column Types
105
440
 
106
441
  | Type | What it is for |
@@ -112,20 +447,21 @@ export function JobsChart() {
112
447
 
113
448
  ## Declarative Schema and Control Restrictions
114
449
 
115
- If you want to expose only a subset of groupings, metrics, chart types, or axes, use the fluent `defineChartSchema<Row>()` builder:
450
+ If you want to expose only a subset of groupings, metrics, chart types, or axes, use the fluent `defineDataset<Row>().chart(...)` builder:
116
451
 
117
452
  ```tsx
118
- import { defineChartSchema, useChart } from '@matthieumordrel/chart-studio'
453
+ import { defineDataset, useChart } from '@matthieumordrel/chart-studio'
119
454
 
120
455
  type Row = { periodEnd: string; segment: string; revenue: number; netIncome: number }
121
456
 
122
- const schema = defineChartSchema<Row>()
457
+ const schema = defineDataset<Row>()
123
458
  .columns((c) => [
124
459
  c.date('periodEnd', { label: 'Period End' }),
125
460
  c.category('segment'),
126
461
  c.number('revenue'),
127
462
  c.number('netIncome')
128
463
  ])
464
+ .chart()
129
465
  .xAxis((x) => x.allowed('periodEnd'))
130
466
  .groupBy((g) => g.allowed('segment'))
131
467
  .metric((m) =>
@@ -153,7 +489,7 @@ Why this pattern:
153
489
  If you want to render your own UI or your own charting library, use only the core state:
154
490
 
155
491
  ```tsx
156
- import { defineChartSchema, useChart } from '@matthieumordrel/chart-studio'
492
+ import { defineDataset, useChart } from '@matthieumordrel/chart-studio'
157
493
 
158
494
  type Job = {
159
495
  dateAdded: string
@@ -161,12 +497,13 @@ type Job = {
161
497
  salary: number
162
498
  }
163
499
 
164
- const jobSchema = defineChartSchema<Job>()
500
+ const jobSchema = defineDataset<Job>()
165
501
  .columns((c) => [
166
502
  c.date('dateAdded', { label: 'Date Added' }),
167
503
  c.category('ownerName', { label: 'Consultant' }),
168
504
  c.number('salary', { label: 'Salary' })
169
505
  ])
506
+ .chart()
170
507
 
171
508
  export function JobsChartHeadless({ data }: { data: Job[] }) {
172
509
  const chart = useChart({ data, schema: jobSchema })
@@ -322,10 +659,23 @@ Only for the UI layer. The headless core does not require it.
322
659
 
323
660
  ### Can I use multiple datasets?
324
661
 
325
- Yes:
662
+ Yes, but there are two different meanings:
663
+
664
+ - independent datasets: use separate `useChart(...)` calls, or `useChart({ sources: [...] })` when one chart should switch between flat sources
665
+ - related datasets: use `defineDataModel().dataset(...).infer(...)`, then `model.chart(...)` for safe lookup-preserving charts
666
+ - explicit expanded grains: use `model.materialize(...)`
667
+ - screen-level composition and shared state: use `defineDashboard(model)` plus `useDashboard(...)`
668
+
669
+ The current chart runtime still executes one flat dataset at a time. Multi-source
670
+ source-switching is separate from linked data models, explicit materialized
671
+ views, and dashboard composition.
672
+
673
+ If you are starting fresh with a dashboard, use the model-first path:
674
+ `defineDataModel(...)`, `model.chart(...)` / `model.materialize(...)`,
675
+ `defineDashboard(...)`, and `useDashboard(...)`.
326
676
 
327
677
  ```tsx
328
- import { defineChartSchema, useChart } from '@matthieumordrel/chart-studio'
678
+ import { defineDataset, useChart } from '@matthieumordrel/chart-studio'
329
679
 
330
680
  const chart = useChart({
331
681
  sources: [
@@ -333,14 +683,47 @@ const chart = useChart({
333
683
  id: 'jobs',
334
684
  label: 'Jobs',
335
685
  data: jobs,
336
- schema: defineChartSchema<Job>()
686
+ schema: defineDataset<Job>()
337
687
  .columns((c) => [c.date('dateAdded', { label: 'Date Added' })])
688
+ .chart()
338
689
  },
339
690
  { id: 'candidates', label: 'Candidates', data: candidates }
340
691
  ]
341
692
  })
342
693
  ```
343
694
 
695
+ Each source may use `defineDataset<Row>().chart(...)`. The chart still reads one
696
+ active source at a time, so this is not dashboard composition and not
697
+ cross-dataset execution.
698
+
699
+ ### Can outside state drive one chart's filters or date range?
700
+
701
+ Yes. Use `inputs` for externally controlled data-scope state:
702
+
703
+ ```tsx
704
+ const chart = useChart({
705
+ data: jobs,
706
+ schema,
707
+ inputs: {
708
+ filters,
709
+ onFiltersChange: setFilters,
710
+ referenceDateId,
711
+ onReferenceDateIdChange: setReferenceDateId,
712
+ dateRange,
713
+ onDateRangeChange: setDateRange
714
+ }
715
+ })
716
+ ```
717
+
718
+ Rules:
719
+
720
+ - `inputs` only covers data-scope state: filters, reference date, and date range
721
+ - presentation controls such as `xAxis`, `groupBy`, `metric`, and `chartType` stay chart-local
722
+ - `dateRange` is `{ preset, customFilter }`
723
+ - when an input is controlled, chart setters request changes through the matching callback
724
+ - `chart.filters` and related date state are always the sanitized effective state for the active source
725
+ - this still does not create a dashboard runtime or shared state between charts; use `defineDashboard()` for that
726
+
344
727
  ### What chart types are available?
345
728
 
346
729
  - date X-axis: `bar`, `line`, `area`
@@ -382,11 +765,18 @@ There is currently no built-in support for drill-down, click-to-filter, brush se
382
765
 
383
766
  ### Multi-dataset composition
384
767
 
385
- Each chart instance operates on a single flat dataset. Overlaying series from different schemas (e.g. revenue on the left Y-axis and headcount on the right) would require separate chart instances today. Dual-axis and cross-dataset composition are not yet supported.
768
+ Dashboard composition and shared dashboard filters are now available, but each
769
+ chart instance still operates on one flat dataset at a time. Overlaying series
770
+ from different schemas (e.g. revenue on the left Y-axis and headcount on the
771
+ right) would require separate chart instances today. Dual-axis cross-dataset
772
+ execution, automatic denormalization, and linked metrics are not yet supported.
386
773
 
387
774
  ### Schema Builder Ergonomics
388
775
 
389
- `defineChartSchema<Row>()` now returns one fluent builder that you pass directly to `useChart(...)` or `inferColumnsFromData(...)`. That keeps the public API strongly typed while improving IntelliSense for raw field ids, derived columns, and control restrictions.
776
+ `defineDataset<Row>()` owns the reusable `.columns(...)` contract, and
777
+ `defineDataset<Row>().chart(...)` is the single explicit path for declaring a
778
+ chart schema. Both feed the same chart-definition surface that you pass directly
779
+ to `useChart(...)` or `inferColumnsFromData(...)`.
390
780
 
391
781
  ## Release
392
782
 
@@ -0,0 +1,141 @@
1
+ import { isSameMetric, normalizeMetricAllowances } from "./metric-utils.mjs";
2
+ //#region src/core/chart-builder-controls.ts
3
+ const SELECTABLE_CONTROL_CONFIG = Symbol("chart-schema-selectable-control-config");
4
+ const METRIC_CONTROL_CONFIG = Symbol("chart-schema-metric-config");
5
+ function uniqueValues(values) {
6
+ if (!values || values.length === 0) return;
7
+ return [...new Set(values)];
8
+ }
9
+ function sanitizeSelectableControlConfig(config, supportsDefault) {
10
+ const allowed = uniqueValues(config.allowed);
11
+ let hidden = uniqueValues(config.hidden);
12
+ if (allowed && hidden) {
13
+ const allowedSet = new Set(allowed);
14
+ hidden = hidden.filter((option) => allowedSet.has(option));
15
+ }
16
+ let nextDefault = supportsDefault ? config.default : void 0;
17
+ if (nextDefault !== void 0) {
18
+ if (allowed && !allowed.includes(nextDefault)) nextDefault = void 0;
19
+ if (nextDefault !== void 0 && hidden?.includes(nextDefault)) nextDefault = void 0;
20
+ }
21
+ const nextConfig = {};
22
+ if (allowed && allowed.length > 0) nextConfig.allowed = allowed;
23
+ if (hidden && hidden.length > 0) nextConfig.hidden = hidden;
24
+ if (nextDefault !== void 0) nextConfig.default = nextDefault;
25
+ return nextConfig;
26
+ }
27
+ function createSelectableControlBuilder(config = {}, supportsDefault) {
28
+ const nextConfig = sanitizeSelectableControlConfig(config, supportsDefault);
29
+ return {
30
+ allowed(...options) {
31
+ return createSelectableControlBuilder({
32
+ ...nextConfig,
33
+ allowed: options
34
+ }, supportsDefault);
35
+ },
36
+ hidden(...options) {
37
+ return createSelectableControlBuilder({
38
+ ...nextConfig,
39
+ hidden: [...nextConfig.hidden ?? [], ...options]
40
+ }, supportsDefault);
41
+ },
42
+ default(option) {
43
+ return createSelectableControlBuilder({
44
+ ...nextConfig,
45
+ default: option
46
+ }, supportsDefault);
47
+ },
48
+ [SELECTABLE_CONTROL_CONFIG]: nextConfig
49
+ };
50
+ }
51
+ function uniqueMetrics(metrics) {
52
+ if (!metrics || metrics.length === 0) return;
53
+ const unique = [];
54
+ for (const metric of metrics) if (!unique.some((candidate) => isSameMetric(candidate, metric))) unique.push(metric);
55
+ return unique;
56
+ }
57
+ function sanitizeMetricConfig(config) {
58
+ const allowed = config.allowed && config.allowed.length > 0 ? [...config.allowed] : void 0;
59
+ let hidden = uniqueMetrics(config.hidden);
60
+ const expandedAllowed = normalizeMetricAllowances(allowed);
61
+ if (expandedAllowed && hidden) hidden = hidden.filter((metric) => expandedAllowed.some((allowedMetric) => isSameMetric(allowedMetric, metric)));
62
+ let nextDefault = config.default;
63
+ if (nextDefault) {
64
+ const defaultMetric = nextDefault;
65
+ if (expandedAllowed && !expandedAllowed.some((metric) => isSameMetric(metric, defaultMetric))) nextDefault = void 0;
66
+ if (nextDefault) {
67
+ const visibleDefault = nextDefault;
68
+ if (hidden?.some((metric) => isSameMetric(metric, visibleDefault))) nextDefault = void 0;
69
+ }
70
+ }
71
+ const nextConfig = {};
72
+ if (allowed && allowed.length > 0) nextConfig.allowed = allowed;
73
+ if (hidden && hidden.length > 0) nextConfig.hidden = hidden;
74
+ if (nextDefault) nextConfig.default = nextDefault;
75
+ return nextConfig;
76
+ }
77
+ function createMetricBuilder(config = {}) {
78
+ const nextConfig = sanitizeMetricConfig(config);
79
+ return {
80
+ count() {
81
+ return createMetricBuilder({
82
+ ...nextConfig,
83
+ allowed: [...nextConfig.allowed ?? [], { kind: "count" }]
84
+ });
85
+ },
86
+ aggregate(columnId, firstAggregate, ...restAggregates) {
87
+ const aggregates = [firstAggregate, ...restAggregates];
88
+ const selection = restAggregates.length === 0 ? firstAggregate : aggregates;
89
+ return createMetricBuilder({
90
+ ...nextConfig,
91
+ allowed: [...nextConfig.allowed ?? [], {
92
+ kind: "aggregate",
93
+ columnId,
94
+ aggregate: selection
95
+ }]
96
+ });
97
+ },
98
+ hideCount() {
99
+ return createMetricBuilder({
100
+ ...nextConfig,
101
+ hidden: [...nextConfig.hidden ?? [], { kind: "count" }]
102
+ });
103
+ },
104
+ hideAggregate(columnId, firstAggregate, ...restAggregates) {
105
+ const aggregates = [firstAggregate, ...restAggregates];
106
+ return createMetricBuilder({
107
+ ...nextConfig,
108
+ hidden: [...nextConfig.hidden ?? [], ...aggregates.map((aggregate) => ({
109
+ kind: "aggregate",
110
+ columnId,
111
+ aggregate
112
+ }))]
113
+ });
114
+ },
115
+ defaultCount() {
116
+ return createMetricBuilder({
117
+ ...nextConfig,
118
+ default: { kind: "count" }
119
+ });
120
+ },
121
+ defaultAggregate(columnId, aggregate) {
122
+ return createMetricBuilder({
123
+ ...nextConfig,
124
+ default: {
125
+ kind: "aggregate",
126
+ columnId,
127
+ aggregate
128
+ }
129
+ });
130
+ },
131
+ [METRIC_CONTROL_CONFIG]: nextConfig
132
+ };
133
+ }
134
+ function getSelectableControlConfig(builder) {
135
+ return builder[SELECTABLE_CONTROL_CONFIG];
136
+ }
137
+ function getMetricBuilderConfig(builder) {
138
+ return builder[METRIC_CONTROL_CONFIG];
139
+ }
140
+ //#endregion
141
+ export { createMetricBuilder, createSelectableControlBuilder, getMetricBuilderConfig, getSelectableControlConfig };