@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.
- package/README.md +402 -12
- package/dist/core/chart-builder-controls.mjs +141 -0
- package/dist/core/dashboard.types.d.mts +220 -0
- package/dist/core/data-label-defaults.mjs +74 -0
- package/dist/core/data-model.types.d.mts +196 -0
- package/dist/core/dataset-builder.types.d.mts +51 -0
- package/dist/core/dataset-chart-metadata.d.mts +8 -0
- package/dist/core/dataset-chart-metadata.mjs +4 -0
- package/dist/core/date-range-presets.mjs +1 -1
- package/dist/core/define-dashboard.d.mts +8 -0
- package/dist/core/define-dashboard.mjs +156 -0
- package/dist/core/define-data-model.d.mts +11 -0
- package/dist/core/define-data-model.mjs +327 -0
- package/dist/core/define-dataset.d.mts +13 -0
- package/dist/core/define-dataset.mjs +111 -0
- package/dist/core/index.d.mts +17 -0
- package/dist/core/infer-columns.mjs +28 -2
- package/dist/core/materialized-view.mjs +580 -0
- package/dist/core/materialized-view.types.d.mts +223 -0
- package/dist/core/model-chart.mjs +242 -0
- package/dist/core/model-chart.types.d.mts +199 -0
- package/dist/core/model-inference.mjs +169 -0
- package/dist/core/model-inference.types.d.mts +71 -0
- package/dist/core/pipeline.mjs +32 -1
- package/dist/core/schema-builder.mjs +28 -158
- package/dist/core/schema-builder.types.d.mts +2 -49
- package/dist/core/types.d.mts +59 -8
- package/dist/core/use-chart-options.d.mts +35 -8
- package/dist/core/use-chart-resolvers.mjs +13 -3
- package/dist/core/use-chart.d.mts +16 -12
- package/dist/core/use-chart.mjs +136 -34
- package/dist/core/use-dashboard.d.mts +190 -0
- package/dist/core/use-dashboard.mjs +551 -0
- package/dist/index.d.mts +10 -3
- package/dist/index.mjs +5 -2
- package/dist/ui/chart-canvas.d.mts +11 -4
- package/dist/ui/chart-canvas.mjs +45 -34
- package/dist/ui/chart-context.d.mts +2 -0
- package/dist/ui/chart-context.mjs +2 -0
- package/dist/ui/chart-filters-panel.d.mts +1 -1
- package/dist/ui/chart-filters-panel.mjs +163 -37
- package/dist/ui/chart-group-by-selector.mjs +4 -4
- package/dist/ui/chart-time-bucket-selector.mjs +1 -1
- package/dist/ui/chart-toolbar-overflow.mjs +5 -13
- package/dist/ui/chart-toolbar.mjs +1 -1
- package/package.json +1 -1
- package/dist/core/define-chart-schema.d.mts +0 -38
- 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 `
|
|
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 `
|
|
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 `
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
`
|
|
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 };
|