@opendata-ai/openchart-engine 7.10.0 → 8.0.0-rc.1

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.
Files changed (159) hide show
  1. package/dist/index.d.ts +142 -7
  2. package/dist/index.js +16182 -10032
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
  6. package/src/__tests__/annotation-behavior.test.ts +721 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/dimensions.test.ts +49 -4
  12. package/src/__tests__/helpers/invariants.ts +54 -20
  13. package/src/__tests__/known-bugs.test.ts +455 -2
  14. package/src/__tests__/legend.test.ts +120 -4
  15. package/src/__tests__/ranking-recipes.test.ts +260 -0
  16. package/src/__tests__/scales.test.ts +9 -3
  17. package/src/__tests__/series-search.test.ts +92 -0
  18. package/src/__tests__/spec-sugar.test.ts +801 -0
  19. package/src/__tests__/you-draw-it.test.ts +160 -0
  20. package/src/annotations/__tests__/compute.test.ts +207 -92
  21. package/src/annotations/__tests__/geometry.test.ts +66 -0
  22. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  23. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  24. package/src/annotations/__tests__/thinning.test.ts +148 -0
  25. package/src/annotations/collisions.ts +130 -84
  26. package/src/annotations/compute.ts +83 -85
  27. package/src/annotations/constants.ts +116 -6
  28. package/src/annotations/geometry.ts +222 -77
  29. package/src/annotations/move.ts +135 -0
  30. package/src/annotations/placement.ts +62 -14
  31. package/src/annotations/resolve-range.ts +2 -0
  32. package/src/annotations/resolve-refline.ts +2 -0
  33. package/src/annotations/resolve-text.ts +312 -111
  34. package/src/annotations/rich-text.ts +65 -0
  35. package/src/annotations/thinning.ts +84 -10
  36. package/src/barlist/compile-barlist.ts +16 -6
  37. package/src/charts/_shared/format-label-value.ts +9 -8
  38. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  39. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  40. package/src/charts/bar/compute.ts +2 -6
  41. package/src/charts/bar/index.ts +8 -1
  42. package/src/charts/bar/labels.ts +3 -3
  43. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  44. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  45. package/src/charts/beeswarm/compute.ts +203 -0
  46. package/src/charts/beeswarm/dodge.ts +155 -0
  47. package/src/charts/beeswarm/index.ts +32 -0
  48. package/src/charts/builtin.ts +12 -1
  49. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  50. package/src/charts/calendar/compute.ts +319 -0
  51. package/src/charts/calendar/index.ts +32 -0
  52. package/src/charts/column/__tests__/compute.test.ts +21 -7
  53. package/src/charts/column/__tests__/labels.test.ts +76 -1
  54. package/src/charts/column/compute.ts +2 -6
  55. package/src/charts/column/index.ts +10 -2
  56. package/src/charts/column/labels.ts +95 -21
  57. package/src/charts/dot/index.ts +8 -1
  58. package/src/charts/dot/labels.ts +3 -3
  59. package/src/charts/line/__tests__/compute.test.ts +15 -19
  60. package/src/charts/line/area.ts +8 -23
  61. package/src/charts/line/compute.ts +32 -2
  62. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  63. package/src/charts/parliament/compute.ts +313 -0
  64. package/src/charts/parliament/index.ts +32 -0
  65. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  66. package/src/charts/pie/compute.ts +63 -8
  67. package/src/charts/pie/labels.ts +1 -0
  68. package/src/charts/range/__tests__/compute.test.ts +486 -0
  69. package/src/charts/range/compute.ts +273 -0
  70. package/src/charts/range/index.ts +82 -0
  71. package/src/charts/range/labels.ts +169 -0
  72. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  73. package/src/charts/rect/index.ts +127 -0
  74. package/src/charts/scatter/compute.ts +7 -21
  75. package/src/charts/text/__tests__/compute.test.ts +307 -0
  76. package/src/charts/text/index.ts +44 -7
  77. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  78. package/src/charts/tick/index.ts +7 -4
  79. package/src/charts/utils.ts +5 -3
  80. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  81. package/src/charts/waffle/compute.ts +197 -0
  82. package/src/charts/waffle/index.ts +31 -0
  83. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  84. package/src/compile/__tests__/data-clip.test.ts +68 -0
  85. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  86. package/src/compile/__tests__/size-scale.test.ts +108 -0
  87. package/src/compile/color-scale-range.ts +54 -33
  88. package/src/compile/data-clip.ts +21 -6
  89. package/src/compile/fill-patterns.ts +74 -0
  90. package/src/compile/layer.ts +117 -21
  91. package/src/compile/size-scale.ts +139 -0
  92. package/src/compile/spec-sugar.ts +758 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +315 -103
  95. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  114. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  115. package/src/compiler/__tests__/normalize.test.ts +65 -1
  116. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  117. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  118. package/src/compiler/__tests__/validate.test.ts +156 -0
  119. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  120. package/src/compiler/a11y-warnings.ts +110 -0
  121. package/src/compiler/normalize.ts +150 -7
  122. package/src/compiler/types.ts +11 -0
  123. package/src/compiler/validate.ts +664 -18
  124. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  125. package/src/endpoint-labels/compute.ts +40 -12
  126. package/src/endpoint-labels/format.ts +45 -15
  127. package/src/endpoint-labels/predict.ts +50 -36
  128. package/src/format/__tests__/field-format.test.ts +164 -0
  129. package/src/format/field-format.ts +27 -0
  130. package/src/geo/__tests__/compile-map.test.ts +621 -0
  131. package/src/geo/compile-map.ts +856 -0
  132. package/src/geo/join.ts +115 -0
  133. package/src/geo/projections.ts +45 -0
  134. package/src/geo/types.ts +24 -0
  135. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  136. package/src/graphs/compile-graph.ts +27 -18
  137. package/src/index.ts +28 -2
  138. package/src/layout/axes/ticks.ts +59 -10
  139. package/src/layout/axes.ts +30 -2
  140. package/src/layout/dimensions.ts +178 -95
  141. package/src/layout/facet.ts +14 -3
  142. package/src/layout/plan.ts +86 -35
  143. package/src/layout/scales.ts +132 -15
  144. package/src/legend/__tests__/continuous.test.ts +318 -0
  145. package/src/legend/__tests__/size.test.ts +348 -0
  146. package/src/legend/__tests__/suppression.test.ts +17 -1
  147. package/src/legend/compute.ts +126 -14
  148. package/src/legend/continuous.ts +327 -0
  149. package/src/legend/size.ts +407 -0
  150. package/src/legend/suppression.ts +8 -7
  151. package/src/legend/wrap.ts +8 -0
  152. package/src/sankey/compile-sankey.ts +31 -19
  153. package/src/tables/format-cells.ts +16 -7
  154. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  155. package/src/tilemap/compile-tilemap.ts +24 -7
  156. package/src/tooltips/__tests__/compute.test.ts +2 -2
  157. package/src/tooltips/compute.ts +422 -41
  158. package/src/transforms/__tests__/conditional.test.ts +18 -0
  159. package/src/transforms/aggregate.ts +2 -1
