@matthieumordrel/chart-studio 0.2.4 → 0.2.5

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 CHANGED
@@ -22,6 +22,10 @@ You get:
22
22
  - transformed chart data
23
23
  - filtering, grouping, metrics, and time bucketing logic
24
24
 
25
+ Requirements:
26
+
27
+ - `react` >= 18.2.0
28
+
25
29
  Install:
26
30
 
27
31
  ```bash
@@ -46,6 +50,12 @@ You get:
46
50
  - `<ChartCanvas>`
47
51
  - granular UI controls from `@matthieumordrel/chart-studio/ui`
48
52
 
53
+ Requirements:
54
+
55
+ - `react` >= 18.2.0
56
+ - `recharts` >= 3.0.0 (v2 is **not** supported)
57
+ - `lucide-react` >= 0.577.0 (optional, for toolbar icons)
58
+
49
59
  Install:
50
60
 
51
61
  ```bash
@@ -88,7 +98,7 @@ export function JobsChart() {
88
98
  ## How It Works
89
99
 
90
100
  1. Pass your raw data to `useChart()`.
91
- 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 `defineChartSchema<Row>()...` when you need labels, type overrides, derived columns, or control restrictions (allowed metrics, groupings, chart types, etc.).
92
102
  3. Either render your own UI from the returned state, or use the components from `@matthieumordrel/chart-studio/ui`.
93
103
 
94
104
  ## Column Types
@@ -102,41 +112,39 @@ export function JobsChart() {
102
112
 
103
113
  ## Declarative Schema and Control Restrictions
104
114
 
105
- If you want to expose only a subset of groupings, metrics, chart types, or axes, use `defineChartSchema<Row>()()` with the control sections:
115
+ If you want to expose only a subset of groupings, metrics, chart types, or axes, use the fluent `defineChartSchema<Row>()` builder:
106
116
 
107
117
  ```tsx
108
118
  import { defineChartSchema, useChart } from '@matthieumordrel/chart-studio'
109
119
 
110
120
  type Row = { periodEnd: string; segment: string; revenue: number; netIncome: number }
111
121
 
112
- const schema = defineChartSchema<Row>()({
113
- columns: {
114
- periodEnd: { type: 'date', label: 'Period End' },
115
- segment: { type: 'category' },
116
- revenue: { type: 'number' },
117
- netIncome: { type: 'number' }
118
- },
119
- xAxis: { allowed: ['periodEnd'] },
120
- groupBy: { allowed: ['segment'] },
121
- metric: {
122
- allowed: [
123
- { kind: 'count' },
124
- { kind: 'aggregate', columnId: 'revenue', aggregate: ['sum', 'avg'] },
125
- { kind: 'aggregate', columnId: 'netIncome', aggregate: 'sum' }
126
- ]
127
- },
128
- chartType: { allowed: ['bar', 'line'] },
129
- timeBucket: { allowed: ['year', 'quarter', 'month'] }
130
- })
122
+ const schema = defineChartSchema<Row>()
123
+ .columns((c) => [
124
+ c.date('periodEnd', { label: 'Period End' }),
125
+ c.category('segment'),
126
+ c.number('revenue'),
127
+ c.number('netIncome')
128
+ ])
129
+ .xAxis((x) => x.allowed('periodEnd'))
130
+ .groupBy((g) => g.allowed('segment'))
131
+ .metric((m) =>
132
+ m
133
+ .count()
134
+ .aggregate('revenue', 'sum', 'avg')
135
+ .aggregate('netIncome', 'sum')
136
+ )
137
+ .chartType((t) => t.allowed('bar', 'line'))
138
+ .timeBucket((tb) => tb.allowed('year', 'quarter', 'month'))
131
139
 
132
140
  const chart = useChart({ data, schema })
133
141
  ```
134
142
 
135
143
  Why this pattern:
136
144
 
137
- - `columns` defines types, labels, and formats for raw fields; use `false` to exclude a column from the chart
138
- - Derived columns use `{ kind: 'derived', type, label?, accessor, format? }` for computed values from each row
139
- - `xAxis`, `groupBy`, `metric`, `chartType`, `timeBucket` restrict the allowed options
145
+ - `columns` defines types, labels, and formats for raw fields; use `c.exclude(...)` to remove a column from the chart
146
+ - Derived columns use `c.derived.*(...)` helpers for computed values from each row
147
+ - `xAxis`, `groupBy`, `metric`, `chartType`, and `timeBucket` restrict the allowed options
140
148
  - invalid column IDs and config keys are rejected at compile time
141
149
  - metric restrictions preserve the order you declare, so the first allowed metric becomes the default
142
150
 
@@ -153,13 +161,12 @@ type Job = {
153
161
  salary: number
154
162
  }
155
163
 
156
- const jobSchema = defineChartSchema<Job>()({
157
- columns: {
158
- dateAdded: { type: 'date', label: 'Date Added' },
159
- ownerName: { type: 'category', label: 'Consultant' },
160
- salary: { type: 'number', label: 'Salary' }
161
- }
162
- })
164
+ const jobSchema = defineChartSchema<Job>()
165
+ .columns((c) => [
166
+ c.date('dateAdded', { label: 'Date Added' }),
167
+ c.category('ownerName', { label: 'Consultant' }),
168
+ c.number('salary', { label: 'Salary' })
169
+ ])
163
170
 
164
171
  export function JobsChartHeadless({ data }: { data: Job[] }) {
165
172
  const chart = useChart({ data, schema: jobSchema })
@@ -298,12 +305,6 @@ Minimal example:
298
305
  }
299
306
  ```
300
307
 
301
- ## Compatibility
302
-
303
- - `react`: `>=18.2.0 <20`
304
- - `recharts`: `>=3.0.0 <4` for the UI layer
305
- - `lucide-react`: `>=0.577.0 <1` for the UI layer
306
-
307
308
  ## Common Questions
308
309
 
309
310
  ### Which import path should I use?
@@ -332,9 +333,8 @@ const chart = useChart({
332
333
  id: 'jobs',
333
334
  label: 'Jobs',
334
335
  data: jobs,
335
- schema: defineChartSchema<Job>()({
336
- columns: { dateAdded: { type: 'date', label: 'Date Added' } }
337
- })
336
+ schema: defineChartSchema<Job>()
337
+ .columns((c) => [c.date('dateAdded', { label: 'Date Added' })])
338
338
  },
339
339
  { id: 'candidates', label: 'Candidates', data: candidates }
340
340
  ]
@@ -384,9 +384,9 @@ There is currently no built-in support for drill-down, click-to-filter, brush se
384
384
 
385
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.
386
386
 
387
- ### The double-call schema syntax
387
+ ### Schema Builder Ergonomics
388
388
 
389
- `defineChartSchema<Row>()()` uses a double function call as a workaround for TypeScript's lack of partial type argument inference. This lets you provide the row type explicitly while the column IDs are inferred automatically. It works well but can surprise newcomers — this will be revisited if TypeScript adds native support for partial inference.
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.
390
390
 
391
391
  ## Release
392
392
 
@@ -50,9 +50,10 @@ function restrictConfiguredValues(values, config, fallbackToBaseIfEmpty = false)
50
50
  /**
51
51
  * Resolve one primitive selection against the current option list.
52
52
  */
53
- function resolveConfiguredValue(currentValue, values, configuredDefault) {
54
- if (values.includes(currentValue)) return currentValue;
53
+ function resolveConfiguredValue(currentValue, values, configuredDefault, globalDefault) {
54
+ if (currentValue !== null && values.includes(currentValue)) return currentValue;
55
55
  if (configuredDefault && values.includes(configuredDefault)) return configuredDefault;
56
+ if (globalDefault && values.includes(globalDefault)) return globalDefault;
56
57
  return values[0] ?? currentValue;
57
58
  }
58
59
  /**
@@ -1,106 +1,38 @@
1
- import { ChartTypeConfig, DefinedChartSchema, ExactShape, FiltersConfig, GroupByConfig, MetricConfig, ResolvedFilterColumnIdFromSchema, ResolvedGroupByColumnIdFromSchema, ResolvedMetricColumnIdFromSchema, ResolvedXAxisColumnIdFromSchema, SchemaColumnsValidationShape, TimeBucketConfig, XAxisConfig } from "./types.mjs";
1
+ import { ChartSchemaBuilder } from "./schema-builder.types.mjs";
2
2
 
3
3
  //#region src/core/define-chart-schema.d.ts
4
- type SchemaFromSections<T, TColumns extends Record<string, unknown> | undefined, TXAxis, TGroupBy, TFilters, TMetric, TChartType, TTimeBucket> = {
5
- columns?: Extract<TColumns, Record<string, unknown> | undefined>;
6
- xAxis?: Extract<TXAxis, XAxisConfig<ResolvedXAxisColumnIdFromSchema<T, {
7
- columns?: TColumns;
8
- }>> | undefined>;
9
- groupBy?: Extract<TGroupBy, GroupByConfig<ResolvedGroupByColumnIdFromSchema<T, {
10
- columns?: TColumns;
11
- }>> | undefined>;
12
- filters?: Extract<TFilters, FiltersConfig<ResolvedFilterColumnIdFromSchema<T, {
13
- columns?: TColumns;
14
- }>> | undefined>;
15
- metric?: Extract<TMetric, MetricConfig<ResolvedMetricColumnIdFromSchema<T, {
16
- columns?: TColumns;
17
- }>> | undefined>;
18
- chartType?: Extract<TChartType, ChartTypeConfig | undefined>;
19
- timeBucket?: Extract<TTimeBucket, TimeBucketConfig | undefined>;
20
- };
21
- type DefineChartSchemaInput<T, TColumns extends Record<string, unknown> | undefined, TXAxis, TGroupBy, TFilters, TMetric, TChartType, TTimeBucket> = {
22
- /**
23
- * Shape the available chart columns.
24
- *
25
- * This is usually the most important part of the schema. Use it to:
26
- * - rename inferred raw fields with `label`
27
- * - force a field to a specific `type`
28
- * - apply `format` or `formatter`
29
- * - remove a raw field with `false`
30
- * - add brand new derived columns with `kind: 'derived'`
31
- */
32
- columns?: TColumns & ExactShape<SchemaColumnsValidationShape<T, NoInfer<TColumns>>, NoInfer<TColumns>>;
33
- /**
34
- * Restrict which resolved columns may be selected on the X-axis.
35
- *
36
- * Use this when you want to expose only a subset of possible X-axis fields.
37
- */
38
- xAxis?: TXAxis & ExactShape<XAxisConfig<ResolvedXAxisColumnIdFromSchema<T, {
39
- columns?: TColumns;
40
- }>>, NoInfer<TXAxis>>;
41
- /**
42
- * Restrict which resolved columns may be used to split the chart into series.
43
- *
44
- * This powers grouped / multi-series charts.
45
- */
46
- groupBy?: TGroupBy & ExactShape<GroupByConfig<ResolvedGroupByColumnIdFromSchema<T, {
47
- columns?: TColumns;
48
- }>>, NoInfer<TGroupBy>>;
49
- /**
50
- * Restrict which resolved columns appear in the filters UI.
51
- *
52
- * Only category and boolean-like columns are eligible here.
53
- */
54
- filters?: TFilters & ExactShape<FiltersConfig<ResolvedFilterColumnIdFromSchema<T, {
55
- columns?: TColumns;
56
- }>>, NoInfer<TFilters>>;
57
- /**
58
- * Restrict which metrics and aggregate combinations remain selectable.
59
- *
60
- * Use this when you want to curate the metric dropdown rather than exposing
61
- * every available numeric aggregate.
62
- */
63
- metric?: TMetric & ExactShape<MetricConfig<ResolvedMetricColumnIdFromSchema<T, {
64
- columns?: TColumns;
65
- }>>, NoInfer<TMetric>>; /** Restrict which chart renderers are available to the user. */
66
- chartType?: TChartType & ExactShape<ChartTypeConfig, NoInfer<TChartType>>;
67
- /**
68
- * Restrict which time buckets remain available for date X-axes.
69
- *
70
- * Example: allow only `'month'` and `'quarter'`.
71
- */
72
- timeBucket?: TTimeBucket & ExactShape<TimeBucketConfig, NoInfer<TTimeBucket>>;
73
- };
74
4
  /**
75
- * Define one explicit chart schema with strict exact-object checking.
5
+ * Define one explicit chart schema through a fluent builder API.
76
6
  *
77
- * The schema is the single advanced authoring surface for chart-studio:
78
- * `columns` can override or exclude inferred raw fields and also define derived
79
- * columns, while the top-level control sections restrict the public chart
80
- * contract.
7
+ * Put `.columns(...)` early in the chain so later sections can narrow against
8
+ * the declared column ids and roles.
81
9
  *
82
10
  * Typical shape:
83
11
  *
84
12
  * ```ts
85
- * const schema = defineChartSchema<Row>()({
86
- * columns: {
87
- * createdAt: {type: 'date', label: 'Created'},
88
- * revenue: {type: 'number', format: 'currency'},
89
- * margin: {
90
- * kind: 'derived',
91
- * type: 'number',
92
- * label: 'Margin',
93
- * format: 'percent',
94
- * accessor: row => row.profit / row.revenue,
95
- * },
96
- * },
97
- * xAxis: {allowed: ['createdAt']},
98
- * metric: {
99
- * allowed: [{kind: 'aggregate', columnId: 'revenue', aggregate: 'sum'}],
100
- * },
101
- * })
13
+ * const schema = defineChartSchema<Row>()
14
+ * .columns((c) => [
15
+ * c.date('createdAt', {label: 'Created'}),
16
+ * c.category('ownerName', {label: 'Owner'}),
17
+ * c.number('salary', {format: 'currency'}),
18
+ * c.exclude('internalId'),
19
+ * c.derived.category('salaryBand', {
20
+ * label: 'Salary Band',
21
+ * accessor: row => row.salary != null && row.salary > 100_000 ? 'High' : 'Base',
22
+ * }),
23
+ * ])
24
+ * .xAxis((x) => x.allowed('createdAt').default('createdAt'))
25
+ * .groupBy((g) => g.allowed('ownerName', 'salaryBand'))
26
+ * .metric((m) =>
27
+ * m
28
+ * .count()
29
+ * .aggregate('salary', 'sum', 'avg')
30
+ * .defaultAggregate('salary', 'sum')
31
+ * )
32
+ *
33
+ * // Pass the builder directly to useChart(...) or inferColumnsFromData(...).
102
34
  * ```
103
35
  */
104
- declare function defineChartSchema<T>(): <const TColumns extends Record<string, unknown> | undefined = undefined, const TXAxis = undefined, const TGroupBy = undefined, const TFilters = undefined, const TMetric = undefined, const TChartType = undefined, const TTimeBucket = undefined>(schema: DefineChartSchemaInput<T, TColumns, TXAxis, TGroupBy, TFilters, TMetric, TChartType, TTimeBucket>) => DefinedChartSchema<T, SchemaFromSections<T, TColumns, TXAxis, TGroupBy, TFilters, TMetric, TChartType, TTimeBucket>>;
36
+ declare function defineChartSchema<TRow>(): ChartSchemaBuilder<TRow, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined>;
105
37
  //#endregion
106
38
  export { defineChartSchema };
@@ -1,47 +1,39 @@
1
+ import { createChartSchemaBuilder } from "./schema-builder.mjs";
1
2
  //#region src/core/define-chart-schema.ts
2
3
  /**
3
- * Define one explicit chart schema with strict exact-object checking.
4
+ * Define one explicit chart schema through a fluent builder API.
4
5
  *
5
- * The schema is the single advanced authoring surface for chart-studio:
6
- * `columns` can override or exclude inferred raw fields and also define derived
7
- * columns, while the top-level control sections restrict the public chart
8
- * contract.
6
+ * Put `.columns(...)` early in the chain so later sections can narrow against
7
+ * the declared column ids and roles.
9
8
  *
10
9
  * Typical shape:
11
10
  *
12
11
  * ```ts
13
- * const schema = defineChartSchema<Row>()({
14
- * columns: {
15
- * createdAt: {type: 'date', label: 'Created'},
16
- * revenue: {type: 'number', format: 'currency'},
17
- * margin: {
18
- * kind: 'derived',
19
- * type: 'number',
20
- * label: 'Margin',
21
- * format: 'percent',
22
- * accessor: row => row.profit / row.revenue,
23
- * },
24
- * },
25
- * xAxis: {allowed: ['createdAt']},
26
- * metric: {
27
- * allowed: [{kind: 'aggregate', columnId: 'revenue', aggregate: 'sum'}],
28
- * },
29
- * })
12
+ * const schema = defineChartSchema<Row>()
13
+ * .columns((c) => [
14
+ * c.date('createdAt', {label: 'Created'}),
15
+ * c.category('ownerName', {label: 'Owner'}),
16
+ * c.number('salary', {format: 'currency'}),
17
+ * c.exclude('internalId'),
18
+ * c.derived.category('salaryBand', {
19
+ * label: 'Salary Band',
20
+ * accessor: row => row.salary != null && row.salary > 100_000 ? 'High' : 'Base',
21
+ * }),
22
+ * ])
23
+ * .xAxis((x) => x.allowed('createdAt').default('createdAt'))
24
+ * .groupBy((g) => g.allowed('ownerName', 'salaryBand'))
25
+ * .metric((m) =>
26
+ * m
27
+ * .count()
28
+ * .aggregate('salary', 'sum', 'avg')
29
+ * .defaultAggregate('salary', 'sum')
30
+ * )
31
+ *
32
+ * // Pass the builder directly to useChart(...) or inferColumnsFromData(...).
30
33
  * ```
31
34
  */
32
35
  function defineChartSchema() {
33
- /**
34
- * Brand one schema object while preserving its literal types.
35
- *
36
- * This is what lets the schema stay both strongly typed and editor-friendly
37
- * when it is later passed to `useChart(...)`.
38
- */
39
- return function defineSchema(schema) {
40
- return {
41
- ...schema,
42
- __chartSchemaBrand: "chart-schema-definition"
43
- };
44
- };
36
+ return createChartSchemaBuilder();
45
37
  }
46
38
  //#endregion
47
39
  export { defineChartSchema };
@@ -1,9 +1,9 @@
1
- import { ChartColumn, ChartSchema, ResolvedColumnIdFromSchema } from "./types.mjs";
1
+ import { ChartColumn, ChartSchemaDefinition, ResolvedChartSchemaFromDefinition, ResolvedColumnIdFromSchema } from "./types.mjs";
2
2
 
3
3
  //#region src/core/infer-columns.d.ts
4
4
  /**
5
5
  * Resolve chart columns directly from raw data and an optional explicit schema.
6
6
  */
7
- declare function inferColumnsFromData<T, const TSchema extends ChartSchema<T, any> | undefined = undefined>(data: readonly T[], schema?: TSchema): readonly ChartColumn<T, ResolvedColumnIdFromSchema<T, TSchema>>[];
7
+ declare function inferColumnsFromData<T, const TSchema extends ChartSchemaDefinition<T, any> | undefined = undefined>(data: readonly T[], schema?: TSchema): readonly ChartColumn<T, ResolvedColumnIdFromSchema<T, ResolvedChartSchemaFromDefinition<TSchema>>>[];
8
8
  //#endregion
9
9
  export { inferColumnsFromData };
@@ -1,3 +1,4 @@
1
+ import { resolveChartSchemaDefinition } from "./schema-builder.mjs";
1
2
  //#region src/core/infer-columns.ts
2
3
  const MAX_SAMPLE_COUNT = 50;
3
4
  const DATE_KEY_PATTERN = /(date|time|timestamp|created|updated|start|end|deadline|due|scheduled|posted|published|at)$/i;
@@ -463,7 +464,8 @@ function sortResolvedColumns(columns) {
463
464
  * Resolve chart columns directly from raw data and an optional explicit schema.
464
465
  */
465
466
  function inferColumnsFromData(data, schema) {
466
- const rawColumnSchema = getRawColumnSchemaMap(schema);
467
+ const resolvedSchema = resolveChartSchemaDefinition(schema);
468
+ const rawColumnSchema = getRawColumnSchemaMap(resolvedSchema);
467
469
  const fields = collectFieldKeys(data, rawColumnSchema);
468
470
  const rawFieldIds = new Set(fields);
469
471
  const resolvedColumns = [];
@@ -473,7 +475,7 @@ function inferColumnsFromData(data, schema) {
473
475
  const column = buildRawColumn(typedField, samples, rawColumnSchema?.[typedField]);
474
476
  if (column) resolvedColumns.push(column);
475
477
  }
476
- for (const [key, columnSchema] of getDerivedColumnSchemas(schema, rawFieldIds)) resolvedColumns.push(buildDerivedColumn(key, columnSchema));
478
+ for (const [key, columnSchema] of getDerivedColumnSchemas(resolvedSchema, rawFieldIds)) resolvedColumns.push(buildDerivedColumn(key, columnSchema));
477
479
  if (resolvedColumns.length === 0) warn("No inferable or explicit chart columns were found. Provide non-empty data or schema.columns.");
478
480
  return finalizeResolvedColumns(sortResolvedColumns(resolvedColumns));
479
481
  }
@@ -109,13 +109,21 @@ function restrictAvailableMetrics(metrics, config) {
109
109
  */
110
110
  function resolveMetric(metric, columns, availableMetrics, configuredDefaultMetric) {
111
111
  if (availableMetrics && availableMetrics.length > 0) {
112
- const selectedMetric = availableMetrics.find((candidate) => isSameMetric(candidate, metric));
113
- if (selectedMetric) return selectedMetric;
114
- return (configuredDefaultMetric ? availableMetrics.find((candidate) => isSameMetric(candidate, configuredDefaultMetric)) : void 0) ?? availableMetrics[0];
112
+ if (metric !== null) {
113
+ const selectedMetric = availableMetrics.find((candidate) => isSameMetric(candidate, metric));
114
+ if (selectedMetric) return selectedMetric;
115
+ }
116
+ const defaultMetric = configuredDefaultMetric ? availableMetrics.find((candidate) => isSameMetric(candidate, configuredDefaultMetric)) : void 0;
117
+ if (defaultMetric) return defaultMetric;
118
+ if (metric === null) {
119
+ const countMetric = availableMetrics.find((candidate) => candidate.kind === "count");
120
+ if (countMetric) return countMetric;
121
+ }
122
+ return availableMetrics[0];
115
123
  }
116
- if (!isAggregateMetric(metric)) return DEFAULT_METRIC;
124
+ if (metric === null || !isAggregateMetric(metric)) return DEFAULT_METRIC;
117
125
  if (!columns.find((candidate) => candidate.type === "number" && candidate.id === metric.columnId)) return DEFAULT_METRIC;
118
126
  return metric;
119
127
  }
120
128
  //#endregion
121
- export { DEFAULT_METRIC, buildAvailableMetrics, getMetricLabel, isAggregateMetric, isSameMetric, resolveMetric, restrictAvailableMetrics };
129
+ export { DEFAULT_METRIC, buildAvailableMetrics, getMetricLabel, isAggregateMetric, isSameMetric, normalizeMetricAllowances, resolveMetric, restrictAvailableMetrics };