@opendata-ai/openchart-core 7.6.1 → 7.7.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.
@@ -10,8 +10,8 @@
10
10
  * enabling darkMode.
11
11
  */
12
12
 
13
- import type { ThemeConfig } from '../types/spec';
14
- import type { ResolvedTheme, Theme } from '../types/theme';
13
+ import type { ChromeThemeOverride, ThemeConfig } from '../types/spec';
14
+ import type { ChromeDefaults, ResolvedTheme, Theme, TokenValue } from '../types/theme';
15
15
  import { DEFAULT_THEME } from './defaults';
16
16
 
17
17
  /**
@@ -44,6 +44,11 @@ function deepMerge(target: any, source: any): any {
44
44
  return result;
45
45
  }
46
46
 
47
+ /** Extract the light-mode string from a TokenValue. */
48
+ function resolveTokenLight(v: TokenValue): string {
49
+ return typeof v === 'string' ? v : v.light;
50
+ }
51
+
47
52
  /**
48
53
  * Convert a user-facing ThemeConfig (partial) into the full Theme shape
49
54
  * that deepMerge can work with.
@@ -60,10 +65,16 @@ function themeConfigToPartial(config: ThemeConfig): Partial<Theme> {
60
65
  if (config.colors.categorical) colors.categorical = config.colors.categorical;
61
66
  if (config.colors.sequential) colors.sequential = config.colors.sequential;
62
67
  if (config.colors.diverging) colors.diverging = config.colors.diverging;
63
- if (config.colors.background) colors.background = config.colors.background;
64
- if (config.colors.text) colors.text = config.colors.text;
65
- if (config.colors.gridline) colors.gridline = config.colors.gridline;
66
- if (config.colors.axis) colors.axis = config.colors.axis;
68
+ if (config.colors.background) colors.background = resolveTokenLight(config.colors.background);
69
+ if (config.colors.text) colors.text = resolveTokenLight(config.colors.text);
70
+ if (config.colors.gridline) colors.gridline = resolveTokenLight(config.colors.gridline);
71
+ if (config.colors.axis) colors.axis = resolveTokenLight(config.colors.axis);
72
+ if (config.colors.annotationFill)
73
+ colors.annotationFill = resolveTokenLight(config.colors.annotationFill);
74
+ if (config.colors.annotationText)
75
+ colors.annotationText = resolveTokenLight(config.colors.annotationText);
76
+ if (config.colors.positive) colors.positive = resolveTokenLight(config.colors.positive);
77
+ if (config.colors.negative) colors.negative = resolveTokenLight(config.colors.negative);
67
78
  }
68
79
  partial.colors = colors as Theme['colors'];
69
80
  }
@@ -73,6 +84,7 @@ function themeConfigToPartial(config: ThemeConfig): Partial<Theme> {
73
84
  if (config.fonts.family) fonts.family = config.fonts.family;
74
85
  if (config.fonts.mono) fonts.mono = config.fonts.mono;
75
86
  if (config.fonts.sizes) fonts.sizes = config.fonts.sizes as Theme['fonts']['sizes'];
87
+ if (config.fonts.weights) fonts.weights = config.fonts.weights as Theme['fonts']['weights'];
76
88
  partial.fonts = fonts as Theme['fonts'];
77
89
  }
78
90
 
@@ -80,6 +92,11 @@ function themeConfigToPartial(config: ThemeConfig): Partial<Theme> {
80
92
  const spacing: Partial<Theme['spacing']> = {};
81
93
  if (config.spacing.padding !== undefined) spacing.padding = config.spacing.padding;
82
94
  if (config.spacing.chromeGap !== undefined) spacing.chromeGap = config.spacing.chromeGap;
95
+ if (config.spacing.chromeToChart !== undefined)
96
+ spacing.chromeToChart = config.spacing.chromeToChart;
97
+ if (config.spacing.chartToFooter !== undefined)
98
+ spacing.chartToFooter = config.spacing.chartToFooter;
99
+ if (config.spacing.axisMargin !== undefined) spacing.axisMargin = config.spacing.axisMargin;
83
100
  if (config.spacing.xAxisHeight !== undefined) spacing.xAxisHeight = config.spacing.xAxisHeight;
84
101
  if (config.spacing.xAxisLabelPadding !== undefined)
85
102
  spacing.xAxisLabelPadding = config.spacing.xAxisLabelPadding;
@@ -91,19 +108,74 @@ function themeConfigToPartial(config: ThemeConfig): Partial<Theme> {
91
108
  }
92
109
 
93
110
  if (config.chrome) {
94
- // Only color is overridable per chrome element. Each element becomes a
95
- // partial `{ color }`; deepMerge layers it onto the full ChromeDefaults
96
- // so font size/weight/lineHeight stay at their typography-scale values.
97
- const chrome: Record<string, { color: string }> = {};
98
- for (const [element, color] of Object.entries(config.chrome)) {
99
- if (color !== undefined) chrome[element] = { color };
111
+ const chrome: Record<string, Partial<ChromeDefaults>> = {};
112
+ for (const [element, value] of Object.entries(config.chrome)) {
113
+ if (value === undefined) continue;
114
+ if (typeof value === 'string') {
115
+ chrome[element] = { color: value };
116
+ } else {
117
+ const override: Partial<ChromeDefaults> = {};
118
+ const obj = value as ChromeThemeOverride;
119
+ if (obj.color !== undefined) override.color = resolveTokenLight(obj.color);
120
+ if (obj.fontSize !== undefined) override.fontSize = obj.fontSize;
121
+ if (obj.fontWeight !== undefined) override.fontWeight = obj.fontWeight;
122
+ if (obj.lineHeight !== undefined) override.lineHeight = obj.lineHeight;
123
+ chrome[element] = override;
124
+ }
100
125
  }
101
126
  partial.chrome = chrome as unknown as Theme['chrome'];
102
127
  }
103
128
 
129
+ if (config.seriesStrategy) {
130
+ partial.seriesStrategy = config.seriesStrategy;
131
+ }
132
+
104
133
  return partial;
105
134
  }
106
135
 
136
+ /**
137
+ * Collect TokenValue pairs from the user's ThemeConfig so adaptTheme()
138
+ * can use explicit dark values instead of algorithmic adaptation.
139
+ */
140
+ function collectTokenPairs(
141
+ config: ThemeConfig,
142
+ ): Record<string, { light: string; dark: string }> | undefined {
143
+ const pairs: Record<string, { light: string; dark: string }> = {};
144
+ if (config.colors && !Array.isArray(config.colors)) {
145
+ const colorFields = [
146
+ 'background',
147
+ 'text',
148
+ 'gridline',
149
+ 'axis',
150
+ 'annotationFill',
151
+ 'annotationText',
152
+ 'positive',
153
+ 'negative',
154
+ ] as const;
155
+ for (const field of colorFields) {
156
+ const v = config.colors[field];
157
+ if (v && typeof v === 'object' && 'light' in v) {
158
+ pairs[`colors.${field}`] = v;
159
+ }
160
+ }
161
+ }
162
+ if (config.chrome) {
163
+ for (const [element, value] of Object.entries(config.chrome)) {
164
+ if (
165
+ value &&
166
+ typeof value === 'object' &&
167
+ 'color' in value &&
168
+ value.color &&
169
+ typeof value.color === 'object' &&
170
+ 'light' in value.color
171
+ ) {
172
+ pairs[`chrome.${element}.color`] = value.color;
173
+ }
174
+ }
175
+ }
176
+ return Object.keys(pairs).length > 0 ? pairs : undefined;
177
+ }
178
+
107
179
  /**
108
180
  * Parse a hex color to sRGB relative luminance (WCAG 2.1).
109
181
  * Returns 0 for invalid/unparseable colors.
@@ -235,5 +307,6 @@ export function resolveTheme(userTheme?: ThemeConfig, base: Theme = DEFAULT_THEM
235
307
  return {
236
308
  ...merged,
237
309
  isDark: dark,
310
+ _tokenPairs: userTheme ? collectTokenPairs(userTheme) : undefined,
238
311
  } as ResolvedTheme;
239
312
  }
@@ -28,6 +28,7 @@ export { elementRef } from './events';
28
28
  // Layout types (engine output)
29
29
  export type {
30
30
  A11yMetadata,
31
+ AnnotationFootnote,
31
32
  ArcMark,
32
33
  AreaMark,
33
34
  AxisLayout,
@@ -44,6 +45,8 @@ export type {
44
45
  CompileTableOptions,
45
46
  EndpointLabelEntry,
46
47
  EndpointLabelsLayout,
48
+ FacetLayout,
49
+ FacetPanelLayout,
47
50
  FlagTableCell,
48
51
  GradientColorStop,
49
52
  GradientLegendLayout,
@@ -124,6 +127,7 @@ export type {
124
127
  Chrome,
125
128
  ChromeText,
126
129
  ChromeTextStyle,
130
+ ChromeThemeOverride,
127
131
  CircleEncoding,
128
132
  Condition,
129
133
  ConditionalValueDef,
@@ -133,6 +137,7 @@ export type {
133
137
  Encoding,
134
138
  EncodingChannel,
135
139
  EndpointLabelsConfig,
140
+ FacetChannel,
136
141
  FieldPredicate,
137
142
  FieldType,
138
143
  FilterPredicate,
@@ -170,6 +175,7 @@ export type {
170
175
  RelativeTimeRef,
171
176
  ResolveConfig,
172
177
  ResolveMode,
178
+ ResponsiveConfig,
173
179
  SankeyEncoding,
174
180
  SankeyLinkColor,
175
181
  SankeyNodeAlign,
@@ -232,6 +238,7 @@ export type {
232
238
  export type {
233
239
  ChromeDefaults,
234
240
  ResolvedTheme,
241
+ SeriesStrategy,
235
242
  Theme,
236
243
  ThemeChromeDefaults,
237
244
  ThemeColors,
@@ -239,4 +246,5 @@ export type {
239
246
  ThemeFonts,
240
247
  ThemeFontWeights,
241
248
  ThemeSpacing,
249
+ TokenValue,
242
250
  } from './theme';
@@ -109,6 +109,8 @@ export interface ResolvedChrome {
109
109
  byline?: ResolvedChromeElement;
110
110
  footer?: ResolvedChromeElement;
111
111
  brand?: ResolvedChromeElement;
112
+ /** Annotation footnotes generated by auto-thinning. Rendered as a numbered list above the source line. */
113
+ footnotes?: AnnotationFootnote[];
112
114
  /** Absolute y at which bottom-chrome relative offsets are anchored: area.y + area.height + x-axis extent. */
