@opendata-ai/openchart-engine 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "2.6.0",
48
+ "@opendata-ai/openchart-core": "2.8.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -509,4 +509,82 @@ describe('compileGraph', () => {
509
509
  // Rotated labels should shrink the chart area height
510
510
  expect(layoutRotated.area.height).toBeLessThan(layoutNormal.area.height);
511
511
  });
512
+
513
+ it('hides legend when legend.show is false', () => {
514
+ const spec = {
515
+ ...lineSpec,
516
+ legend: { show: false },
517
+ };
518
+ const layout = compileChart(spec, { width: 600, height: 400 });
519
+ expect(layout.legend.entries).toHaveLength(0);
520
+ expect(layout.legend.bounds.width).toBe(0);
521
+ expect(layout.legend.bounds.height).toBe(0);
522
+ });
523
+
524
+ it('applies compact breakpoint overrides to chrome', () => {
525
+ const spec = {
526
+ ...lineSpec,
527
+ chrome: {
528
+ title: 'Full width title with extra context',
529
+ subtitle: 'Long subtitle with methodology details',
530
+ },
531
+ overrides: {
532
+ compact: {
533
+ chrome: {
534
+ title: 'Short title',
535
+ subtitle: 'Short sub',
536
+ },
537
+ },
538
+ },
539
+ };
540
+
541
+ // At compact width (< 400), overrides should apply
542
+ const compactLayout = compileChart(spec, { width: 350, height: 400 });
543
+ expect(compactLayout.chrome.title!.text).toBe('Short title');
544
+ expect(compactLayout.chrome.subtitle!.text).toBe('Short sub');
545
+
546
+ // At full width, base spec should apply
547
+ const fullLayout = compileChart(spec, { width: 800, height: 400 });
548
+ expect(fullLayout.chrome.title!.text).toBe('Full width title with extra context');
549
+ expect(fullLayout.chrome.subtitle!.text).toBe('Long subtitle with methodology details');
550
+ });
551
+
552
+ it('applies medium breakpoint overrides', () => {
553
+ const spec = {
554
+ ...lineSpec,
555
+ chrome: { title: 'Full title' },
556
+ overrides: {
557
+ medium: {
558
+ chrome: { title: 'Medium title' },
559
+ },
560
+ },
561
+ };
562
+
563
+ // At medium width (400-700), override should apply
564
+ const mediumLayout = compileChart(spec, { width: 500, height: 400 });
565
+ expect(mediumLayout.chrome.title!.text).toBe('Medium title');
566
+
567
+ // At full width, base spec should apply
568
+ const fullLayout = compileChart(spec, { width: 800, height: 400 });
569
+ expect(fullLayout.chrome.title!.text).toBe('Full title');
570
+ });
571
+
572
+ it('applies breakpoint override for legend show', () => {
573
+ const spec = {
574
+ ...lineSpec,
575
+ overrides: {
576
+ compact: {
577
+ legend: { show: false },
578
+ },
579
+ },
580
+ };
581
+
582
+ // At compact, legend hidden
583
+ const compactLayout = compileChart(spec, { width: 350, height: 400 });
584
+ expect(compactLayout.legend.entries).toHaveLength(0);
585
+
586
+ // At full, legend shown
587
+ const fullLayout = compileChart(spec, { width: 800, height: 400 });
588
+ expect(fullLayout.legend.entries.length).toBeGreaterThan(0);
589
+ });
512
590
  });
@@ -82,6 +82,30 @@ describe('computeLegend', () => {
82
82
  expect(legend.bounds.height).toBeGreaterThan(0);
83
83
  });
84
84
 
