@opendata-ai/openchart-engine 6.25.4 → 6.26.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,6 +10,8 @@ import type {
10
10
  AxisLabelDensity,
11
11
  AxisLayout,
12
12
  AxisTick,
13
+ DataRow,
14
+ Encoding,
13
15
  Gridline,
14
16
  LayoutStrategy,
15
17
  MeasureTextFn,
@@ -191,6 +193,14 @@ export interface AxesResult {
191
193
  y?: AxisLayout;
192
194
  }
193
195
 
196
+ /** Optional data context for axis computation (enables labelField subtitles). */
197
+ export interface AxesDataContext {
198
+ /** The data rows for subtitle lookup. */
199
+ data: DataRow[];
200
+ /** The encoding object to resolve field names. */
201
+ encoding: Encoding;
202
+ }
203
+
194
204
  /**
195
205
  * Compute axis layouts with tick positions, labels, and axis lines.
196
206
  *
@@ -199,6 +209,7 @@ export interface AxesResult {
199
209
  * @param strategy - Responsive layout strategy.
200
210
  * @param theme - Resolved theme for styling.
201
211
  * @param measureText - Optional real text measurement from the adapter.
212
+ * @param dataContext - Optional data context for labelField subtitle support.
202
213
  */
203
214
  export function computeAxes(
204
215
  scales: ResolvedScales,
@@ -206,6 +217,7 @@ export function computeAxes(
206
217
  strategy: LayoutStrategy,
207
218
  theme: ResolvedTheme,
208
219
  measureText?: MeasureTextFn,
220
+ dataContext?: AxesDataContext,
209
221
  ): AxesResult {
210
222
  const result: AxesResult = {};
211
223
  const baseDensity = strategy.axisLabelDensity;
@@ -362,7 +374,21 @@ export function computeAxes(
362
374
  if (axisConfig?.values) {
363
375
  allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
364
376
  } else if (!isContinuousY) {
365
- allTicks = categoricalTicks(scales.y, yDensity, 'vertical');
377
+ const yFieldName = dataContext?.encoding.y?.field;
378
+ const yLabelField = axisConfig?.labelField;
379
+ allTicks = categoricalTicks(
380
+ scales.y,
381
+ yDensity,
382
+ 'vertical',
383
+ undefined,
384
+ undefined,
385
+ undefined,
386
+ undefined,
387
+ undefined,
388
+ yFieldName && yLabelField && dataContext
389
+ ? { data: dataContext.data, fieldName: yFieldName, labelField: yLabelField }
390
+ : undefined,
391
+ );
366
392
  } else {
367
393
  allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
368
394
  }
@@ -275,10 +275,26 @@ export function computeDimensions(
275
275
  ) {
276
276
  // Category labels on the left for bar/dot charts
277
277
  const yField = encoding.y.field;
278
+ const yLabelField = (encoding.y.axis as Record<string, unknown> | undefined)?.labelField as
279
+ | string
280
+ | undefined;
278
281
  let maxLabelWidth = 0;
279
282
  for (const row of spec.data) {
280
283
  const label = String(row[yField] ?? '');
281
- const w = estimateTextWidth(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
284
+ let w = estimateTextWidth(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
285
+ // When labelField is set, add a gap and the subtitle width
286
+ if (yLabelField) {
287
+ const subtitle = String(row[yLabelField] ?? '');
288
+ if (subtitle) {
289
+ const gap = theme.fonts.sizes.axisTick * 0.6;
290
+ const subtitleWidth = estimateTextWidth(
291
+ subtitle,
292
+ theme.fonts.sizes.axisTick,
293
+ theme.fonts.weights.normal,
294
+ );
295
+ w += gap + subtitleWidth;
296
+ }
297
+ }
282
298
  if (w > maxLabelWidth) maxLabelWidth = w;
283
299
  }
284
300
  if (maxLabelWidth > 0) {
@@ -359,7 +375,7 @@ export function computeDimensions(
359
375
  }
360
376
 
361
377
  // Reserve legend space
362
- if (legendLayout.entries.length > 0) {
378
+ if ('entries' in legendLayout && legendLayout.entries.length > 0) {
363
379
  const gap = legendGap(width);
364
380
  if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
365
381
  margins.right += legendLayout.bounds.width + 8;
@@ -407,7 +423,9 @@ export function computeDimensions(
407
423
  const gap = legendGap(width);
408
424
  margins.top =
409
425
  newTop +
410
- (legendLayout.entries.length > 0 && legendLayout.position === 'top'
426
+ ('entries' in legendLayout &&
427
+ legendLayout.entries.length > 0 &&
428
+ legendLayout.position === 'top'
411
429
  ? legendLayout.bounds.height + gap
412
430
  : 0);
413
431
  margins.bottom = newBottom;
@@ -312,7 +312,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
312
312
  );
313
313
 
314
314
  // Reserve legend space by shrinking the drawing area
315
- const legendGap = legend.entries.length > 0 ? 4 : 0;
315
+ const legendGap = 'entries' in legend && legend.entries.length > 0 ? 4 : 0;
316
316
  const area: Rect = {
317
317
  x: fullArea.x,
318
318
  y: fullArea.y + legend.bounds.height + legendGap,
@@ -0,0 +1,322 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compileTileMap } from '../../compile';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Shared fixtures
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const basicSpec = {
9
+ type: 'tilemap' as const,
10
+ data: { CA: 5.4, TX: 4.1, NY: 4.5, FL: 3.3, IL: 4.6 } as Record<string, number>,
11
+ };
12
+
13
+ const fullSpec = {
14
+ type: 'tilemap' as const,
15
+ data: {
16
+ AL: 2.7,
17
+ AK: 6.4,
18
+ AZ: 3.5,
19
+ AR: 3.4,
20
+ CA: 5.4,
21
+ CO: 3.4,
22
+ CT: 4.1,
23
+ DE: 4.4,
24
+ FL: 3.3,
25
+ GA: 3.4,
26
+ HI: 3.2,
27
+ ID: 3.0,
28
+ IL: 4.6,
29
+ IN: 3.3,
30
+ IA: 2.7,
31
+ KS: 3.2,
32
+ KY: 4.4,
33
+ LA: 3.6,
34
+ ME: 3.6,
35
+ MD: 1.8,
36
+ MA: 3.3,
37
+ MI: 4.2,
38
+ MN: 2.8,
39
+ MS: 3.7,
40
+ MO: 3.5,
41
+ MT: 2.9,
42
+ NE: 2.2,
43
+ NV: 5.4,
44
+ NH: 2.4,
45
+ NJ: 4.8,
46
+ NM: 4.1,
47
+ NY: 4.5,
48
+ NC: 3.5,
49
+ ND: 1.9,
50
+ OH: 4.0,
51
+ OK: 3.9,
52
+ OR: 4.2,
53
+ PA: 3.4,
54
+ RI: 3.8,
55
+ SC: 3.3,
56
+ SD: 2.0,
57
+ TN: 3.5,
58
+ TX: 4.1,
59
+ UT: 2.9,
60
+ VT: 2.3,
61
+ VA: 2.9,
62
+ WA: 4.6,
63
+ WV: 4.0,
64
+ WI: 2.9,
65
+ WY: 3.2,
66
+ DC: 5.2,
67
+ } as Record<string, number>,
68
+ };
69
+
70
+ const defaultOptions = { width: 600, height: 400 };
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Tests
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe('compileTileMap', () => {
77
+ it('always renders all 51 state tiles', () => {
78
+ const result = compileTileMap(basicSpec, defaultOptions);
79
+ expect(result.tiles).toHaveLength(51);
80
+ });
81
+
82
+ it('marks data-bearing states as hasData: true', () => {
83
+ const result = compileTileMap(basicSpec, defaultOptions);
84
+
85
+ const caTile = result.tiles.find((t) => t.stateCode === 'CA')!;
86
+ expect(caTile.hasData).toBe(true);
87
+ expect(caTile.value).toBe(5.4);
88
+
89
+ const dataTiles = result.tiles.filter((t) => t.hasData);
90
+ expect(dataTiles).toHaveLength(5);
91
+ const codes = dataTiles.map((t) => t.stateCode).sort();
92
+ expect(codes).toEqual(['CA', 'FL', 'IL', 'NY', 'TX']);
93
+ });
94
+
95
+ it('marks missing states as hasData: false with neutral fill', () => {
96
+ const result = compileTileMap(basicSpec, defaultOptions);
97
+
98
+ const akTile = result.tiles.find((t) => t.stateCode === 'AK')!;
99
+ expect(akTile).toBeDefined();
100
+ expect(akTile.hasData).toBe(false);
101
+ expect(akTile.value).toBeNull();
102
+ expect(akTile.formattedValue).toBe('–');
103
+ });
104
+
105
+ it('all tiles have valid position and size (x >= 0, y >= 0, size > 0)', () => {
106
+ const result = compileTileMap(fullSpec, defaultOptions);
107
+
108
+ for (const tile of result.tiles) {
109
+ expect(tile.x).toBeGreaterThanOrEqual(0);
110
+ expect(tile.y).toBeGreaterThanOrEqual(0);
111
+ expect(tile.size).toBeGreaterThan(0);
112
+ }
113
+ });
114
+
115
+ it('data tiles have fill colors from the sequential palette', () => {
116
+ const result = compileTileMap(basicSpec, defaultOptions);
117
+
118
+ const dataTiles = result.tiles.filter((t) => t.hasData);
119
+ for (const tile of dataTiles) {
120
+ expect(tile.fill).toBeTruthy();
121
+ expect(typeof tile.fill).toBe('string');
122
+ }
123
+
124
+ const fills = new Set(dataTiles.map((t) => t.fill));
125
+ expect(fills.size).toBeGreaterThan(1);
126
+ });
127
+
128
+ it('compiles tabular DataRow[] data with encoding', () => {
129
+ const spec = {
130
+ type: 'tilemap' as const,
131
+ data: [
132
+ { code: 'CA', rate: 5.4 },
133
+ { code: 'TX', rate: 4.1 },
134
+ { code: 'NY', rate: 4.5 },
135
+ ],
136
+ encoding: {
137
+ state: { field: 'code', type: 'nominal' as const },
138
+ value: { field: 'rate', type: 'quantitative' as const },
139
+ },
140
+ };
141
+
142
+ const result = compileTileMap(spec, defaultOptions);
143
+
144
+ expect(result.tiles).toHaveLength(51);
145
+ const dataTiles = result.tiles.filter((t) => t.hasData);
146
+ expect(dataTiles).toHaveLength(3);
147
+ const codes = dataTiles.map((t) => t.stateCode).sort();
148
+ expect(codes).toEqual(['CA', 'NY', 'TX']);
149
+ });
150
+
151
+ it('handles null values in record-map data as missing', () => {
152
+ const spec = {
153
+ type: 'tilemap' as const,
154
+ data: { CA: 5.4, TX: null, NY: 4.5 } as Record<string, number | null>,
155
+ };
156
+
157
+ const result = compileTileMap(spec, defaultOptions);
158
+
159
+ const caTile = result.tiles.find((t) => t.stateCode === 'CA')!;
160
+ expect(caTile.hasData).toBe(true);
161
+
162
+ const txTile = result.tiles.find((t) => t.stateCode === 'TX')!;
163
+ expect(txTile.hasData).toBe(false);
164
+ expect(txTile.value).toBeNull();
165
+ });
166
+
167
+ describe('gradient legend', () => {
168
+ it('has correct min/max labels', () => {
169
+ const result = compileTileMap(basicSpec, defaultOptions);
170
+
171
+ expect(result.gradientLegend).not.toBeNull();
172
+ expect(result.gradientLegend!.minLabel).toBeTruthy();
173
+ expect(result.gradientLegend!.maxLabel).toBeTruthy();
174
+ expect(Number(result.gradientLegend!.minLabel)).toBeLessThan(
175
+ Number(result.gradientLegend!.maxLabel),
176
+ );
177
+ });
178
+
179
+ it('has colorStops', () => {
180
+ const result = compileTileMap(basicSpec, defaultOptions);
181
+
182
+ expect(result.gradientLegend!.colorStops.length).toBeGreaterThan(0);
183
+ for (const stop of result.gradientLegend!.colorStops) {
184
+ expect(stop.offset).toBeGreaterThanOrEqual(0);
185
+ expect(stop.offset).toBeLessThanOrEqual(1);
186
+ expect(stop.color).toBeTruthy();
187
+ }
188
+ });
189
+
190
+ it('is null when legend.show is false', () => {
191
+ const spec = { ...basicSpec, legend: { show: false } };
192
+ const result = compileTileMap(spec, defaultOptions);
193
+
194
+ expect(result.gradientLegend).toBeNull();
195
+ });
196
+ });
197
+
198
+ describe('valueFormat', () => {
199
+ it('applies to tile formattedValue', () => {
200
+ const spec = { ...basicSpec, valueFormat: '.1f' };
201
+ const result = compileTileMap(spec, defaultOptions);
202
+
203
+ const caTile = result.tiles.find((t) => t.stateCode === 'CA');
204
+ expect(caTile).toBeDefined();
205
+ expect(caTile!.formattedValue).toBe('5.4');
206
+ });
207
+ });
208
+
209
+ describe('dark mode', () => {
210
+ it('reverses palette direction compared to light mode', () => {
211
+ const lightResult = compileTileMap(basicSpec, defaultOptions);
212
+ const darkResult = compileTileMap(basicSpec, { ...defaultOptions, darkMode: true });
213
+
214
+ const lightFL = lightResult.tiles.find((t) => t.stateCode === 'FL');
215
+ const darkFL = darkResult.tiles.find((t) => t.stateCode === 'FL');
216
+ expect(lightFL!.fill).not.toBe(darkFL!.fill);
217
+ });
218
+ });
219
+
220
+ describe('chrome', () => {
221
+ it('resolves title and subtitle', () => {
222
+ const spec = {
223
+ ...basicSpec,
224
+ chrome: {
225
+ title: 'Test Title',
226
+ subtitle: 'Test Subtitle',
227
+ },
228
+ };
229
+ const result = compileTileMap(spec, defaultOptions);
230
+
231
+ expect(result.chrome.title).toBeDefined();
232
+ expect(result.chrome.title!.text).toBe('Test Title');
233
+ expect(result.chrome.subtitle).toBeDefined();
234
+ expect(result.chrome.subtitle!.text).toBe('Test Subtitle');
235
+ });
236
+ });
237
+
238
+ describe('tooltip descriptors', () => {
239
+ it('contains entries for all tiles', () => {
240
+ const result = compileTileMap(basicSpec, defaultOptions);
241
+
242
+ expect(result.tooltipDescriptors.has('CA')).toBe(true);
243
+ expect(result.tooltipDescriptors.has('TX')).toBe(true);
244
+ expect(result.tooltipDescriptors.size).toBe(51);
245
+ });
246
+
247
+ it('tooltip has title and value field', () => {
248
+ const result = compileTileMap(basicSpec, defaultOptions);
249
+
250
+ const tooltip = result.tooltipDescriptors.get('CA')!;
251
+ expect(tooltip.title).toBe('California');
252
+ expect(tooltip.fields.length).toBeGreaterThan(0);
253
+ expect(tooltip.fields.some((f) => f.label === 'Value')).toBe(true);
254
+ });
255
+ });
256
+
257
+ describe('a11y', () => {
258
+ it('generates descriptive alt text', () => {
259
+ const result = compileTileMap(basicSpec, defaultOptions);
260
+
261
+ expect(result.a11y.altText).toContain('Tile map');
262
+ expect(result.a11y.altText).toContain('US states');
263
+ });
264
+
265
+ it('has a data table fallback', () => {
266
+ const result = compileTileMap(basicSpec, defaultOptions);
267
+
268
+ expect(result.a11y.dataTableFallback.length).toBeGreaterThan(0);
269
+ });
270
+ });
271
+
272
+ describe('validation', () => {
273
+ it('throws on non-tilemap spec', () => {
274
+ const chartSpec = {
275
+ mark: 'bar' as const,
276
+ data: [{ x: 1, y: 2 }],
277
+ encoding: {
278
+ x: { field: 'x', type: 'quantitative' as const },
279
+ y: { field: 'y', type: 'quantitative' as const },
280
+ },
281
+ };
282
+
283
+ expect(() => compileTileMap(chartSpec, defaultOptions)).toThrow(/non-tilemap/);
284
+ });
285
+
286
+ it('throws on empty record-map data', () => {
287
+ const spec = {
288
+ type: 'tilemap' as const,
289
+ data: {} as Record<string, number>,
290
+ };
291
+
292
+ expect(() => compileTileMap(spec, defaultOptions)).toThrow();
293
+ });
294
+ });
295
+
296
+ describe('dimensions', () => {
297
+ it('reflects the compile options', () => {
298
+ const result = compileTileMap(basicSpec, defaultOptions);
299
+
300
+ expect(result.width).toBe(600);
301
+ expect(result.height).toBe(400);
302
+ });
303
+
304
+ it('works with different container sizes', () => {
305
+ const result = compileTileMap(basicSpec, { width: 800, height: 600 });
306
+
307
+ expect(result.width).toBe(800);
308
+ expect(result.height).toBe(600);
309
+ });
310
+ });
311
+
312
+ describe('palette', () => {
313
+ it('uses the specified palette (data tiles differ between palettes)', () => {
314
+ const blueResult = compileTileMap(basicSpec, defaultOptions);
315
+ const greenResult = compileTileMap({ ...basicSpec, palette: 'green' }, defaultOptions);
316
+
317
+ const blueCa = blueResult.tiles.find((t) => t.stateCode === 'CA')!;
318
+ const greenCa = greenResult.tiles.find((t) => t.stateCode === 'CA')!;
319
+ expect(blueCa.fill).not.toBe(greenCa.fill);
320
+ });
321
+ });
322
+ });