113
115
  bottomAnchorY?: number;
114
116
  }
@@ -610,6 +612,20 @@ export interface ResolvedAnnotation {
610
612
  };
611
613
  /** Full placed extent: label bounds unioned with subtitle bounds. Collision and placement source of truth. */
612
614
  bounds?: Rect;
615
+ /**
616
+ * When set, this annotation was demoted to a footnote marker by auto-thinning.
617
+ * The label is replaced by a numbered dot at the data point; the original text
618
+ * appears in the chrome footnote band. 1-indexed.
619
+ */
620
+ footnoteIndex?: number;
621
+ }
622
+
623
+ /** A footnote entry generated by annotation auto-thinning. */
624
+ export interface AnnotationFootnote {
625
+ /** 1-indexed footnote number matching the dot marker on the chart. */
626
+ index: number;
627
+ /** The original annotation text that was demoted. */
628
+ text: string;
613
629
  }
614
630
 
615
631
  // ---------------------------------------------------------------------------
@@ -751,6 +767,10 @@ export interface EndpointLabelsLayout {
751
767
  valueGap: number;
752
768
  /** Fill color for the rounded chip background behind the colored bar. */
753
769
  swatchChipFill: string;
770
+ /** Leading-edge (left side) entries when `ends: 'both'`. Empty when trailing only. */
771
+ leading?: EndpointLabelEntry[];
772
+ /** Bounding box for the leading column. Only set when `ends: 'both'`. */
773
+ leadingBounds?: Rect;
754
774
  }
