@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/dist/index.js +3458 -307
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/compile-chart.test.ts +78 -0
- package/src/__tests__/legend.test.ts +24 -0
- package/src/charts/bar/__tests__/compute.test.ts +51 -0
- package/src/charts/bar/labels.ts +6 -23
- package/src/charts/column/__tests__/compute.test.ts +76 -0
- package/src/charts/column/index.ts +1 -1
- package/src/charts/column/labels.ts +18 -4
- package/src/compile.ts +52 -5
- package/src/layout/axes.ts +8 -15
- package/src/legend/compute.ts +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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
|
});
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -17,8 +17,11 @@ import type {
|
|
|
17
17
|
RectMark,
|
|
18
18
|
ResolvedLabel,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
|
-
import {
|
|
21
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
60
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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,
|
package/src/layout/axes.ts
CHANGED
|
@@ -15,8 +15,12 @@ import type {
|
|
|
15
15
|
ResolvedTheme,
|
|
16
16
|
TextStyle,
|
|
17
17
|
} from '@opendata-ai/openchart-core';
|
|
18
|
-
import {
|
|
19
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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);
|
package/src/legend/compute.ts
CHANGED
|
@@ -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 = {
|