@@ -0,0 +1,115 @@
1
+ import type { CompileWarning } from '@opendata-ai/openchart-core';
2
+
3
+ export function normalizeFips(value: string | number, digits: 2 | 5 = 2): string {
4
+ return String(value).padStart(digits, '0');
5
+ }
6
+
7
+ function detectKeyFormat(keys: string[]): string | null {
8
+ if (keys.length === 0) return null;
9
+ const allNumeric = keys.every((k) => /^\d+$/.test(k));
10
+ if (allNumeric) {
11
+ const maxLen = Math.max(...keys.map((k) => k.length));
12
+ if (maxLen <= 2) return 'state-fips';
13
+ if (maxLen <= 3) return 'iso-numeric';
14
+ if (maxLen <= 5) return 'county-fips';
15
+ }
16
+ if (keys.every((k) => /^[A-Z]{2}$/.test(k))) return 'iso-alpha2';
17
+ if (keys.every((k) => /^[A-Z]{3}$/.test(k))) return 'iso-alpha3';
18
+ return null;
19
+ }
20
+
21
+ function suggestNormalization(unmatchedKeys: string[], featureIds: string[]): string | null {
22
+ const dataFormat = detectKeyFormat(unmatchedKeys);
23
+ const featureFormat = detectKeyFormat(featureIds);
24
+
25
+ if (dataFormat === 'state-fips' && featureFormat === 'state-fips') {
26
+ const needsPadding = unmatchedKeys.some((k) => k.length === 1);
27
+ if (needsPadding) return 'Data keys appear to need zero-padding (e.g. "1" -> "01").';
28
+ }
29
+ if (dataFormat === 'county-fips') {
30
+ const needsPadding = unmatchedKeys.some((k) => k.length < 5);
31
+ if (needsPadding)
32
+ return 'County FIPS codes should be 5 digits with zero-padding (e.g. "1001" -> "01001").';
33
+ }
34
+ return null;
35
+ }
36
+
37
+ export interface JoinResult {
38
+ joined: Map<string | number, Record<string, unknown>>;
39
+ warnings: CompileWarning[];
40
+ }
41
+
42
+ export function joinDataToFeatures(
43
+ features: Array<{ id: string | number; properties: Record<string, unknown> }>,
44
+ data: Record<string, unknown>[],
45
+ dataKeyField: string,
46
+ featureIdField: string,
47
+ ): JoinResult {
48
+ const warnings: CompileWarning[] = [];
49
+
50
+ const dataLookup = new Map<string, Record<string, unknown>>();
51
+ for (const row of data) {
52
+ const key = String(row[dataKeyField]);
53
+ dataLookup.set(key, row);
54
+ }
55
+
56
+ const joined = new Map<string | number, Record<string, unknown>>();
57
+ const usedDataKeys = new Set<string>();
58
+ let lowerLookup: Map<string, Record<string, unknown>> | null = null;
59
+ let unmatchedFeatureCount = 0;
60
+
61
+ for (const feature of features) {
62
+ const featureId =
63
+ featureIdField === 'id'
64
+ ? String(feature.id)
65
+ : String(feature.properties[featureIdField] ?? feature.id);
66
+
67
+ let match = dataLookup.get(featureId);
68
+
69
+ // Try case-insensitive fallback for name-based joins
70
+ if (!match) {
71
+ if (!lowerLookup) {
72
+ lowerLookup = new Map<string, Record<string, unknown>>();
73
+ for (const [key, row] of dataLookup) {
74
+ lowerLookup.set(key.toLowerCase(), row);
75
+ }
76
+ }
77
+ match = lowerLookup.get(featureId.toLowerCase());
78
+ }
79
+
80
+ if (match) {
81
+ joined.set(feature.id, match);
82
+ usedDataKeys.add(String(match[dataKeyField]));
83
+ } else {
84
+ unmatchedFeatureCount++;
85
+ }
86
+ }
87
+
88
+ const unmatchedDataKeys: string[] = [];
89
+ for (const key of dataLookup.keys()) {
90
+ if (!usedDataKeys.has(key)) {
91
+ unmatchedDataKeys.push(key);
92
+ }
93
+ }
94
+
95
+ if (unmatchedDataKeys.length > 0) {
96
+ const sampleKeys = unmatchedDataKeys.slice(0, 5);
97
+ const sampleFeatureIds = features.slice(0, 3).map((f) => String(f.id));
98
+ const suggestion = suggestNormalization(unmatchedDataKeys, sampleFeatureIds);
99
+ warnings.push({
100
+ code: 'UNMATCHED_DATA_KEYS',
101
+ message: `${unmatchedDataKeys.length} data row(s) matched no geo feature. Keys: ${sampleKeys.join(', ')}${unmatchedDataKeys.length > 5 ? ` (and ${unmatchedDataKeys.length - 5} more)` : ''}.${suggestion ? ` ${suggestion}` : ''} Expected format matches feature IDs like: ${sampleFeatureIds.join(', ')}.`,
102
+ context: { keys: unmatchedDataKeys },
103
+ });
104
+ }
105
+
106
+ if (unmatchedFeatureCount > 0) {
107
+ warnings.push({
108
+ code: 'UNMATCHED_FEATURES',
109
+ message: `${unmatchedFeatureCount} geo feature(s) have no matching data and will use neutral fill.`,
110
+ context: { count: unmatchedFeatureCount },
111
+ });
112
+ }
113
+
114
+ return { joined, warnings };
115
+ }
@@ -0,0 +1,45 @@
1
+ import type { MapProjection } from '@opendata-ai/openchart-core';
2
+ import type { GeoIdentityTransform, GeoPermissibleObjects, GeoProjection } from 'd3-geo';
3
+ import { geoAlbersUsa, geoBounds, geoEqualEarth, geoIdentity, geoMercator } from 'd3-geo';
4
+
5
+ // Mercator inflates polar regions toward infinity. Fitting against features
6
+ // entirely below this latitude (Antarctica) wastes most of the map area on
7
+ // empty ocean. Exclude them from the fit; they still render if in view.
8
+ const MERCATOR_FIT_MIN_LAT = -60;
9
+
10
+ export function createProjection(
11
+ type: MapProjection,
12
+ width: number,
13
+ height: number,
14
+ geojson: GeoPermissibleObjects,
15
+ ): GeoProjection | GeoIdentityTransform {
16
+ if (type === 'identity') {
17
+ return geoIdentity().fitSize([width, height], geojson);
18
+ }
19
+
20
+ const projection =
21
+ type === 'albersUsa' ? geoAlbersUsa() : type === 'equalEarth' ? geoEqualEarth() : geoMercator();
22
+
23
+ const fitGeometry = type === 'mercator' ? excludePolarFeatures(geojson) : geojson;
24
+ projection.fitSize([width, height], fitGeometry);
25
+ return projection;
26
+ }
27
+
28
+ function excludePolarFeatures(geojson: GeoPermissibleObjects): GeoPermissibleObjects {
29
+ // GeoPermissibleObjects doesn't include FeatureCollection in its type union,
30
+ // but topojson.feature() returns one at runtime. Work with the runtime shape.
31
+ const obj = geojson as unknown as Record<string, unknown>;
32
+ if (obj.type !== 'FeatureCollection' || !Array.isArray(obj.features)) return geojson;
33
+
34
+ const features = obj.features as Array<Record<string, unknown>>;
35
+ const filtered = features.filter((f) => {
36
+ try {
37
+ const b = geoBounds(f as unknown as GeoPermissibleObjects);
38
+ return b[1][1] > MERCATOR_FIT_MIN_LAT;
39
+ } catch {
40
+ return true;
41
+ }
42
+ });
43
+ if (filtered.length === 0) return geojson;
44
+ return { type: 'FeatureCollection', features: filtered } as unknown as GeoPermissibleObjects;
45
+ }
@@ -0,0 +1,24 @@
1
+ import type {
2
+ AnimationSpec,
3
+ DarkMode,
4
+ LegendConfig,
5
+ MapEncoding,
6
+ MapGeo,
7
+ ThemeConfig,
8
+ } from '@opendata-ai/openchart-core';
9
+
10
+ import type { NormalizedChrome } from '../compiler/types';
11
+
12
+ export interface NormalizedMapSpec {
13
+ type: 'map';
14
+ geo: Required<MapGeo>;
15
+ data: Record<string, unknown>[];
16
+ encoding: MapEncoding;
17
+ chrome: NormalizedChrome;
18
+ legend?: LegendConfig;
19
+ theme: ThemeConfig;
20
+ darkMode: DarkMode;
21
+ watermark: boolean;
22
+ animation?: AnimationSpec;
23
+ valueFormat?: string;
24
+ }
@@ -228,6 +228,31 @@ describe('compileGraph', () => {
228
228
  });