755
775
 
756
776
  // ---------------------------------------------------------------------------
@@ -851,6 +871,38 @@ export interface ResolvedMetricBar {
851
871
  // ChartLayout (the main engine output for charts)
852
872
  // ---------------------------------------------------------------------------
853
873
 
874
+ /**
875
+ * A single panel in a faceted (small-multiples) layout.
876
+ * Each panel has its own marks, axes, and drawing area but typically
877
+ * shares scales with sibling panels.
878
+ */
879
+ export interface FacetPanelLayout {
880
+ /** Facet value that produced this panel. */
881
+ key: string;
882
+ /** Panel drawing area in SVG coordinates. */
883
+ area: Rect;
884
+ /** Data marks for this panel. */
885
+ marks: Mark[];
886
+ /** Per-panel axis layouts. Inner panels may have ticks suppressed. */
887
+ axes: { x?: AxisLayout; y?: AxisLayout };
888
+ /** Per-panel annotations. */
889
+ annotations: ResolvedAnnotation[];
890
+ /** Panel header label (facet value) positioned above the panel. */
891
+ header: { text: string; x: number; y: number; fontSize: number; fontWeight: number };
892
+ }
893
+
894
+ /** Layout for a faceted (small-multiples) chart. */
895
+ export interface FacetLayout {
896
+ /** Ordered array of panel layouts. */
897
+ panels: FacetPanelLayout[];
898
+ /** The data field used for faceting. */
899
+ facetField: string;
900
+ /** Number of columns in the wrap grid. */
901
+ columns: number;
902
+ /** Whether scales are shared across panels. */
903
+ sharedScales: boolean;
904
+ }
905
+
854
906
  /**
855
907
  * ChartLayout: the complete engine output for chart visualizations.
856
908
  *
@@ -903,6 +955,8 @@ export interface ChartLayout {
903
955
  crosshair: boolean;
904
956
  /** Real text measurement function from the adapter (for accurate SVG text wrapping). */
905
957
  measureText?: MeasureTextFn;
958
+ /** Faceted (small-multiples) layout. When present, renderers iterate panels instead of using top-level marks/axes. */
959
+ facet?: FacetLayout;
906
960
  }