85
+ it('returns empty entries when show is false', () => {
86
+ const specHidden: NormalizedChartSpec = {
87
+ ...specWithColor,
88
+ legend: { show: false },
89
+ hiddenSeries: [],
90
+ seriesStyles: {},
91
+ };
92
+ const legend = computeLegend(specHidden, fullStrategy, theme, chartArea);
93
+ expect(legend.entries).toHaveLength(0);
94
+ expect(legend.bounds.width).toBe(0);
95
+ expect(legend.bounds.height).toBe(0);
96
+ });
97
+
98
+ it('still shows legend when show is true', () => {
99
+ const specShown: NormalizedChartSpec = {
100
+ ...specWithColor,
101
+ legend: { show: true },
102
+ hiddenSeries: [],
103
+ seriesStyles: {},
104
+ };
105
+ const legend = computeLegend(specShown, fullStrategy, theme, chartArea);
106
+ expect(legend.entries).toHaveLength(3);
107
+ });
108
+
85
109
  it('uses correct swatch shape for chart type', () => {
86
110
  const lineLegend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
87
111
  expect(lineLegend.entries[0].shape).toBe('line');
@@ -291,4 +291,55 @@ describe('computeBarLabels', () => {
291
291
  expect(texts).toContain('30');
292
292
  expect(texts).toContain('70');
293
293
  });
294
+
295
+ it('applies d3 label format string', () => {
296
+ const spec = makeSimpleBarSpec();
297
+ const scales = computeScales(spec, chartArea, spec.data);
298
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
299
+ const labels = computeBarLabels(marks, chartArea, 'auto', '$,.0f');
300
+
301
+ const texts = labels.map((l) => l.text);
302
+ expect(texts).toContain('$50');
303
+ expect(texts).toContain('$30');
304
+ expect(texts).toContain('$70');
305
+ });
306
+
307
+ it('applies format with literal alpha suffix (e.g. "T")', () => {
308
+ const spec: NormalizedChartSpec = {
309
+ type: 'bar',
310
+ data: [
311
+ { company: 'Apple', cap: 3.75 },
312
+ { company: 'Meta', cap: 1.63 },
313
+ ],
314
+ encoding: {
315
+ x: { field: 'cap', type: 'quantitative' },
316
+ y: { field: 'company', type: 'nominal' },
317
+ },
318
+ chrome: {},
319
+ annotations: [],
320
+ responsive: true,
321
+ theme: {},
322
+ darkMode: 'off',
323
+ labels: { density: 'all', format: '$,.2~fT' },
324
+ };
325
+ const scales = computeScales(spec, chartArea, spec.data);
326
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
327
+ const labels = computeBarLabels(marks, chartArea, 'all', '$,.2~fT');
328
+
329
+ const texts = labels.map((l) => l.text);
330
+ expect(texts).toContain('$3.75T');
331
+ expect(texts).toContain('$1.63T');
332
+ });
333
+
334
+ it('applies format with non-alpha suffix (e.g. "%")', () => {
335
+ const spec = makeSimpleBarSpec();
336
+ const scales = computeScales(spec, chartArea, spec.data);
337
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
338
+ const labels = computeBarLabels(marks, chartArea, 'auto', '.0f%');
339
+
340
+ const texts = labels.map((l) => l.text);
341
+ expect(texts).toContain('50%');
342
+ expect(texts).toContain('30%');
343
+ expect(texts).toContain('70%');
344
+ });
294
345
  });
