@opendata-ai/openchart-engine 6.25.4 → 6.27.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/dist/index.d.ts +82 -4
- package/dist/index.js +1027 -76
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +33 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +6 -0
- package/src/__tests__/compile-chart.test.ts +301 -0
- package/src/__tests__/compound-labels.test.ts +147 -0
- package/src/charts/line/area.ts +1 -1
- package/src/charts/line/compute.ts +7 -1
- package/src/compile.ts +222 -17
- package/src/compiler/normalize.ts +83 -1
- package/src/compiler/types.ts +41 -1
- package/src/compiler/validate.ts +124 -5
- package/src/index.ts +16 -1
- package/src/layout/axes/ticks.ts +34 -2
- package/src/layout/axes.ts +36 -3
- package/src/layout/dimensions.ts +98 -5
- package/src/legend/compute.ts +6 -1
- package/src/sankey/compile-sankey.ts +1 -1
- package/src/tilemap/__tests__/compile-tilemap.test.ts +322 -0
- package/src/tilemap/compile-tilemap.ts +383 -0
- package/src/tilemap/layout.ts +172 -0
- package/src/tilemap/types.ts +32 -0
- package/src/transforms/__tests__/filter-relative.test.ts +202 -0
- package/src/transforms/__tests__/window.test.ts +286 -0
- package/src/transforms/filter.ts +108 -3
- package/src/transforms/index.ts +5 -1
- package/src/transforms/predicates.ts +39 -9
- package/src/transforms/window.ts +185 -0
package/src/compile.ts
CHANGED
|
@@ -77,6 +77,7 @@ import { computeLegend } from './legend/compute';
|
|
|
77
77
|
import { legendGap } from './legend/wrap';
|
|
78
78
|
import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
|
|
79
79
|
import { compileTableLayout } from './tables/compile-table';
|
|
80
|
+
import { compileTileMap as compileTileMapImpl } from './tilemap/compile-tilemap';
|
|
80
81
|
import { computeTooltipDescriptors } from './tooltips/compute';
|
|
81
82
|
import { runTransforms } from './transforms';
|
|
82
83
|
|
|
@@ -198,7 +199,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
198
199
|
|
|
199
200
|
// Resolve watermark: explicit spec value wins, then options fallback, then default true.
|
|
200
201
|
const rawWatermark = (expandedSpec as Record<string, unknown>).watermark;
|
|
201
|
-
|
|
202
|
+
let watermark = rawWatermark !== undefined ? chartSpec.watermark : (options.watermark ?? true);
|
|
202
203
|
|
|
203
204
|
// Run data transforms (filter, bin, calculate, timeUnit) before any other data processing.
|
|
204
205
|
// Transforms are defined on the expanded spec (which includes any auto-generated
|
|
@@ -222,10 +223,49 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
222
223
|
| Partial<
|
|
223
224
|
Record<
|
|
224
225
|
string,
|
|
225
|
-
{
|
|
226
|
+
{
|
|
227
|
+
chrome?: unknown;
|
|
228
|
+
labels?: unknown;
|
|
229
|
+
legend?: unknown;
|
|
230
|
+
annotations?: unknown;
|
|
231
|
+
animation?: unknown;
|
|
232
|
+
display?: unknown;
|
|
233
|
+
encoding?: unknown;
|
|
234
|
+
watermark?: unknown;
|
|
235
|
+
crosshair?: unknown;
|
|
236
|
+
}
|
|
226
237
|
>
|
|
227
238
|
>
|
|
228
239
|
| undefined;
|
|
240
|
+
|
|
241
|
+
// Build userExplicit descriptor BEFORE applying any overrides so we capture
|
|
242
|
+
// the union of "user wrote this at top-level" and "user wrote this in the
|
|
243
|
+
// active breakpoint override." Sparkline display mode reads this to decide
|
|
244
|
+
// whether to suppress chrome/axes/legend/etc. by default vs. respecting an
|
|
245
|
+
// explicit user opt-in. Precedence: explicit at any level wins.
|
|
246
|
+
const rawEncoding = rawSpec.encoding as
|
|
247
|
+
| { x?: { axis?: unknown }; y?: { axis?: unknown } }
|
|
248
|
+
| undefined;
|
|
249
|
+
const bpForExplicit = overrides?.[breakpoint];
|
|
250
|
+
const bpEncoding = bpForExplicit?.encoding as
|
|
251
|
+
| { x?: { axis?: unknown }; y?: { axis?: unknown } }
|
|
252
|
+
| undefined;
|
|
253
|
+
// chrome: {} (empty object) is not "explicit" — it's an idiom users write to
|
|
254
|
+
// silence defaults. Require at least one chrome key set to count as opt-in.
|
|
255
|
+
const hasChromeKeys = (v: unknown): boolean =>
|
|
256
|
+
!!v && typeof v === 'object' && Object.keys(v as Record<string, unknown>).length > 0;
|
|
257
|
+
const userExplicit = {
|
|
258
|
+
chrome: hasChromeKeys(rawSpec.chrome) || hasChromeKeys(bpForExplicit?.chrome),
|
|
259
|
+
legend: rawSpec.legend !== undefined || bpForExplicit?.legend !== undefined,
|
|
260
|
+
xAxis: rawEncoding?.x?.axis !== undefined || bpEncoding?.x?.axis !== undefined,
|
|
261
|
+
yAxis: rawEncoding?.y?.axis !== undefined || bpEncoding?.y?.axis !== undefined,
|
|
262
|
+
labels: rawSpec.labels !== undefined || bpForExplicit?.labels !== undefined,
|
|
263
|
+
animation: rawSpec.animation !== undefined || bpForExplicit?.animation !== undefined,
|
|
264
|
+
watermark: rawSpec.watermark !== undefined || bpForExplicit?.watermark !== undefined,
|
|
265
|
+
crosshair: rawSpec.crosshair !== undefined || bpForExplicit?.crosshair !== undefined,
|
|
266
|
+
};
|
|
267
|
+
chartSpec = { ...chartSpec, userExplicit };
|
|
268
|
+
|
|
229
269
|
if (overrides?.[breakpoint]) {
|
|
230
270
|
const bp = overrides[breakpoint]!;
|
|
231
271
|
if (bp.chrome) {
|
|
@@ -273,14 +313,138 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
273
313
|
// responsive strategy so they render inline instead of being stripped.
|
|
274
314
|
strategy = { ...strategy, annotationPosition: 'inline' };
|
|
275
315
|
}
|
|
316
|
+
// New override branches for sparkline mode and related fields:
|
|
317
|
+
if (bp.display !== undefined) {
|
|
318
|
+
chartSpec = {
|
|
319
|
+
...chartSpec,
|
|
320
|
+
display: bp.display as NormalizedChartSpec['display'],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (bp.encoding !== undefined) {
|
|
324
|
+
// Merge encoding so a breakpoint can flip on/off encoding.x.axis or
|
|
325
|
+
// encoding.y.axis (used by sparkline display mode to opt back in to
|
|
326
|
+
// axes at a specific breakpoint). Channels merge per-key, and `axis`
|
|
327
|
+
// and `scale` deep-merge one level so a breakpoint can set
|
|
328
|
+
// `axis: { title: 'foo' }` without dropping the base spec's
|
|
329
|
+
// `axis.tickCount` / `axis.format`.
|
|
330
|
+
const bpEnc = bp.encoding as Record<string, Record<string, unknown> | undefined>;
|
|
331
|
+
const mergedEncoding = { ...chartSpec.encoding } as Record<
|
|
332
|
+
string,
|
|
333
|
+
Record<string, unknown> | undefined
|
|
334
|
+
>;
|
|
335
|
+
const NESTED_CHANNEL_KEYS = ['axis', 'scale'];
|
|
336
|
+
for (const channel of Object.keys(bpEnc)) {
|
|
337
|
+
const baseCh = mergedEncoding[channel];
|
|
338
|
+
const bpCh = bpEnc[channel];
|
|
339
|
+
if (bpCh && baseCh) {
|
|
340
|
+
const merged: Record<string, unknown> = { ...baseCh, ...bpCh };
|
|
341
|
+
for (const key of NESTED_CHANNEL_KEYS) {
|
|
342
|
+
const baseNested = baseCh[key];
|
|
343
|
+
const bpNested = bpCh[key];
|
|
344
|
+
if (
|
|
345
|
+
baseNested &&
|
|
346
|
+
bpNested &&
|
|
347
|
+
typeof baseNested === 'object' &&
|
|
348
|
+
typeof bpNested === 'object' &&
|
|
349
|
+
!Array.isArray(baseNested) &&
|
|
350
|
+
!Array.isArray(bpNested)
|
|
351
|
+
) {
|
|
352
|
+
merged[key] = { ...baseNested, ...bpNested };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
mergedEncoding[channel] = merged;
|
|
356
|
+
} else if (bpCh) {
|
|
357
|
+
mergedEncoding[channel] = bpCh;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
chartSpec = {
|
|
361
|
+
...chartSpec,
|
|
362
|
+
encoding: mergedEncoding as unknown as NormalizedChartSpec['encoding'],
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (typeof bp.watermark === 'boolean') {
|
|
366
|
+
// Update the resolved watermark value used downstream. ChartSpec carries
|
|
367
|
+
// this in its normalized shape; the local `watermark` variable controls
|
|
368
|
+
// chrome computation and rendering.
|
|
369
|
+
watermark = bp.watermark;
|
|
370
|
+
chartSpec = { ...chartSpec, watermark };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Sparkline mode: default labels off. Mark renderers draw value labels per
|
|
375
|
+
// labels.density (default 'auto'), which fills tiny sparklines with text and
|
|
376
|
+
// is never what you want. Explicit user labels at any level wins via
|
|
377
|
+
// userExplicit.labels.
|
|
378
|
+
if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.labels) {
|
|
379
|
+
chartSpec = {
|
|
380
|
+
...chartSpec,
|
|
381
|
+
labels: { ...chartSpec.labels, density: 'none' },
|
|
382
|
+
};
|
|
276
383
|
}
|
|
277
384
|
|
|
278
385
|
// Resolve animation spec. Breakpoint override wins over base spec (matching
|
|
279
386
|
// chrome, labels, legend, and annotation override precedence).
|
|
280
|
-
|
|
387
|
+
// Precedence rule for sparkline mode: an explicit user animation at ANY
|
|
388
|
+
// level (top-level OR breakpoint) always wins, regardless of display mode.
|
|
389
|
+
// resolveAnimation handles the explicit-user value; the sparkline default-off
|
|
390
|
+
// behavior is applied below when no explicit value exists.
|
|
391
|
+
let rawAnimationSpec = ((overrides?.[breakpoint] as Record<string, unknown> | undefined)
|
|
281
392
|
?.animation ?? rawSpec.animation) as AnimationSpec | undefined;
|
|
393
|
+
if (rawAnimationSpec === undefined && chartSpec.display === 'sparkline') {
|
|
394
|
+
// Sparkline mode: animation defaults to false. User-explicit (top OR bp)
|
|
395
|
+
// already short-circuits this branch via userExplicit.animation.
|
|
396
|
+
rawAnimationSpec = false;
|
|
397
|
+
}
|
|
398
|
+
// Sparkline mode: when animation is on but the user didn't specify duration,
|
|
399
|
+
// bump to 1100ms so the line/area reveal feels paced rather than mechanical.
|
|
400
|
+
// The CSS override pairs this with an expo-out easing curve. AnimationConfig
|
|
401
|
+
// nests duration under `enter`, so we set it there.
|
|
402
|
+
if (
|
|
403
|
+
chartSpec.display === 'sparkline' &&
|
|
404
|
+
rawAnimationSpec !== false &&
|
|
405
|
+
rawAnimationSpec !== undefined
|
|
406
|
+
) {
|
|
407
|
+
const SPARK_DURATION = 1100;
|
|
408
|
+
if (rawAnimationSpec === true) {
|
|
409
|
+
rawAnimationSpec = { enter: { duration: SPARK_DURATION } } as AnimationSpec;
|
|
410
|
+
} else if (typeof rawAnimationSpec === 'object') {
|
|
411
|
+
const cfg = rawAnimationSpec as { enter?: unknown; annotationDelay?: number };
|
|
412
|
+
const enter = cfg.enter;
|
|
413
|
+
if (enter === undefined || enter === true) {
|
|
414
|
+
rawAnimationSpec = {
|
|
415
|
+
...cfg,
|
|
416
|
+
enter: { duration: SPARK_DURATION },
|
|
417
|
+
} as AnimationSpec;
|
|
418
|
+
} else if (
|
|
419
|
+
typeof enter === 'object' &&
|
|
420
|
+
enter !== null &&
|
|
421
|
+
(enter as { duration?: number }).duration === undefined
|
|
422
|
+
) {
|
|
423
|
+
rawAnimationSpec = {
|
|
424
|
+
...cfg,
|
|
425
|
+
enter: { ...(enter as object), duration: SPARK_DURATION },
|
|
426
|
+
} as AnimationSpec;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
282
430
|
const resolvedAnimation = resolveAnimation(rawAnimationSpec);
|
|
283
431
|
|
|
432
|
+
// Crosshair: explicit user value at any level wins. In sparkline mode the
|
|
433
|
+
// default is off, otherwise default is off too (crosshair is opt-in). The
|
|
434
|
+
// value is plumbed through ChartLayout so the renderer doesn't need to
|
|
435
|
+
// re-inspect the raw spec.
|
|
436
|
+
const rawCrosshair = (bpForExplicit?.crosshair ?? rawSpec.crosshair) as boolean | undefined;
|
|
437
|
+
const crosshair =
|
|
438
|
+
chartSpec.display === 'sparkline' && !chartSpec.userExplicit.crosshair
|
|
439
|
+
? false
|
|
440
|
+
: rawCrosshair === true;
|
|
441
|
+
|
|
442
|
+
// Watermark default-off in sparkline mode unless user-explicit.
|
|
443
|
+
if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.watermark) {
|
|
444
|
+
watermark = false;
|
|
445
|
+
chartSpec = { ...chartSpec, watermark: false };
|
|
446
|
+
}
|
|
447
|
+
|
|
284
448
|
// Resolve theme: merge spec-level theme with options-level overrides
|
|
285
449
|
const mergedThemeConfig = options.theme
|
|
286
450
|
? { ...chartSpec.theme, ...options.theme }
|
|
@@ -310,7 +474,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
310
474
|
// the reserved margin. This way computeLegend positions the legend outside
|
|
311
475
|
// the data area (in the margin) instead of overlapping data marks.
|
|
312
476
|
const legendArea: Rect = { ...chartArea };
|
|
313
|
-
if (legendLayout.entries.length > 0) {
|
|
477
|
+
if ('entries' in legendLayout && legendLayout.entries.length > 0) {
|
|
314
478
|
const gap = legendGap(options.width);
|
|
315
479
|
switch (legendLayout.position) {
|
|
316
480
|
case 'top':
|
|
@@ -364,10 +528,19 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
364
528
|
// Arc charts (pie/donut) don't use axes or gridlines
|
|
365
529
|
const isRadial = chartSpec.markType === 'arc';
|
|
366
530
|
|
|
367
|
-
// Compute axes (skip for radial charts)
|
|
531
|
+
// Compute axes (skip for radial charts).
|
|
532
|
+
// Sparkline mode skips axes by default unless the user explicitly opted into
|
|
533
|
+
// an axis on a specific channel.
|
|
534
|
+
const skipX = chartSpec.display === 'sparkline' && !chartSpec.userExplicit.xAxis;
|
|
535
|
+
const skipY = chartSpec.display === 'sparkline' && !chartSpec.userExplicit.yAxis;
|
|
368
536
|
const axes = isRadial
|
|
369
537
|
? { x: undefined, y: undefined }
|
|
370
|
-
: computeAxes(scales, chartArea, strategy, theme, options.measureText
|
|
538
|
+
: computeAxes(scales, chartArea, strategy, theme, options.measureText, {
|
|
539
|
+
data: renderSpec.data,
|
|
540
|
+
encoding: renderSpec.encoding as Encoding,
|
|
541
|
+
skipX,
|
|
542
|
+
skipY,
|
|
543
|
+
});
|
|
371
544
|
|
|
372
545
|
// INVARIANT 2 — computeGridlines mutates `axes` in place. Downstream consumers read
|
|
373
546
|
// axes.y.gridlines off the same object. Do not introduce a copy-on-write.
|
|
@@ -460,6 +633,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
460
633
|
},
|
|
461
634
|
animation: resolvedAnimation,
|
|
462
635
|
watermark,
|
|
636
|
+
display: chartSpec.display,
|
|
637
|
+
crosshair,
|
|
463
638
|
measureText: options.measureText,
|
|
464
639
|
};
|
|
465
640
|
}
|
|
@@ -504,7 +679,8 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
|
|
|
504
679
|
|
|
505
680
|
const allMarks: Mark[] = [];
|
|
506
681
|
const seenLabels = new Set<string>();
|
|
507
|
-
const
|
|
682
|
+
const pLegend = primaryLayout.legend;
|
|
683
|
+
const mergedLegendEntries = 'entries' in pLegend ? [...pLegend.entries] : [];
|
|
508
684
|
for (const entry of mergedLegendEntries) {
|
|
509
685
|
seenLabels.add(entry.label);
|
|
510
686
|
}
|
|
@@ -522,10 +698,13 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
|
|
|
522
698
|
|
|
523
699
|
allMarks.push(...leafLayout.marks);
|
|
524
700
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
701
|
+
const leafLeg = leafLayout.legend;
|
|
702
|
+
if ('entries' in leafLeg) {
|
|
703
|
+
for (const entry of leafLeg.entries) {
|
|
704
|
+
if (!seenLabels.has(entry.label)) {
|
|
705
|
+
seenLabels.add(entry.label);
|
|
706
|
+
mergedLegendEntries.push(entry);
|
|
707
|
+
}
|
|
529
708
|
}
|
|
530
709
|
}
|
|
531
710
|
}
|
|
@@ -535,8 +714,8 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
|
|
|
535
714
|
marks: allMarks,
|
|
536
715
|
legend: {
|
|
537
716
|
...primaryLayout.legend,
|
|
538
|
-
entries: mergedLegendEntries,
|
|
539
|
-
},
|
|
717
|
+
...('entries' in pLegend ? { entries: mergedLegendEntries } : {}),
|
|
718
|
+
} as typeof primaryLayout.legend,
|
|
540
719
|
};
|
|
541
720
|
}
|
|
542
721
|
|
|
@@ -812,9 +991,12 @@ function compileLayerIndependent(
|
|
|
812
991
|
|
|
813
992
|
// Merge legend entries with deduplication
|
|
814
993
|
const seenLabels = new Set<string>();
|
|
815
|
-
const
|
|
994
|
+
const l0Legend = layout0.legend;
|
|
995
|
+
const l1Legend = layout1.legend;
|
|
996
|
+
const mergedLegendEntries = 'entries' in l0Legend ? [...l0Legend.entries] : [];
|
|
816
997
|
for (const entry of mergedLegendEntries) seenLabels.add(entry.label);
|
|
817
|
-
|
|
998
|
+
const l1Entries = 'entries' in l1Legend ? l1Legend.entries : [];
|
|
999
|
+
for (const entry of l1Entries) {
|
|
818
1000
|
if (!seenLabels.has(entry.label)) {
|
|
819
1001
|
seenLabels.add(entry.label);
|
|
820
1002
|
mergedLegendEntries.push(entry);
|
|
@@ -856,8 +1038,8 @@ function compileLayerIndependent(
|
|
|
856
1038
|
marks,
|
|
857
1039
|
legend: {
|
|
858
1040
|
...layout0.legend,
|
|
859
|
-
entries: mergedLegendEntries,
|
|
860
|
-
},
|
|
1041
|
+
...('entries' in l0Legend ? { entries: mergedLegendEntries } : {}),
|
|
1042
|
+
} as typeof layout0.legend,
|
|
861
1043
|
tooltipDescriptors: mergedTooltips,
|
|
862
1044
|
};
|
|
863
1045
|
}
|
|
@@ -1111,3 +1293,26 @@ export function compileSankey(
|
|
|
1111
1293
|
): import('@opendata-ai/openchart-core').SankeyLayout {
|
|
1112
1294
|
return compileSankeyImpl(spec, options);
|
|
1113
1295
|
}
|
|
1296
|
+
|
|
1297
|
+
// ---------------------------------------------------------------------------
|
|
1298
|
+
// TileMap compilation
|
|
1299
|
+
// ---------------------------------------------------------------------------
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Compile a tilemap spec into a TileMapLayout.
|
|
1303
|
+
*
|
|
1304
|
+
* Takes a raw tilemap spec, validates, normalizes, resolves theme and chrome,
|
|
1305
|
+
* computes tile positions, builds tile marks with colors and labels, and
|
|
1306
|
+
* returns a TileMapLayout ready for rendering.
|
|
1307
|
+
*
|
|
1308
|
+
* @param spec - Raw tilemap spec (validated and normalized internally).
|
|
1309
|
+
* @param options - Compile options (width, height, theme, darkMode).
|
|
1310
|
+
* @returns TileMapLayout with computed positions and visual properties.
|
|
1311
|
+
* @throws Error if spec is invalid or not a tilemap type.
|
|
1312
|
+
*/
|
|
1313
|
+
export function compileTileMap(
|
|
1314
|
+
spec: unknown,
|
|
1315
|
+
options: CompileOptions,
|
|
1316
|
+
): import('@opendata-ai/openchart-core').TileMapLayout {
|
|
1317
|
+
return compileTileMapImpl(spec, options);
|
|
1318
|
+
}
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
LayerSpec,
|
|
22
22
|
SankeySpec,
|
|
23
23
|
TableSpec,
|
|
24
|
+
TileMapSpec,
|
|
24
25
|
VizSpec,
|
|
25
26
|
} from '@opendata-ai/openchart-core';
|
|
26
27
|
import {
|
|
@@ -29,10 +30,13 @@ import {
|
|
|
29
30
|
isLayerSpec,
|
|
30
31
|
isSankeySpec,
|
|
31
32
|
isTableSpec,
|
|
33
|
+
isTileMapSpec,
|
|
32
34
|
resolveMarkDef,
|
|
33
35
|
resolveMarkType,
|
|
34
36
|
} from '@opendata-ai/openchart-core';
|
|
35
37
|
import type { NormalizedSankeySpec } from '../sankey/types';
|
|
38
|
+
import { STATE_CODE_SET } from '../tilemap/layout';
|
|
39
|
+
import type { NormalizedTileMapSpec } from '../tilemap/types';
|
|
36
40
|
import type {
|
|
37
41
|
NormalizedChartSpec,
|
|
38
42
|
NormalizedChrome,
|
|
@@ -213,6 +217,19 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
|
|
|
213
217
|
const encoding = inferEncodingTypes(spec.encoding, spec.data, warnings);
|
|
214
218
|
const markType = resolveMarkType(spec.mark);
|
|
215
219
|
const markDef = resolveMarkDef(spec.mark);
|
|
220
|
+
const display = spec.display ?? 'full';
|
|
221
|
+
|
|
222
|
+
if (
|
|
223
|
+
display === 'sparkline' &&
|
|
224
|
+
markType !== 'line' &&
|
|
225
|
+
markType !== 'area' &&
|
|
226
|
+
markType !== 'bar' &&
|
|
227
|
+
markType !== 'point'
|
|
228
|
+
) {
|
|
229
|
+
warnings.push(
|
|
230
|
+
`[openchart] display: 'sparkline' works best with mark: 'line' | 'area' | 'bar' | 'point'. Got mark: '${markType}' — rendering may degrade.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
216
233
|
|
|
217
234
|
return {
|
|
218
235
|
markType,
|
|
@@ -229,6 +246,19 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
|
|
|
229
246
|
hiddenSeries: spec.hiddenSeries ?? [],
|
|
230
247
|
seriesStyles: spec.seriesStyles ?? {},
|
|
231
248
|
watermark: spec.watermark ?? true,
|
|
249
|
+
display,
|
|
250
|
+
// Default empty userExplicit; compileChart overwrites this with the real
|
|
251
|
+
// descriptor built from the raw expanded spec before normalize runs.
|
|
252
|
+
userExplicit: {
|
|
253
|
+
chrome: false,
|
|
254
|
+
legend: false,
|
|
255
|
+
xAxis: false,
|
|
256
|
+
yAxis: false,
|
|
257
|
+
labels: false,
|
|
258
|
+
animation: false,
|
|
259
|
+
watermark: false,
|
|
260
|
+
crosshair: false,
|
|
261
|
+
},
|
|
232
262
|
};
|
|
233
263
|
}
|
|
234
264
|
|
|
@@ -303,6 +333,55 @@ function normalizeGraphSpec(spec: GraphSpec, _warnings: string[]): NormalizedGra
|
|
|
303
333
|
};
|
|
304
334
|
}
|
|
305
335
|
|
|
336
|
+
function normalizeTileMapSpec(spec: TileMapSpec, warnings: string[]): NormalizedTileMapSpec {
|
|
337
|
+
// Convert record data to array if needed
|
|
338
|
+
let data: Record<string, unknown>[] = Array.isArray(spec.data) ? spec.data : [];
|
|
339
|
+
|
|
340
|
+
if (!Array.isArray(spec.data)) {
|
|
341
|
+
// Convert record map to array of rows
|
|
342
|
+
data = Object.entries(spec.data).map(([state, value]) => ({ state, value }));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Auto-generate encoding if not provided
|
|
346
|
+
let encoding = spec.encoding;
|
|
347
|
+
if (!encoding) {
|
|
348
|
+
encoding = {
|
|
349
|
+
state: { field: 'state', type: 'nominal' },
|
|
350
|
+
value: { field: 'value', type: 'quantitative' },
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Count matched states and warn if low match ratio
|
|
355
|
+
let matchedCount = 0;
|
|
356
|
+
for (const row of data) {
|
|
357
|
+
const stateCode = String(row[encoding.state.field]);
|
|
358
|
+
if (STATE_CODE_SET.has(stateCode)) {
|
|
359
|
+
matchedCount++;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const matchRatio = data.length > 0 ? matchedCount / data.length : 0;
|
|
364
|
+
if (matchRatio < 0.5 && data.length > 0) {
|
|
365
|
+
warnings.push(
|
|
366
|
+
`TileMap data: only ${matchedCount} of ${data.length} rows have valid US state codes (expected ≥50%)`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
type: 'tilemap',
|
|
372
|
+
data,
|
|
373
|
+
encoding,
|
|
374
|
+
palette: spec.palette ?? 'blue',
|
|
375
|
+
chrome: normalizeChrome(spec.chrome),
|
|
376
|
+
legend: spec.legend,
|
|
377
|
+
theme: spec.theme ?? {},
|
|
378
|
+
darkMode: spec.darkMode ?? 'off',
|
|
379
|
+
watermark: spec.watermark ?? true,
|
|
380
|
+
animation: spec.animation,
|
|
381
|
+
valueFormat: spec.valueFormat,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
306
385
|
// ---------------------------------------------------------------------------
|
|
307
386
|
// Public API
|
|
308
387
|
// ---------------------------------------------------------------------------
|
|
@@ -337,9 +416,12 @@ export function normalizeSpec(spec: VizSpec, warnings: string[] = []): Normalize
|
|
|
337
416
|
if (isSankeySpec(spec)) {
|
|
338
417
|
return normalizeSankeySpec(spec, warnings);
|
|
339
418
|
}
|
|
419
|
+
if (isTileMapSpec(spec)) {
|
|
420
|
+
return normalizeTileMapSpec(spec, warnings);
|
|
421
|
+
}
|
|
340
422
|
// Should never happen after validation
|
|
341
423
|
throw new Error(
|
|
342
|
-
`Unknown spec shape. Expected mark (chart), layer, type: 'table', type: 'graph', or type: '
|
|
424
|
+
`Unknown spec shape. Expected mark (chart), layer, type: 'table', type: 'graph', type: 'sankey', or type: 'tilemap'.`,
|
|
343
425
|
);
|
|
344
426
|
}
|
|
345
427
|
|
package/src/compiler/types.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
ColumnConfig,
|
|
16
16
|
DarkMode,
|
|
17
17
|
DataRow,
|
|
18
|
+
Display,
|
|
18
19
|
Encoding,
|
|
19
20
|
FieldType,
|
|
20
21
|
GraphEncoding,
|
|
@@ -29,6 +30,7 @@ import type {
|
|
|
29
30
|
ThemeConfig,
|
|
30
31
|
} from '@opendata-ai/openchart-core';
|
|
31
32
|
import type { NormalizedSankeySpec } from '../sankey/types';
|
|
33
|
+
import type { NormalizedTileMapSpec } from '../tilemap/types';
|
|
32
34
|
|
|
33
35
|
// ---------------------------------------------------------------------------
|
|
34
36
|
// NormalizedChrome: all fields are ChromeText objects (not plain strings)
|
|
@@ -60,6 +62,35 @@ export interface NormalizedEncodingChannel {
|
|
|
60
62
|
// NormalizedSpec types
|
|
61
63
|
// ---------------------------------------------------------------------------
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Tracks which top-level fields the user explicitly set in their input spec.
|
|
67
|
+
*
|
|
68
|
+
* Built from the raw expandedSpec (post-breakpoint-merge, pre-normalize) so
|
|
69
|
+
* that "user wrote chrome.title" vs "user wrote nothing" is distinguishable
|
|
70
|
+
* after normalization fills in defaults.
|
|
71
|
+
*
|
|
72
|
+
* Used by sparkline display mode to decide whether to suppress chrome/axes/
|
|
73
|
+
* legend/etc. by default vs. respecting an explicit user opt-in.
|
|
74
|
+
*/
|
|
75
|
+
export interface UserExplicit {
|
|
76
|
+
/** True if user wrote `chrome` (any non-empty chrome). */
|
|
77
|
+
chrome: boolean;
|
|
78
|
+
/** True if user wrote `legend`. */
|
|
79
|
+
legend: boolean;
|
|
80
|
+
/** True if user wrote `encoding.x.axis`. */
|
|
81
|
+
xAxis: boolean;
|
|
82
|
+
/** True if user wrote `encoding.y.axis`. */
|
|
83
|
+
yAxis: boolean;
|
|
84
|
+
/** True if user wrote `labels`. */
|
|
85
|
+
labels: boolean;
|
|
86
|
+
/** True if user wrote `animation`. */
|
|
87
|
+
animation: boolean;
|
|
88
|
+
/** True if user wrote `watermark`. */
|
|
89
|
+
watermark: boolean;
|
|
90
|
+
/** True if user wrote `crosshair`. */
|
|
91
|
+
crosshair: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
63
94
|
/** A ChartSpec with all optional fields filled with sensible defaults. */
|
|
64
95
|
export interface NormalizedChartSpec {
|
|
65
96
|
/** Resolved mark type string (extracted from spec.mark). */
|
|
@@ -84,6 +115,14 @@ export interface NormalizedChartSpec {
|
|
|
84
115
|
hiddenSeries: string[];
|
|
85
116
|
/** Per-series visual style overrides. */
|
|
86
117
|
seriesStyles: Record<string, import('@opendata-ai/openchart-core').SeriesStyle>;
|
|
118
|
+
/** Display mode controlling chrome/axes/legend stripping. Defaults to `'full'`. */
|
|
119
|
+
display: Display;
|
|
120
|
+
/**
|
|
121
|
+
* Which top-level fields the user explicitly set. Populated by compileChart
|
|
122
|
+
* from the raw expanded spec before normalization. NormalizeChartSpec runs
|
|
123
|
+
* with a default-empty descriptor; compileChart overwrites it post-normalize.
|
|
124
|
+
*/
|
|
125
|
+
userExplicit: UserExplicit;
|
|
87
126
|
}
|
|
88
127
|
|
|
89
128
|
/** A TableSpec with all optional fields filled with sensible defaults. */
|
|
@@ -124,7 +163,8 @@ export type NormalizedSpec =
|
|
|
124
163
|
| NormalizedChartSpec
|
|
125
164
|
| NormalizedTableSpec
|
|
126
165
|
| NormalizedGraphSpec
|
|
127
|
-
| NormalizedSankeySpec
|
|
166
|
+
| NormalizedSankeySpec
|
|
167
|
+
| NormalizedTileMapSpec;
|
|
128
168
|
|
|
129
169
|
// ---------------------------------------------------------------------------
|
|
130
170
|
// Validation types
|