907
961
 
908
962
  // ---------------------------------------------------------------------------
@@ -1320,8 +1374,10 @@ export interface TileMapLayout {
1320
1374
  chrome: ResolvedChrome;
1321
1375
  /** Resolved tile marks with positions, colors, and labels. */
1322
1376
  tiles: TileMapTileMark[];
1323
- /** Gradient color legend (null if legend is hidden). */
1377
+ /** Gradient color legend for quantitative mode (null if hidden or categorical). */
1324
1378
  gradientLegend: GradientLegendLayout | null;
1379
+ /** Categorical swatch legend for nominal mode (null if hidden or quantitative). */
1380
+ categoricalLegend: CategoricalLegendLayout | null;
1325
1381
  /** Tooltip content descriptors keyed by state code. */
1326
1382
  tooltipDescriptors: Map<string, TooltipContent>;
1327
1383
  /** Accessibility metadata for the tile map. */
package/src/types/spec.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // Re-import for use in LegendConfig and overrides (avoids circular by importing from sibling)
12
12
  import type { Breakpoint, LegendPosition } from '../responsive/breakpoints';
13
13
  import type { ColumnConfig } from './table';
14
+ import type { SeriesStrategy, TokenValue } from './theme';
14
15
 
15
16
  // ---------------------------------------------------------------------------
16
17
  // Mark type union (Vega-Lite aligned)
@@ -417,6 +418,37 @@ export interface EncodingChannel<TData extends DataRow = DataRow> {
417
418
  * Used in tooltips; `axis.format` takes precedence for axis tick labels.
418
419
  */
419
420
  format?: string;
421
+ /**
422
+ * Emphasis highlight for the color channel. Names one or more series values
423
+ * to visually emphasize; all other series are muted to a neutral gray.
424
+ *
425
+ * Only meaningful on the `color` encoding channel. On other channels this
426
+ * property is ignored.
427
+ *
428
+ * @example
429
+ * encoding: {
430
+ * color: { field: 'region', type: 'nominal', highlight: 'West' }
431
+ * }
432
+ */
433
+ highlight?: string | string[];
434
+ }
435
+
436
+ /**
437
+ * Facet channel definition: partitions data into small-multiple panels.
438
+ *
439
+ * Unlike positional channels, facet only needs a field, a categorical type,
440
+ * and optional layout hints (columns, sort). It does not carry scale/axis
441
+ * config because it produces a grid, not a visual encoding.
442
+ */
443
+ export interface FacetChannel<TData extends DataRow = DataRow> {
444
+ /** Data field to partition by. Each unique value produces one panel. */
445
+ field: keyof TData & string;
446
+ /** Must be categorical. */
447
+ type: 'nominal' | 'ordinal';
448
+ /** Number of columns in the wrap grid. Auto-computed when omitted. */
449
+ columns?: number;
450
+ /** Sort order for panel values. Default ascending. */
451
+ sort?: 'ascending' | 'descending' | null;
420
452
  }
421
453
 
422
454
  /**
@@ -512,6 +544,13 @@ export interface Encoding<TData extends DataRow = DataRow> {
512
544
  * Not used by any other mark type.
513
545
  */
514
546
  radius?: EncodingChannel<TData>;
547
+ /**
548
+ * Facet channel: partitions data into a grid of small-multiple panels.
549
+ * Each unique value of the facet field produces one panel. Panels share
550
+ * scales by default; use `resolve: { scale: { y: 'independent' } }` for
551
+ * per-panel axes.
552
+ */
553
+ facet?: FacetChannel<TData>;
515
554
  }