@@ -17,8 +17,11 @@ import type {
17
17
  RectMark,
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
- import { estimateTextWidth, resolveCollisions } from '@opendata-ai/openchart-core';
21
- import { format as d3Format } from 'd3-format';
20
+ import {
21
+ buildD3Formatter,
22
+ estimateTextWidth,
23
+ resolveCollisions,
24
+ } from '@opendata-ai/openchart-core';
22
25
 
23
26
  // ---------------------------------------------------------------------------
24
27
  // Constants
@@ -55,27 +58,7 @@ export function computeBarLabels(
55
58
 
56
59
  const candidates: LabelCandidate[] = [];
57
60
 
58
- // Build a d3 formatter if a label format string was provided.
59
- // Supports a literal suffix after the d3 format, e.g. ".1f%" formats as "12.5%"
60
- // (the trailing "%" is appended literally, not d3's multiply-by-100 percent type).
61
- let formatter: ((v: number) => string) | null = null;
62
- if (labelFormat) {
63
- try {
64
- formatter = d3Format(labelFormat);
65
- } catch {
66
- // If d3-format rejects it, try stripping a trailing suffix
67
- const suffixMatch = labelFormat.match(/^(.+[a-z])([^a-z]+)$/i);
68
- if (suffixMatch) {
69
- try {
70
- const d3Fmt = d3Format(suffixMatch[1]);
71
- const suffix = suffixMatch[2];
72
- formatter = (v: number) => d3Fmt(v) + suffix;
73
- } catch {
74
- // Give up on formatting
75
- }
76
- }
77
- }
78
- }
61
+ const formatter = buildD3Formatter(labelFormat);
79
62
 
80
63
  for (const mark of targetMarks) {
81
64
  // Extract the display value from the aria label.
@@ -274,4 +274,80 @@ describe('computeColumnLabels', () => {
274
274
  expect(texts).toContain('120');
275
275
  expect(texts).toContain('200');
276
276
  });
277
+
278
+ it('applies d3 label format string', () => {
279
+ const spec = makeSimpleColumnSpec();
280
+ const scales = computeScales(spec, chartArea, spec.data);
281
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
282
+ const labels = computeColumnLabels(marks, chartArea, 'auto', '$,.0f');
283
+
284
+ const texts = labels.map((l) => l.text);
285
+ expect(texts).toContain('$120');
286
+ expect(texts).toContain('$200');
287
+ });
288
+
289
+ it('applies format with trailing zero trim (~)', () => {
290
+ const spec: NormalizedChartSpec = {
291
+ type: 'column',
292
+ data: [
293
+ { company: 'A', cap: 3.1 },
294
+ { company: 'B', cap: 2.85 },
295
+ ],
296
+ encoding: {
297
+ x: { field: 'company', type: 'nominal' },
298
+ y: { field: 'cap', type: 'quantitative' },
299
+ },
300
+ chrome: {},
301
+ annotations: [],
302
+ responsive: true,
303
+ theme: {},
304
+ darkMode: 'off',
305
+ labels: { density: 'all', format: '$,.2~f' },
306
+ };
307
+ const scales = computeScales(spec, chartArea, spec.data);
308
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
309
+ const labels = computeColumnLabels(marks, chartArea, 'all', '$,.2~f');
310
+
311
+ const texts = labels.map((l) => l.text);
312
+ expect(texts).toContain('$3.1');
313
+ expect(texts).toContain('$2.85');
314
+ });
315
+
316
+ it('applies format with literal alpha suffix (e.g. "T")', () => {
317
+ const spec: NormalizedChartSpec = {
318
+ type: 'column',
319
+ data: [
320
+ { company: 'Apple', cap: 3.75 },
321
+ { company: 'Meta', cap: 1.63 },
322
+ ],
323
+ encoding: {
324
+ x: { field: 'company', type: 'nominal' },
325
+ y: { field: 'cap', type: 'quantitative' },
326
+ },
327
+ chrome: {},
328
+ annotations: [],
329
+ responsive: true,
330
+ theme: {},
331
+ darkMode: 'off',
332
+ labels: { density: 'all', format: '$,.2~fT' },
333
+ };
334
+ const scales = computeScales(spec, chartArea, spec.data);
335
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
336
+ const labels = computeColumnLabels(marks, chartArea, 'all', '$,.2~fT');
337
+
338
+ const texts = labels.map((l) => l.text);
339
+ expect(texts).toContain('$3.75T');
340
+ expect(texts).toContain('$1.63T');
341
+ });
342
+
343
+ it('applies format with non-alpha suffix (e.g. "%")', () => {
344
+ const spec = makeSimpleColumnSpec();
345
+ const scales = computeScales(spec, chartArea, spec.data);
346
+ const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
347
+ const labels = computeColumnLabels(marks, chartArea, 'auto', '.0f%');
348
+
349
+ const texts = labels.map((l) => l.text);
350
+ expect(texts).toContain('120%');
351
+ expect(texts).toContain('200%');
352
+ });
277
353
  });
@@ -17,7 +17,7 @@ export const columnRenderer: ChartRenderer = (spec, scales, chartArea, strategy,
17
17
  const marks = computeColumnMarks(spec, scales, chartArea, strategy);
18
18
 
19
19
  // Compute and attach value labels (respects spec.labels.density)
20
- const labels = computeColumnLabels(marks, chartArea, spec.labels.density);
20
+ const labels = computeColumnLabels(marks, chartArea, spec.labels.density, spec.labels.format);
21
21
  for (let i = 0; i < marks.length && i < labels.length; i++) {
22
22
  marks[i].label = labels[i];
23
23
  }
@@ -17,7 +17,11 @@ import type {
17
17
  RectMark,
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
- import { estimateTextWidth, resolveCollisions } from '@opendata-ai/openchart-core';
20
+ import {
21
+ buildD3Formatter,
22
+ estimateTextWidth,
23
+ resolveCollisions,
24
+ } from '@opendata-ai/openchart-core';
21
25
 
22
26
  // ---------------------------------------------------------------------------
23
27
  // Constants
@@ -40,6 +44,7 @@ export function computeColumnLabels(
40
44
  marks: RectMark[],
41
45
  _chartArea: { x: number; y: number; width: number; height: number },
42
46
  density: LabelDensity = 'auto',
47
+ labelFormat?: string,
43
48
  ): ResolvedLabel[] {
44
49
  // 'none': no labels at all
45
50
  if (density === 'none') return [];
@@ -48,6 +53,8 @@ export function computeColumnLabels(
48
53
  const targetMarks =
49
54
  density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
50
55
 
56
+ const formatter = buildD3Formatter(labelFormat);
57
+
51
58
  const candidates: LabelCandidate[] = [];
52
59
 
53
60
  for (const mark of targetMarks) {
@@ -56,8 +63,15 @@ export function computeColumnLabels(
56
63
  // Use the last colon to split, which handles colons in category names.
57
64
  const ariaLabel = mark.aria.label;
58
65
  const lastColon = ariaLabel.lastIndexOf(':');
59
- const valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
60
- if (!valuePart) continue;
66
+ const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
67
+ if (!rawValue) continue;
68
+
69
+ // Apply label format if provided (re-parse the number from the aria string)
70
+ let valuePart = rawValue;
71
+ if (formatter) {
72
+ const num = Number(rawValue.replace(/[^0-9.-]/g, ''));
73
+ if (!Number.isNaN(num)) valuePart = formatter(num);
74
+ }
61
75
 
62
76
  const numericValue = parseFloat(valuePart);
63
77
  const isNegative = Number.isFinite(numericValue) && numericValue < 0;
@@ -67,7 +81,7 @@ export function computeColumnLabels(
67
81
 
68
82
  // For positive values, place label above the column top.
69
83
  // For negative values, place label below the column bottom.
70
- const anchorX = mark.x + mark.width / 2 - textWidth / 2;
84
+ const anchorX = mark.x + mark.width / 2;
71
85
  const anchorY = isNegative
72
86
  ? mark.y + mark.height + LABEL_OFFSET_Y
73
87
  : mark.y - LABEL_OFFSET_Y - textHeight;
package/src/compile.ts CHANGED
@@ -159,7 +159,58 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
159
159
  throw new Error('compileChart received a graph spec. Use compileGraph instead.');
160
160
  }
161
161
 
162
- const chartSpec = normalized as NormalizedChartSpec;
162
+ let chartSpec = normalized as NormalizedChartSpec;
163
+
164
+ // Responsive strategy
165
+ const breakpoint = getBreakpoint(options.width);
166
+ const strategy = getLayoutStrategy(breakpoint);
167
+
168
+ // Apply breakpoint-conditional overrides from the original spec
169
+ const rawSpec = spec as Record<string, unknown>;
170
+ const overrides = rawSpec.overrides as
171
+ | Partial<
172
+ Record<
173
+ string,
174
+ { chrome?: unknown; labels?: unknown; legend?: unknown; annotations?: unknown }
175
+ >
176
+ >
177
+ | undefined;
178
+ if (overrides?.[breakpoint]) {
179
+ const bp = overrides[breakpoint]!;
180
+ if (bp.chrome) {
181
+ chartSpec = {
182
+ ...chartSpec,
183
+ chrome: {
184
+ ...chartSpec.chrome,
185
+ ...(bp.chrome as NormalizedChartSpec['chrome']),
186
+ },
187
+ };
188
+ }
189
+ if (bp.labels) {
190
+ chartSpec = {
191
+ ...chartSpec,
192
+ labels: {
193
+ ...chartSpec.labels,
194
+ ...(bp.labels as NormalizedChartSpec['labels']),
195
+ },
196
+ };
197
+ }
198
+ if (bp.legend) {
199
+ chartSpec = {
200
+ ...chartSpec,
201
+ legend: {
202
+ ...chartSpec.legend,
203
+ ...(bp.legend as NormalizedChartSpec['legend']),
204
+ },
205
+ };
206
+ }
207
+ if (bp.annotations) {
208
+ chartSpec = {
209
+ ...chartSpec,
210
+ annotations: bp.annotations as NormalizedChartSpec['annotations'],
211
+ };
212
+ }
213
+ }
163
214
 
164
215
  // Resolve theme: merge spec-level theme with options-level overrides
165
216
  const mergedThemeConfig = options.theme
@@ -170,10 +221,6 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
170
221
  theme = adaptTheme(theme);
171
222
  }
172
223
 
173
- // Responsive strategy
174
- const breakpoint = getBreakpoint(options.width);
175
- const strategy = getLayoutStrategy(breakpoint);
176
-
177
224
  // Compute legend first (needs to reserve space)
178
225
  const preliminaryArea: Rect = {
179
226
  x: 0,
@@ -15,8 +15,12 @@ import type {
15
15
  ResolvedTheme,
16
16
  TextStyle,
17
17
  } from '@opendata-ai/openchart-core';
18
- import { abbreviateNumber, formatDate, formatNumber } from '@opendata-ai/openchart-core';
19
- import { format as d3Format } from 'd3-format';
18
+ import {
19
+ abbreviateNumber,
20
+ buildD3Formatter,
21
+ formatDate,
22
+ formatNumber,
23
+ } from '@opendata-ai/openchart-core';
20
24
  import type { ScaleBand } from 'd3-scale';
21
25
  import type {
22
26
  D3CategoricalScale,
@@ -149,19 +153,8 @@ function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
149
153
  if (resolvedScale.type === 'linear' || resolvedScale.type === 'log') {
150
154
  const num = value as number;
151
155
  if (formatStr) {
152
- try {
153
- return d3Format(formatStr)(num);
154
- } catch {
155
- // Support literal suffix after d3 format, e.g. ".1f%" → d3(".1f") + "%"
156
- const suffixMatch = formatStr.match(/^(.+[a-z])([^a-z]+)$/i);
157
- if (suffixMatch) {
158
- try {
159
- return d3Format(suffixMatch[1])(num) + suffixMatch[2];
160
- } catch {
161
- // Fall through to default formatting
162
- }
163
- }
164
- }
156
+ const fmt = buildD3Formatter(formatStr);
157
+ if (fmt) return fmt(num);
165
158
  }
166
159
  // Abbreviate large numbers for axis labels
167
160
  if (Math.abs(num) >= 1000) return abbreviateNumber(num);
@@ -87,6 +87,25 @@ export function computeLegend(
87
87
  theme: ResolvedTheme,
88
88
  chartArea: Rect,
89
89
  ): LegendLayout {
90
+ // Legend explicitly hidden via show: false
91
+ if (spec.legend?.show === false) {
92
+ return {
93
+ position: 'top',
94
+ entries: [],
95
+ bounds: { x: 0, y: 0, width: 0, height: 0 },
96
+ labelStyle: {
97
+ fontFamily: theme.fonts.family,
98
+ fontSize: theme.fonts.sizes.small,
99
+ fontWeight: theme.fonts.weights.normal,
100
+ fill: theme.colors.text,
101
+ lineHeight: 1.3,
102
+ },
103
+ swatchSize: SWATCH_SIZE,
104
+ swatchGap: SWATCH_GAP,
105
+ entryGap: ENTRY_GAP,
106
+ };
107
+ }
108
+
90
109
  const entries = extractColorEntries(spec, theme);
91
110
 
92
111
  const labelStyle: TextStyle = {