@opendata-ai/openchart-core 6.28.5 → 7.0.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.
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * Default theme definition.
3
3
  *
4
- * Tuned to match Infrographic's visual weight and editorial style.
5
- * Inter font family, typography hierarchy for chrome, and color
6
- * palettes from the colors module.
4
+ * Editorial design system with cyan-led categorical palette, Inter
5
+ * Variable typography (weights 400/510/590), and zinc-based achromatic
6
+ * surfaces. Light is the default surface; dark adaptation lives in
7
+ * dark-mode.ts.
8
+ *
9
+ * resolveTheme() deep-merges user overrides onto this base.
7
10
  */
8
11
 
9
12
  import { CATEGORICAL_PALETTE, DIVERGING_PALETTES, SEQUENTIAL_PALETTES } from '../colors/palettes';
@@ -11,7 +14,6 @@ import type { Theme } from '../types/theme';
11
14
 
12
15
  /**
13
16
  * The default theme. All fields are required and fully specified.
14
- * resolveTheme() deep-merges user overrides onto this base.
15
17
  */
16
18
  export const DEFAULT_THEME: Theme = {
17
19
  colors: {
@@ -19,26 +21,32 @@ export const DEFAULT_THEME: Theme = {
19
21
  sequential: SEQUENTIAL_PALETTES,
20
22
  diverging: DIVERGING_PALETTES,
21
23
  background: '#ffffff',
22
- text: '#1d1d1d',
23
- gridline: '#e8e8e8',
24
- axis: '#888888',
24
+ text: '#09090b',
25
+ gridline: 'rgba(0,0,0,0.06)',
26
+ // Used for axis lines/ticks AND axis tick label fill. Must clear WCAG AA
27
+ // contrast (4.5:1) on white because tick labels are rendered with this
28
+ // color. Zinc-500 hits ~5.7:1.
29
+ axis: '#71717a',
25
30
  annotationFill: 'rgba(0,0,0,0.04)',
26
- annotationText: '#555555',
31
+ annotationText: '#71717a',
32
+ positive: '#16a34a',
33
+ negative: '#dc2626',
27
34
  },
28
35
  fonts: {
29
- family: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
36
+ family:
37
+ '"Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
30
38
  mono: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
31
39
  sizes: {
32
- title: 22,
33
- subtitle: 15,
40
+ title: 26,
41
+ subtitle: 14,
34
42
  body: 13,
35
43
  small: 11,
36
44
  axisTick: 11,
37
45
  },
38
46
  weights: {
39
47
  normal: 400,
40
- medium: 500,
41
- semibold: 600,
48
+ medium: 510,
49
+ semibold: 590,
42
50
  bold: 700,
43
51
  },
44
52
  },
@@ -49,37 +57,43 @@ export const DEFAULT_THEME: Theme = {
49
57
  chartToFooter: 8,
50
58
  axisMargin: 6,
51
59
  },
52
- borderRadius: 4,
60
+ borderRadius: 2,
53
61
  chrome: {
62
+ eyebrow: {
63
+ fontSize: 11,
64
+ fontWeight: 510,
65
+ color: '#06b6d4',
66
+ lineHeight: 1.4,
67
+ },
54
68
  title: {
55
- fontSize: 22,
56
- fontWeight: 700,
57
- color: '#333333',
58
- lineHeight: 1.3,
69
+ fontSize: 26,
70
+ fontWeight: 590,
71
+ color: '#09090b',
72
+ lineHeight: 1.15,
59
73
  },
60
74
  subtitle: {
61
- fontSize: 15,
75
+ fontSize: 14,
62
76
  fontWeight: 400,
63
- color: '#666666',
64
- lineHeight: 1.4,
77
+ color: '#71717a',
78
+ lineHeight: 1.45,
65
79
  },
66
80
  source: {
67
- fontSize: 12,
81
+ fontSize: 11,
68
82
  fontWeight: 400,
69
- color: '#999999',
70
- lineHeight: 1.3,
83
+ color: '#71717a',
84
+ lineHeight: 1.4,
71
85
  },
72
86
  byline: {
73
- fontSize: 12,
87
+ fontSize: 11,
74
88
  fontWeight: 400,
75
- color: '#999999',
76
- lineHeight: 1.3,
89
+ color: '#71717a',
90
+ lineHeight: 1.4,
77
91
  },
78
92
  footer: {
79
- fontSize: 12,
93
+ fontSize: 11,
80
94
  fontWeight: 400,
81
- color: '#999999',
82
- lineHeight: 1.3,
95
+ color: '#71717a',
96
+ lineHeight: 1.4,
83
97
  },
84
98
  },
85
99
  };
@@ -2,6 +2,6 @@
2
2
  * Theme module barrel export.
3
3
  */
4
4
 
5
- export { adaptColorForDarkMode, adaptTheme } from './dark-mode';
5
+ export { adaptColorForDarkMode, adaptForLightLineStroke, adaptTheme } from './dark-mode';
6
6
  export { DEFAULT_THEME } from './defaults';
7
7
  export { resolveTheme } from './resolve';
@@ -118,6 +118,8 @@ function adaptChromeForDarkBg(theme: Theme, textColor: string): Theme {
118
118
  return {
119
119
  ...theme,
120
120
  chrome: {
121
+ // Eyebrow keeps its accent tint regardless of surface mode.
122
+ eyebrow: theme.chrome.eyebrow,
121
123
  title: {
122
124
  ...theme.chrome.title,
123
125
  color:
@@ -22,7 +22,7 @@ import {
22
22
  // Test data factories
23
23
  // ---------------------------------------------------------------------------
24
24
 
25
- function makeChartSpec(overrides?: Partial<ChartSpec>): ChartSpec {
25
+ function makeChartSpec(): ChartSpec {
26
26
  return {
27
27
  mark: 'line',
28
28
  data: [
@@ -33,7 +33,6 @@ function makeChartSpec(overrides?: Partial<ChartSpec>): ChartSpec {
33
33
  x: { field: 'date', type: 'temporal' },
34
34
  y: { field: 'value', type: 'quantitative' },
35
35
  },
36
- ...overrides,
37
36
  };
38
37
  }
39
38
 
@@ -70,22 +69,10 @@ function makeGraphSpec(overrides?: Partial<GraphSpec>): GraphSpec {
70
69
 
71
70
  describe('isChartSpec', () => {
72
71
  it('returns true for all mark types', () => {
73
- const markTypes = [
74
- 'line',
75
- 'area',
76
- 'bar',
77
- 'point',
78
- 'circle',
79
- 'arc',
80
- 'text',
81
- 'rule',
82
- 'tick',
83
- 'rect',
84
- 'lollipop',
85
- ] as const;
86
-
87
- for (const markType of markTypes) {
88
- const spec = makeChartSpec({ mark: markType });
72
+ // isChartSpec is a runtime guard that only checks for the presence of `mark`.
73
+ // Use `as ChartSpec` here since we're testing the guard, not encoding validity.
74
+ for (const markType of MARK_TYPES) {
75
+ const spec = { mark: markType, data: [], encoding: {} } as ChartSpec;
89
76
  expect(isChartSpec(spec)).toBe(true);
90
77
  }
91
78
  });
@@ -426,3 +413,83 @@ describe('type-level spec construction', () => {
426
413
  expect(spec.edges).toHaveLength(1);
427
414
  });
428
415
  });
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Negative type tests (compile-time rejection verification)
419
+ //
420
+ // Each variable below is annotated with @ts-expect-error on the line that
421
+ // should be rejected. If any @ts-expect-error becomes "unused" (no error),
422
+ // TypeScript will fail the build — meaning the type guarantee regressed.
423
+ // ---------------------------------------------------------------------------
424
+
425
+ describe('type-level rejection', () => {
426
+ it('compiles with @ts-expect-error annotations intact (runtime no-op)', () => {
427
+ // Arc requires y + color. Missing color must error.
428
+ const _arcMissingColor: ChartSpec = {
429
+ mark: 'arc',
430
+ data: [],
431
+ // @ts-expect-error ArcEncoding requires color channel
432
+ encoding: {
433
+ y: { field: 'value', type: 'quantitative' },
434
+ },
435
+ };
436
+
437
+ // Text mark requires text channel. Missing text must error.
438
+ const _textMissingText: ChartSpec = {
439
+ mark: 'text',
440
+ data: [],
441
+ // @ts-expect-error TextEncoding requires text channel
442
+ encoding: {},
443
+ };
444
+
445
+ // Typed spec: field typo must error when TData is provided.
446
+ type SalesRow = { date: string; revenue: number };
447
+ const _typoField: ChartSpec<SalesRow> = {
448
+ mark: 'line',
449
+ data: [],
450
+ encoding: {
451
+ // @ts-expect-error 'dat' is not a key of SalesRow — did you mean 'date'?
452
+ x: { field: 'dat', type: 'temporal' },
453
+ y: { field: 'revenue', type: 'quantitative' },
454
+ },
455
+ };
456
+
457
+ // Valid typed spec must compile without errors.
458
+ const _validTyped: ChartSpec<SalesRow> = {
459
+ mark: 'line',
460
+ data: [{ date: '2024-01', revenue: 100 }],
461
+ encoding: {
462
+ x: { field: 'date', type: 'temporal' },
463
+ y: { field: 'revenue', type: 'quantitative' },
464
+ },
465
+ };
466
+
467
+ // Arc without theta must compile (theta is optional per MARK_ENCODING_RULES).
468
+ const _arcNoTheta: ChartSpec = {
469
+ mark: 'arc',
470
+ data: [],
471
+ encoding: {
472
+ y: { field: 'value', type: 'quantitative' },
473
+ color: { field: 'category', type: 'nominal' },
474
+ },
475
+ };
476
+
477
+ // Untyped spec (no TData param) must compile — no migration cost.
478
+ const _untypedSpec: ChartSpec = {
479
+ mark: 'line',
480
+ data: [],
481
+ encoding: {
482
+ x: { field: 'anything', type: 'temporal' },
483
+ y: { field: 'anything', type: 'quantitative' },
484
+ },
485
+ };
486
+
487
+ void _arcMissingColor;
488
+ void _textMissingText;
489
+ void _typoField;
490
+ void _validTyped;
491
+ void _arcNoTheta;
492
+ void _untypedSpec;
493
+ expect(true).toBe(true);
494
+ });
495
+ });
@@ -42,6 +42,8 @@ export type {
42
42
  ChartLayout,
43
43
  CompileOptions,
44
44
  CompileTableOptions,
45
+ EndpointLabelEntry,
46
+ EndpointLabelsLayout,
45
47
  FlagTableCell,
46
48
  GradientColorStop,
47
49
  GradientLegendLayout,
@@ -69,6 +71,8 @@ export type {
69
71
  ResolvedChromeElement,
70
72
  ResolvedColumn,
71
73
  ResolvedLabel,
74
+ ResolvedMetricBar,
75
+ ResolvedMetricCell,
72
76
  RuleMarkLayout,
73
77
  SankeyLayout,
74
78
  SankeyLinkMark,
@@ -101,7 +105,10 @@ export type {
101
105
  Annotation,
102
106
  AnnotationAnchor,
103
107
  AnnotationOffset,
108
+ ArcEncoding,
109
+ AreaEncoding,
104
110
  AxisConfig,
111
+ BarEncoding,
105
112
  BarListEncoding,
106
113
  BarListSpec,
107
114
  BarListSpecWithoutData,
@@ -116,6 +123,7 @@ export type {
116
123
  Chrome,
117
124
  ChromeText,
118
125
  ChromeTextStyle,
126
+ CircleEncoding,
119
127
  Condition,
120
128
  ConditionalValueDef,
121
129
  DarkMode,
@@ -123,6 +131,7 @@ export type {
123
131
  Display,
124
132
  Encoding,
125
133
  EncodingChannel,
134
+ EndpointLabelsConfig,
126
135
  FieldPredicate,
127
136
  FieldType,
128
137
  FilterPredicate,
@@ -143,14 +152,19 @@ export type {
143
152
  LayerSpec,
144
153
  LegendConfig,
145
154
  LinearGradient,
155
+ LineEncoding,
146
156
  LogicalAnd,
147
157
  LogicalNot,
148
158
  LogicalOr,
159
+ LollipopEncoding,
149
160
  MarkDef,
150
161
  MarkType,
162
+ Metric,
151
163
  NodeOverride,
164
+ PointEncoding,
152
165
  RadialGradient,
153
166
  RangeAnnotation,
167
+ RectEncoding,
154
168
  RefLineAnnotation,
155
169
  RelativeTimeRef,
156
170
  ResolveConfig,
@@ -167,7 +181,9 @@ export type {
167
181
  TableSpec,
168
182
  TableSpecWithoutData,
169
183
  TextAnnotation,
184
+ TextEncoding,
170
185
  ThemeConfig,
186
+ TickEncoding,
171
187
  TileMapEncoding,
172
188
  TileMapPalette,
173
189
  TileMapSpec,
@@ -102,11 +102,13 @@ export interface ResolvedChrome {
102
102
  /** Total height consumed by chrome elements below the chart area. */
103
103
  bottomHeight: number;
104
104
  /** Resolved chrome elements. Only present if specified in the spec. */
105
+ eyebrow?: ResolvedChromeElement;
105
106
  title?: ResolvedChromeElement;
106
107
  subtitle?: ResolvedChromeElement;
107
108
  source?: ResolvedChromeElement;
108
109
  byline?: ResolvedChromeElement;
109
110
  footer?: ResolvedChromeElement;
111
+ brand?: ResolvedChromeElement;
110
112
  }
111
113
 
112
114
  // ---------------------------------------------------------------------------
@@ -167,6 +169,13 @@ export interface AxisLayout {
167
169
  labelOverlap?: boolean | 'parity' | 'greedy';
168
170
  /** Whether to flush labels to the axis edges. */
169
171
  labelFlush?: boolean;
172
+ /**
173
+ * Where tick labels render relative to the chart area.
174
+ * `'inline'` puts y-axis labels above their gridlines at chart-area x and
175
+ * suppresses the axis line and tick marks. `'gutter'` (default) keeps the
176
+ * classic outside-the-area placement.
177
+ */
178
+ tickPosition?: 'inline' | 'gutter';
170
179
  }
171
180
 
172
181
  // ---------------------------------------------------------------------------
@@ -175,12 +184,17 @@ export interface AxisLayout {
175
184
 
176
185
  /** Accessibility attributes for a mark. */
177
186
  export interface MarkAria {
178
- /** ARIA label for the mark. */
179
- label: string;
187
+ /** ARIA label for the mark. Optional when `decorative: true` — decorative
188
+ * marks render with `aria-hidden="true"` and don't need a label. */
189
+ label?: string;
180
190
  /** Optional longer description. */
181
191
  description?: string;
182
192
  /** ARIA role override. */
183
193
  role?: string;
194
+ /** When true, the mark is purely decorative (e.g. a sparkline endpoint dot
195
+ * that duplicates an existing data point). Renderers translate this into
196
+ * `aria-hidden="true"` and skip the mark from the screen-reader data table. */
197
+ decorative?: boolean;
184
198
  }
185
199
 
186
200
  /**
@@ -292,6 +306,13 @@ export interface RectMark {
292
306
  strokeWidth?: number;
293
307
  /** Corner radius. */
294
308
  cornerRadius?: number;
309
+ /**
310
+ * Which corners receive `cornerRadius`. Defaults to all four when unset.
311
+ * Used by stacked bars/columns to round only the leading edge of the
312
+ * topmost (vertical) or rightmost (horizontal) segment so the seams
313
+ * between stacked segments stay square and visually contiguous.
314
+ */
315
+ cornerRadiusSides?: { tl?: boolean; tr?: boolean; br?: boolean; bl?: boolean };
295
316
  /** Original data row. */
296
317
  data: Record<string, unknown>;
297
318
  /** Resolved label. */
@@ -512,12 +533,14 @@ export interface ResolvedLabel {
512
533
  connector?: {
513
534
  /** Connector start (at the label). */
514
535
  from: Point;
515
- /** Connector end (at the data point). */
536
+ /** Connector end (pulled back from the data point so the line doesn't touch it). */
516
537
  to: Point;
538
+ /** Actual data point the connector is calling out. Renderer uses this for the endpoint marker. */
539
+ endpoint?: Point;
517
540
  /** Connector line color. */
518
541
  stroke: string;
519
- /** Connector style: straight line or curved arrow. */
520
- style: 'straight' | 'curve';
542
+ /** Connector style: straight line, curved arrow, or vertical drop-line through the data point. */
543
+ style: 'straight' | 'curve' | 'drop-line';
521
544
  };
522
545
  /** Background color behind the label text. */
523
546
  background?: string;
@@ -553,6 +576,28 @@ export interface ResolvedAnnotation {
553
576
  strokeWidth?: number;
554
577
  /** Z-index for render ordering. Higher values render on top. */
555
578
  zIndex?: number;
579
+ /**
580
+ * For text annotations: optional dot marker drawn at the connector's
581
+ * data-point endpoint. Coordinates match `label.connector.to` exactly.
582
+ */
583
+ dot?: {
584
+ x: number;
585
+ y: number;
586
+ radius: number;
587
+ fill: string;
588
+ stroke: string;
589
+ strokeWidth: number;
590
+ };
591
+ /**
592
+ * For text annotations: optional muted subtitle rendered below the primary
593
+ * label. Positioned `lineHeight * primaryLineCount + gap` below `label.y`.
594
+ */
595
+ subtitle?: {
596
+ text: string;
597
+ x: number;
598
+ y: number;
599
+ style: TextStyle;
600
+ };
556
601
  }
557
602
 
558
603
  // ---------------------------------------------------------------------------
@@ -595,6 +640,8 @@ export interface CategoricalLegendLayout extends BaseLegendLayout {
595
640
  swatchGap: number;
596
641
  /** Gap between entries. */
597
642
  entryGap: number;
643
+ /** Fill color for the rounded chip background behind the colored bar. */
644
+ swatchChipFill: string;
598
645
  }
599
646
 
600
647
  /** A color stop in a gradient legend. */
@@ -622,6 +669,61 @@ export interface GradientLegendLayout extends BaseLegendLayout {
622
669
  /** Resolved legend layout — either categorical (swatches) or gradient (continuous bar). */
623
670
  export type LegendLayout = CategoricalLegendLayout | GradientLegendLayout;
624
671
 
672
+ // ---------------------------------------------------------------------------
673
+ // Endpoint labels (right-side per-series column for line/area charts)
674
+ // ---------------------------------------------------------------------------
675
+
676
+ /** A single resolved endpoint-label entry, fully positioned. */
677
+ export interface EndpointLabelEntry {
678
+ /** Series identifier (matches mark.seriesKey). */
679
+ seriesKey: string;
680
+ /** Pre-wrapped label text lines. */
681
+ labelLines: string[];
682
+ /** Formatted value string for the last data point. */
683
+ value: string;
684
+ /** Series color. */
685
+ color: string;
686
+ /** True pixel y of the last data point on the line. */
687
+ dataY: number;
688
+ /** Displaced y after the bidirectional collision sweep. */
689
+ labelY: number;
690
+ /** True when |labelY - dataY| exceeds the threshold (renderer draws a leader line). */
691
+ showLeader: boolean;
692
+ /** Optional anchor circle drawn on the line at the chart's right edge. */
693
+ marker?: {
694
+ x: number;
695
+ y: number;
696
+ fill: string;
697
+ stroke: string;
698
+ strokeWidth: number;
699
+ radius: number;
700
+ };
701
+ }
702
+
703
+ /** Resolved layout for the endpoint labels column. */
704
+ export interface EndpointLabelsLayout {
705
+ /** Per-series resolved entries. */
706
+ entries: EndpointLabelEntry[];
707
+ /** Bounding box for the column. */
708
+ bounds: Rect;
709
+ /** Style for the series-name text. */
710
+ labelStyle: TextStyle;
711
+ /** Style for the formatted value text. */
712
+ valueStyle: TextStyle;
713
+ /** Width of the colored swatch line drawn left of the label. */
714
+ swatchSize: number;
715
+ /** Horizontal gap between swatch and label text. */
716
+ gap: number;
717
+ /**
718
+ * Vertical gap between the last wrapped label line and the value text
719
+ * directly below it. Resolved by the engine so the renderer can't drift
720
+ * away from the height the collision sweep budgeted for each entry.
721
+ */
722
+ valueGap: number;
723
+ /** Fill color for the rounded chip background behind the colored bar. */
724
+ swatchChipFill: string;
725
+ }
726
+
625
727
  // ---------------------------------------------------------------------------
626
728
  // Tooltips
627
729
  // ---------------------------------------------------------------------------
@@ -680,6 +782,42 @@ export interface ResolvedAnimation {
680
782
  annotationDelay: number;
681
783
  }
682
784
 
785
+ // ---------------------------------------------------------------------------
786
+ // Metric bar (resolved)
787
+ // ---------------------------------------------------------------------------
788
+
789
+ /**
790
+ * A resolved KPI metric cell with computed positions for label, value, and
791
+ * supplementary text spans. Cells render as a row above the chart area.
792
+ */
793
+ export interface ResolvedMetricCell {
794
+ /** Cell left x in layout coordinates. */
795
+ x: number;
796
+ /** Cell width. */
797
+ cellWidth: number;
798
+ /** Baseline y for the uppercase label. */
799
+ labelY: number;
800
+ /** Baseline y for the primary value. */
801
+ valueY: number;
802
+ /** The original metric spec (label/value/delta/etc.). */
803
+ metric: import('./spec').Metric;
804
+ /** True if value+delta+secondary would overflow cellWidth. Set by layout for diagnostics. */
805
+ overflowed: boolean;
806
+ }
807
+
808
+ /**
809
+ * The full metric-bar layout. Present only when spec.metrics is supplied
810
+ * and the bar fits the container.
811
+ */
812
+ export interface ResolvedMetricBar {
813
+ /** Top y of the metric row in layout coordinates. */
814
+ y: number;
815
+ /** Total reserved height of the row. */
816
+ height: number;
817
+ /** Cells laid out evenly across the chart width. */
818
+ cells: ResolvedMetricCell[];
819
+ }
820
+
683
821
  // ---------------------------------------------------------------------------
684
822
  // ChartLayout (the main engine output for charts)
685
823
  // ---------------------------------------------------------------------------
@@ -697,6 +835,8 @@ export interface ChartLayout {
697
835
  area: Rect;
698
836
  /** Resolved chrome text elements with positions and styles. */
699
837
  chrome: ResolvedChrome;
838
+ /** Resolved KPI metric bar. Present only when spec.metrics is supplied and fits. */
839
+ metrics?: ResolvedMetricBar;
700
840
  /** Resolved axis layouts. */
701
841
  axes: {
702
842
  x?: AxisLayout;
@@ -710,6 +850,12 @@ export interface ChartLayout {
710
850
  annotations: ResolvedAnnotation[];
711
851
  /** Legend layout (position, entries, bounds). */
712
852
  legend: LegendLayout;
853
+ /**
854
+ * Right-side endpoint labels column for multi-series line/area charts.
855
+ * Empty `entries` means the column is suppressed (single-series, opt-out, or
856
+ * auto-suppressed by the truth table).
857
+ */
858
+ endpointLabels?: EndpointLabelsLayout;
713
859
  /** Tooltip descriptors keyed by a mark identifier. */
714
860
  tooltipDescriptors: Map<string, TooltipContent>;
715
861
  /** Accessibility metadata. */