516
555
 
517
556
  // ---------------------------------------------------------------------------
@@ -679,6 +718,8 @@ export interface TextAnnotation extends AnnotationBase {
679
718
  y: string | number;
680
719
  /** The annotation text. Required for text annotations. */
681
720
  text: string;
721
+ /** Thinning priority (lower = kept longer at narrow widths). When omitted, spec order is used. */
722
+ priority?: number;
682
723
  /**
683
724
  * Optional muted second-tone text rendered below the primary `text`.
684
725
  * Used for supporting context (e.g. methodology, source). Newlines in
@@ -804,6 +845,21 @@ export type Annotation = TextAnnotation | RangeAnnotation | RefLineAnnotation;
804
845
  */
805
846
  export type DarkMode = 'auto' | 'force' | 'off';
806
847
 
848
+ /**
849
+ * Per-element chrome theme override. Pass a plain string (color only,
850
+ * backward-compatible) or an object with full typography control.
851
+ */
852
+ export interface ChromeThemeOverride {
853
+ /** Text color. Accepts a TokenValue for light/dark pairs. */
854
+ color?: TokenValue;
855
+ /** Font size in pixels. */
856
+ fontSize?: number;
857
+ /** Font weight. */
858
+ fontWeight?: number;
859
+ /** Line height multiplier. */
860
+ lineHeight?: number;
861
+ }
862
+
807
863
  /**
808
864
  * User-facing theme configuration for overriding defaults.
809
865
  * All fields are optional. The engine deep-merges these onto the default theme.
@@ -823,14 +879,22 @@ export interface ThemeConfig {
823
879
  sequential?: Record<string, string[]>;
824
880
  /** Diverging palettes keyed by name. Each is an array of color stops with a neutral midpoint. */
825
881
  diverging?: Record<string, string[]>;
826
- /** Background color. */
827
- background?: string;
828
- /** Default text color. */
829
- text?: string;
830
- /** Gridline color. */
831
- gridline?: string;
832
- /** Axis line and tick color. */
833
- axis?: string;
882
+ /** Background color. Accepts a TokenValue for light/dark pairs. */
883
+ background?: TokenValue;
884
+ /** Default text color. Accepts a TokenValue for light/dark pairs. */
885
+ text?: TokenValue;
886
+ /** Gridline color. Accepts a TokenValue for light/dark pairs. */
887
+ gridline?: TokenValue;
888
+ /** Axis line and tick color. Accepts a TokenValue for light/dark pairs. */
889
+ axis?: TokenValue;
890
+ /** Annotation fill color. Accepts a TokenValue for light/dark pairs. */
891
+ annotationFill?: TokenValue;
892
+ /** Annotation text color. Accepts a TokenValue for light/dark pairs. */
893
+ annotationText?: TokenValue;
894
+ /** Semantic color for positive/up-trend values. Accepts a TokenValue for light/dark pairs. */
895
+ positive?: TokenValue;
896
+ /** Semantic color for negative/down-trend values. Accepts a TokenValue for light/dark pairs. */
897
+ negative?: TokenValue;
834
898
  };