229
229
  });
230
230
 
231
+ describe('legend', () => {
232
+ it('is empty when there is no color encoding and no clustering', () => {
233
+ // Every node shares one fill, so a legend would list each node label
234
+ // against an identical swatch and communicate nothing.
235
+ const result = compileGraph(makeBasicGraphSpec(), compileOptions);
236
+
237
+ expect(result.legend.entries).toEqual([]);
238
+ });
239
+
240
+ it('entries come from the nodeColor field, not node labels', () => {
241
+ const result = compileGraph(makeEncodedGraphSpec(), compileOptions);
242
+
243
+ const labels = result.legend.entries.map((e) => e.label).sort();
244
+ expect(labels).toEqual(['X', 'Y']);
245
+ });
246
+
247
+ it('is empty when the nodeColor field has only one category', () => {
248
+ const spec = makeEncodedGraphSpec();
249
+ for (const node of spec.nodes) node.group = 'X';
250
+ const result = compileGraph(spec, compileOptions);
251
+
252
+ expect(result.legend.entries).toEqual([]);
253
+ });
254
+ });
255
+
231
256
  describe('tooltips', () => {
232
257
  it('generates tooltip descriptors for each node', () => {
233
258
  const result = compileGraph(makeBasicGraphSpec(), compileOptions);
@@ -22,14 +22,21 @@ import type {
22
22
  TooltipContent,
23
23
  TooltipField,
24
24
  } from '@opendata-ai/openchart-core';
25
- import { adaptTheme, computeChrome, resolveTheme } from '@opendata-ai/openchart-core';
26
-
25
+ import {
26
+ adaptTheme,
27
+ computeChrome,
28
+ defaultNumberFormatter,
29
+ resolveTheme,
30
+ } from '@opendata-ai/openchart-core';
31
+ import { emitSpecWarnings } from '../compile/spec-sugar';
27
32
  import { compile as compileSpec } from '../compiler/index';
28
33
  import type { NormalizedGraphSpec } from '../compiler/types';
29
34
  import { applyCommunityColors, assignCommunities, buildCommunityColorMap } from './community';
30
35
  import { resolveEdgeVisuals, resolveNodeVisuals } from './encoding';
31
36
  import type { CompiledGraphNode, GraphCompilation, SimulationConfig } from './types';
32
37
 
38
+ const graphNumberFormatter = defaultNumberFormatter({ allIntegers: false, surface: 'chart' });
39
+
33
40
  // ---------------------------------------------------------------------------
34
41
  // Constants
35
42
  // ---------------------------------------------------------------------------
@@ -74,31 +81,32 @@ function buildGraphLegend(
74
81
  shape: 'circle' as const,
75
82
  active: true,
76
83
  }));
77
- } else {
84
+ } else if (nodeColorField) {
78
85
  // Build legend from nodeColor encoding: group by the color field value
79
86
  // so each legend entry shows the categorical value (e.g. "Dataset", "bls")
80
87
  // rather than an arbitrary node label.
81
88
  const categoryColors = new Map<string, string>();
82
89
  for (const node of nodes) {
83
- const category = nodeColorField
84
- ? String(node.data[nodeColorField] ?? node.label ?? node.id)
85
- : (node.label ?? node.id);
90
+ const category = String(node.data[nodeColorField] ?? node.label ?? node.id);
86
91
  if (!categoryColors.has(category)) {
87
92
  categoryColors.set(category, node.fill);
88
93
  }
89
94
  }
90
95
 
91
96
  // Only show legend if there are multiple categories
92
- if (categoryColors.size <= 1) {
93
- entries = [];
94
- } else {
95
- entries = [...categoryColors.entries()].map(([label, color]) => ({
96
- label,
97
- color,
98
- shape: 'circle' as const,
99
- active: true,
100
- }));
101
- }
97
+ entries =
98
+ categoryColors.size <= 1
99
+ ? []
100
+ : [...categoryColors.entries()].map(([label, color]) => ({
101
+ label,
102
+ color,
103
+ shape: 'circle' as const,
104
+ active: true,
105
+ }));
106
+ } else {
107
+ // No communities and no color encoding: every node shares one fill, so a
108
+ // legend would just list every node label against identical swatches.
109
+ entries = [];
102
110
  }
103
111
 
104
112
  return {
@@ -144,7 +152,7 @@ function buildGraphTooltips(nodes: CompiledGraphNode[]): Map<string, TooltipCont
144
152
 
145
153
  fields.push({
146
154
  label: key,
147
- value: typeof value === 'number' ? value.toLocaleString() : String(value),
155
+ value: typeof value === 'number' ? graphNumberFormatter(value) : String(value),
148
156
  });
149
157
  }
150
158
 
@@ -185,7 +193,8 @@ function buildGraphTooltips(nodes: CompiledGraphNode[]): Map<string, TooltipCont
185
193
  */
186
194
  export function compileGraph(spec: unknown, options: CompileOptions): GraphCompilation {
187
195
  // 1. Validate + normalize
188
- const { spec: normalized } = compileSpec(spec);
196
+ const { spec: normalized, warnings } = compileSpec(spec);
197
+ emitSpecWarnings(warnings, options.onWarn);
189
198
 
190
199
  if (!('type' in normalized) || normalized.type !== 'graph') {
191
200
  throw new Error(
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  compileChart,
18
18
  compileGraph,
19
19
  compileLayer,
20
+ compileMap,
20
21
  compileSankey,
21
22
  compileTable,
22
23
  compileTileMap,
@@ -69,6 +70,8 @@ export type { NormalizedSankeySpec } from './sankey/types';
69
70
 
70
71
  export type { NormalizedBarListSpec } from './barlist/types';
71
72
 
73
+ export type { NormalizedMapSpec } from './geo/types';
74
+
72
75
  export type { NormalizedTileMapSpec } from './tilemap/types';
73
76
 
74
77
  // ---------------------------------------------------------------------------
@@ -76,9 +79,19 @@ export type { NormalizedTileMapSpec } from './tilemap/types';
76
79
  // ---------------------------------------------------------------------------
77
80
 
78
81
  export type { AnnotationContext } from './annotations/compute';
79
- export type { AnnotationMeasureTextFn } from './annotations/geometry';
80
- export { heuristicMeasure } from './annotations/geometry';
82
+ export {
83
+ ARROWHEAD_LENGTH,
84
+ DEFAULT_ANNOTATION_FONT_SIZE,
85
+ DEFAULT_LINE_HEIGHT,
86
+ } from './annotations/constants';
87
+ export type { AnnotationMeasureTextFn, ArrowheadPoints } from './annotations/geometry';
88
+ export {
89
+ computeArrowheadPoints,
90
+ heuristicMeasure,
91
+ } from './annotations/geometry';
81
92
  export type { ObstacleKind, PlacementObstacle } from './annotations/placement';
93
+ export type { RichSpan } from './annotations/rich-text';
94
+ export { BOLD_SPAN_FONT_WEIGHT, parseAnnotationSpans } from './annotations/rich-text';
82
95
 
83
96
  // ---------------------------------------------------------------------------
84
97
  // Compiler pipeline (spec validation, normalization, generic compile)
@@ -112,6 +125,17 @@ export {
112
125
  registerChartRenderer,
113
126
  } from './charts/registry';
114
127
 
128
+ // ---------------------------------------------------------------------------
129
+ // Legend geometry
130
+ // ---------------------------------------------------------------------------
131
+
132
+ // Used by the vanilla mount to compute facet height growth for auto-height
133
+ // figures; single-sourcing avoids constant divergence with facet.ts.
134
+ export { facetMinHeight } from './layout/facet';
135
+ // Used by the vanilla mount to compute the top-legend block when growing
136
+ // auto-height figures; exporting it keeps the gap math single-sourced.
137
+ export { legendGap } from './legend/wrap';
138
+
115
139
  // ---------------------------------------------------------------------------
116
140
  // Data transforms
117
141
  // ---------------------------------------------------------------------------
@@ -141,6 +165,8 @@ export type {
141
165
  GraphLayout,
142
166
  GraphSpec,
143
167
  LayerSpec,
168
+ MapLayout,
169
+ MapSpec,
144
170
  SankeyLayout,
145
171
  SankeySpec,
146
172
  TableLayout,
@@ -7,13 +7,12 @@
7
7
 
8
8
  import type { AxisLabelDensity, AxisTick, DataRow } from '@opendata-ai/openchart-core';
9
9
  import {
10
- abbreviateNumber,
11
- buildD3Formatter,
12
10
  buildTemporalFormatter,
13
11
  formatDate,
14
- formatNumber,
12
+ resolveNumberFormatter,
15
13
  } from '@opendata-ai/openchart-core';
16
14
  import type { ScaleBand } from 'd3-scale';
15
+ import { resolveFieldFormatter } from '../../format/field-format';
17
16
  import type { D3CategoricalScale, D3ContinuousScale, ResolvedScale } from '../scales';
18
17
 
19
18
  /**
@@ -122,8 +121,18 @@ const NUMERIC_SCALE_TYPES = new Set([
122
121
  /** Set of temporal scale types. */
123
122
  const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
124
123
 
124
+ interface TickContext {
125
+ step?: number;
126
+ maxAbsTick?: number;
127
+ }
128
+
125
129
  /** Format a tick value based on the scale type. */
126
- function formatTickLabel(value: unknown, resolvedScale: ResolvedScale, compact = false): string {
130
+ function formatTickLabel(
131
+ value: unknown,
132
+ resolvedScale: ResolvedScale,
133
+ compact = false,
134
+ tickContext?: TickContext,
135
+ ): string {
127
136
  const axisConfig = resolvedScale.channel.axis || undefined;
128
137
  const formatStr = axisConfig?.format;
129
138
  const suffix = axisConfig?.labelSuffix ?? '';
@@ -139,17 +148,54 @@ function formatTickLabel(value: unknown, resolvedScale: ResolvedScale, compact =
139
148
  if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
140
149
  const num = value as number;
141
150
  if (formatStr) {
142
- const fmt = buildD3Formatter(formatStr);
151
+ const ctx = {
152
+ ...resolvedScale.formatContext,
153
+ step: tickContext?.step,
154
+ stepReference: tickContext?.maxAbsTick,
155
+ };
156
+ const fmt = resolveNumberFormatter(formatStr, ctx);
143
157
  if (fmt) return fmt(num) + suffix;
144
158
  }
145
- // Abbreviate large numbers for axis labels
146
- if (Math.abs(num) >= 1000) return abbreviateNumber(num) + suffix;
147
- return formatNumber(num) + suffix;
159
+ const fmt = resolveFieldFormatter({
160
+ channelFormat: resolvedScale.channel.format,
161
+ context: {
162
+ ...resolvedScale.formatContext,
163
+ step: tickContext?.step,
164
+ stepReference: tickContext?.maxAbsTick,
165
+ },
166
+ });
167
+ return fmt(num) + suffix;
148
168
  }
149
169
 
150
170
  return String(value) + suffix;
151
171
  }
152
172
 
173
+ function computeTickContext(ticks: unknown[]): TickContext | undefined {
174
+ if (ticks.length < 2) return undefined;
175
+ const nums = ticks.filter((v) => typeof v === 'number' && Number.isFinite(v)) as number[];
176
+ if (nums.length < 2) return undefined;
177
+
178
+ const firstGap = Math.abs(nums[1] - nums[0]);
179
+ if (firstGap === 0) return undefined;
180
+
181
+ let uniform = true;
182
+ for (let i = 2; i < nums.length; i++) {
183
+ const gap = Math.abs(nums[i] - nums[i - 1]);
184
+ const relDiff = Math.abs(gap - firstGap) / Math.max(firstGap, 1e-15);
185
+ if (relDiff > 1e-9) {
186
+ uniform = false;
187
+ break;
188
+ }
189
+ }
190
+
191
+ if (!uniform) return undefined;
192
+
193
+ return {
194
+ step: firstGap,
195
+ maxAbsTick: Math.max(Math.abs(nums[0]), Math.abs(nums[nums.length - 1])),
196
+ };
197
+ }
198
+
153
199
  /**
154
200
  * Generate ticks for a continuous scale (linear, time, log, pow, sqrt, symlog).
155
201
  *
@@ -225,10 +271,12 @@ export function buildContinuousTicks(
225
271
  }
226
272
  }
227
273
 
274
+ const tickCtx = computeTickContext(ticks);
275
+
228
276
  return ticks.map((value: unknown) => ({
229
277
  value,
230
278
  position: scale(value as number & Date) as number,
231
- label: formatTickLabel(value, resolvedScale, compact),
279
+ label: formatTickLabel(value, resolvedScale, compact, tickCtx),
232
280
  }));
233
281
  }
234
282
 
@@ -333,6 +381,7 @@ export function categoricalTicks(
333
381
  */
334
382
  export function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedScale): AxisTick[] {
335
383
  const scale = resolvedScale.scale;
384
+ const tickCtx = computeTickContext(values);
336
385
  return values.map((value) => {
337
386
  let position: number;
338
387
  if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
@@ -354,7 +403,7 @@ export function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedS
354
403
  return {
355
404
  value,
356
405
  position,
357
- label: formatTickLabel(value, resolvedScale),
406
+ label: formatTickLabel(value, resolvedScale, false, tickCtx),
358
407
  };
359
408
  });
360
409
  }
@@ -24,6 +24,7 @@ import {
24
24
  computeXAxisExtentFromLabels,
25
25
  estimateTextWidth,
26
26
  getAxisTitleOffset,
27
+ truncateRotatedLabel,
27
28
  X_AXIS_TITLE_BAND_ROTATED,
28
29
  } from '@opendata-ai/openchart-core';
29
30
  import type { ScaleBand } from 'd3-scale';
@@ -513,6 +514,30 @@ export function computeAxes(
513
514
  ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText);
514
515
  }
515
516
 
517
+ // Truncate rotated labels that would overflow the capped reservation.
518
+ // computeXAxisExtentFromLabels clamps the reserved band to
519
+ // X_AXIS_ROTATED_EXTENT_CAP, but nothing shortened the drawn string, so a
520
+ // long label rendered straight past the axis and through the source line.
521
+ // Clip to the same budget the reservation is computed from. `fullLabel`
522
+ // carries the original text so tooltips and the a11y table stay complete.
523
+ if (tickAngle && Math.abs(tickAngle) > 10) {
524
+ // MeasureTextFn returns { width }, not a number — adapt it the same way
525
+ // measureLabel does, or every comparison against the width budget is NaN
526
+ // and the search collapses every label to a bare ellipsis.
527
+ const measureWidth = (text: string, size: number, weight?: number) =>
528
+ measureLabel(text, size, weight ?? fontWeight, measureText);
529
+ ticks = ticks.map((t) => {
530
+ const truncated = truncateRotatedLabel(
531
+ t.label,
532
+ tickAngle,
533
+ fontSize,
534
+ fontWeight,
535
+ measureWidth,
536
+ );
537
+ return truncated === t.label ? t : { ...t, label: truncated, fullLabel: t.label };
538
+ });
539
+ }
540
+
516
541
  const axisTitle = axisConfig?.title;
517
542
  const xLabelColor = axisConfig?.labelColor;
518
543
  // X-axis defaults to gutter (no inline mode is sensible for the x axis
@@ -637,8 +662,11 @@ export function computeAxes(
637
662
 
638
663
  result.y = {
639
664
  ticks,
640
- // Y-axis gridlines are shown by default (standard editorial practice)
641
- gridlines,
665
+ // Y-axis gridlines are shown by default (standard editorial practice).
666
+ // Not for heatmaps: the cells tile the plot, so a gridline can only draw
667
+ // *through* them. The cell grid already is the grid. Explicit `grid: true`
668
+ // still wins.
669
+ gridlines: (axisConfig?.grid ?? dataContext?.markType !== 'rect') ? gridlines : [],
642
670
  label: axisTitle,
643
671
  labelStyle: yLabelColor ? { ...axisLabelStyle, fill: yLabelColor } : axisLabelStyle,
644
672
  tickLabelStyle: yLabelColor ? { ...tickLabelStyle, fill: yLabelColor } : tickLabelStyle,