835
899
  /** Font overrides. */
836
900
  fonts?: {
@@ -855,6 +919,17 @@ export interface ThemeConfig {
855
919
  /** KPI metric primary value. Default: 22. Delta/secondary derive from this. */
856
920
  metricValue?: number;
857
921
  };
922
+ /** Font weight overrides. Partial — only specified keys are overridden. */
923
+ weights?: {
924
+ /** Normal text weight. Default: 450. */
925
+ normal?: number;
926
+ /** Medium text weight. Default: 550. */
927
+ medium?: number;
928
+ /** Semibold text weight. Default: 590. */
929
+ semibold?: number;
930
+ /** Bold text weight. Default: 700. */
931
+ bold?: number;
932
+ };
858
933
  };
859
934
  /** Spacing overrides in pixels. */
860
935
  spacing?: {
@@ -862,6 +937,12 @@ export interface ThemeConfig {
862
937
  padding?: number;
863
938
  /** Gap between chrome elements (title to subtitle, etc.). */
864
939
  chromeGap?: number;
940
+ /** Gap between the last chrome element and the chart area. */
941
+ chromeToChart?: number;
942
+ /** Gap between chart area and source/footer below. */
943
+ chartToFooter?: number;
944
+ /** Internal padding within the chart area (axes margins). */
945
+ axisMargin?: number;
865
946
  /** Height reserved below chart area for x-axis tick labels. Increase when large axisTick font sizes cause label clipping. */
866
947
  xAxisHeight?: number;
867
948
  /** Gap in pixels between the x-axis line and tick label text. Increase when larger axisTick fonts sit too close to the axis line. */
@@ -870,25 +951,29 @@ export interface ThemeConfig {
870
951
  /** Border radius for chart container and tooltips. */
871
952
  borderRadius?: number;
872
953
  /**
873
- * Per-element chrome text color overrides. Font sizes and weights come
874
- * from the typography scale and are not overridable here; only color is.
875
- * An override survives dark-mode adaptation (adaptTheme preserves any
876
- * chrome color the spec set explicitly).
954
+ * Per-element chrome style overrides. Pass a plain string for color-only
955
+ * (backward-compatible), or a ChromeThemeOverride object for full
956
+ * typography control (color, fontSize, fontWeight, lineHeight).
877
957
  */
878
958
  chrome?: {
879
- /** Eyebrow (kicker) text color. */
880
- eyebrow?: string;
881
- /** Title text color. */
882
- title?: string;
883
- /** Subtitle text color. */
884
- subtitle?: string;
885
- /** Source/attribution text color. */
886
- source?: string;
887
- /** Byline text color. */
888
- byline?: string;
889
- /** Footer text color. */
890
- footer?: string;
959
+ /** Eyebrow (kicker) style override. */
960
+ eyebrow?: string | ChromeThemeOverride;
961
+ /** Title style override. */
962
+ title?: string | ChromeThemeOverride;
963
+ /** Subtitle style override. */
964
+ subtitle?: string | ChromeThemeOverride;
965
+ /** Source/attribution style override. */
966
+ source?: string | ChromeThemeOverride;
967
+ /** Byline style override. */
968
+ byline?: string | ChromeThemeOverride;
969
+ /** Footer style override. */
970
+ footer?: string | ChromeThemeOverride;
891
971
  };
972
+ /**
973
+ * Series color assignment strategy.
974
+ * Default: 'palette' (full categorical palette always, zero visual drift).
975
+ */
976
+ seriesStrategy?: SeriesStrategy;
892
977
  }
893
978
 
894
979
  // ---------------------------------------------------------------------------
@@ -976,6 +1061,14 @@ export interface LegendConfig {
976
1061
  export interface EndpointLabelsConfig {
977
1062
  /** Explicit on/off. When undefined, the chart auto-decides based on series count. */
978
1063
  show?: boolean;
1064
+ /** Maximum number of series before endpoint labels switch to a legend. Default 8. */
1065
+ maxSeries?: number;
1066
+ /**
1067
+ * Which ends of the chart get label columns.
1068
+ * - `'end'` (default): labels on the right (trailing) edge only.
1069
+ * - `'both'`: labels on both left (leading) and right (trailing) edges.
1070
+ */
1071
+ ends?: 'end' | 'both';
979
1072
  /** Field to read the displayed value from. Defaults to `encoding.y.field`. */
980
1073
  valueField?: string;
981
1074
  /** d3-format string for the value. Defaults to `encoding.y.axis.format`. */
@@ -1038,6 +1131,17 @@ export interface AnimationStagger {
1038
1131
  order?: 'index' | 'value' | 'reverse';
1039
1132
  }
1040
1133
 
1134
+ /** Fine-grained responsive behavior configuration. */
1135
+ export interface ResponsiveConfig {
1136
+ /**
1137
+ * Automatic annotation thinning at narrow widths. When true (default),
1138
+ * annotations that can't be placed without overlap are demoted to numbered
1139
+ * dot markers with footnote text below the chart. Set false to restore
1140
+ * the pre-auto-thinning behavior (annotations hidden at compact breakpoints).
1141
+ */
1142
+ autoThin?: boolean;
1143
+ }
1144
+
1041
1145
  /**
1042
1146
  * Animation phase config. Follows Vega's enter/update/exit model.
1043
1147
  * Each phase can be true (use defaults) or a config object.
@@ -1282,8 +1386,12 @@ interface BaseChartSpec<TData extends DataRow = DataRow> {
1282
1386
  * relates this flag to `legend.show` and the legacy end-of-line labels.
1283
1387
  */
1284
1388
  endpointLabels?: boolean | EndpointLabelsConfig;
1285
- /** Whether the chart adapts to container width. Defaults to true. */
1286
- responsive?: boolean;
1389
+ /**
1390
+ * Responsive behavior. `true`/`false` enables/disables container-width
1391
+ * adaptation. Pass a `ResponsiveConfig` object for fine-grained control
1392
+ * (e.g. `{ autoThin: false }` to disable automatic annotation thinning).
1393
+ */
1394
+ responsive?: boolean | ResponsiveConfig;
1287
1395
  /** Theme configuration overrides. */
1288
1396
  theme?: ThemeConfig;
1289
1397
  /** Dark mode behavior. Defaults to "off". */
@@ -1329,6 +1437,13 @@ interface BaseChartSpec<TData extends DataRow = DataRow> {
1329
1437
  * top-level and per-breakpoint overrides.
1330
1438
  */
1331
1439
  display?: Display;
1440
+ /**
1441
+ * Resolution strategy for shared/independent scales and axes across facet
1442
+ * panels. Only meaningful when `encoding.facet` is present. Shared scales
1443
+ * (the default) let readers compare across panels; independent scales give
1444
+ * each panel its own domain.
1445
+ */
1446
+ resolve?: ResolveConfig;
1332
1447
  /**
1333
1448
  * Render order within a LayerSpec. Higher values render on top.
1334
1449
  * When omitted, layers render in array order (later layers paint on top).
@@ -1667,8 +1782,14 @@ export interface SankeySpec {
1667
1782
  export interface TileMapEncoding {
1668
1783
  /** State code field (required, nominal). Maps to US state abbreviations. */
1669
1784
  state: EncodingChannel;
1670
- /** Value field (required, quantitative). Maps to the sequential color scale. */
1785
+ /**
1786
+ * Value field. For quantitative mode, maps to a sequential color scale.
1787
+ * For categorical mode (when color channel is present or values are strings),
1788
+ * maps to category labels shown in tooltips.
1789
+ */
1671
1790
  value: EncodingChannel;
1791
+ /** Color encoding channel (optional, nominal). When present, enables categorical coloring. */
1792
+ color?: EncodingChannel;
1672
1793
  /** Tooltip encoding (optional). */
1673
1794
  tooltip?: EncodingChannel | EncodingChannel[];
1674
1795
  }
@@ -1682,16 +1803,24 @@ export interface TileMapSpec {
1682
1803
  /**
1683
1804
  * Data for the tile map. Accepts either:
1684
1805
  * - A record mapping state codes to numeric values: `{ "CA": 12000, "TX": 8500 }`
1806
+ * - A record mapping state codes to string categories: `{ "CA": "high", "TX": "low" }`
1685
1807
  * - Tabular data rows with state and value fields (requires encoding)
1686
1808
  */
1687
- data: Record<string, number | null> | DataRow[];
1809
+ data: Record<string, number | string | null> | DataRow[];
1688
1810
  /**
1689
1811
  * Encoding channels mapping data fields to visual properties.
1690
1812
  * Required when data is DataRow[]. Auto-generated when data is a record map.
1691
1813
  */
1692
1814
  encoding?: TileMapEncoding;
1693
- /** Sequential color palette. Defaults to 'blue'. */
1815
+ /** Sequential color palette. Defaults to 'blue'. Only used in quantitative mode. */
1694
1816
  palette?: TileMapPalette;
1817
+ /**
1818
+ * Custom category-to-color mapping for categorical tilemaps.
1819
+ * Keys are category values, values are CSS color strings.
1820
+ * When provided, forces categorical mode regardless of encoding.
1821
+ * Example: `{ "medical_only": "#ee4a73", "religious": "#e07d00", "philosophical": "#06b6d4" }`
1822
+ */
1823
+ colors?: Record<string, string>;
1695
1824
  /** Editorial chrome (title, subtitle, source, byline, footer). */
1696
1825
  chrome?: Chrome;
1697
1826
  /** Legend display configuration. */
@@ -5,6 +5,33 @@
5
5
  * ResolvedTheme is the engine-internal fully resolved version (no optionals).
6
6
  */
7
7
 
8
+ // ---------------------------------------------------------------------------
9
+ // Token value (light/dark pair)
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /**
13
+ * A color token value: either a plain CSS color string, or a light/dark pair.
14
+ * When a pair is given, the resolver picks the appropriate value based on
15
+ * dark mode state. When a plain string is given, dark-mode adaptation
16
+ * derives the dark value algorithmically (existing behavior).
17
+ */
18
+ export type TokenValue = string | { light: string; dark: string };
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Series strategy
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Series color assignment strategy.
26
+ *
27
+ * - `'palette'` — always assign from the full categorical palette (current
28
+ * default, zero visual drift).
29
+ * - `'accent-neutral'` — assign by series count: a single series gets the
30
+ * accent (first categorical color) only; 2-4 series get accent for the
31
+ * first and neutral grays for the rest; 5+ fall back to the full palette.
32
+ */
33
+ export type SeriesStrategy = 'palette' | 'accent-neutral';
34
+
8
35
  // ---------------------------------------------------------------------------
9
36
  // Color palette types
10
37
  // ---------------------------------------------------------------------------
@@ -154,6 +181,8 @@ export interface Theme {
154
181
  borderRadius: number;
155
182
  /** Default chrome text styles. */
156
183
  chrome: ThemeChromeDefaults;
184
+ /** Series color assignment strategy. Default: 'palette' (full categorical). */
185
+ seriesStrategy: SeriesStrategy;
157
186
  }
158
187
 
159
188
  /**
@@ -170,4 +199,11 @@ export interface Theme {
170
199
  export interface ResolvedTheme extends Theme {
171
200
  /** Whether dark mode adaptations have been applied to this theme. */
172
201
  isDark: boolean;
202
+ /**
203
+ * Original light/dark token pairs from the user's ThemeConfig. Used
204
+ * by adaptTheme() to skip algorithmic adaptation for tokens with
205
+ * explicit dark values. Engine-internal, not part of the public API.
206
+ * @internal
207
+ */
208
+ _tokenPairs?: Record<string, { light: string; dark: string }>;
173
209
  }