@opendata-ai/openchart-engine 2.5.0 → 2.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.
- package/dist/index.js +57 -18
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/annotations/compute.ts +24 -5
- package/src/charts/bar/__tests__/compute.test.ts +51 -0
- package/src/charts/bar/index.ts +1 -1
- package/src/charts/bar/labels.ts +17 -3
- 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/layout/axes.ts +10 -2
- package/src/layout/dimensions.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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.7.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -356,18 +356,33 @@ function resolveRangeAnnotation(
|
|
|
356
356
|
|
|
357
357
|
const rect: Rect = { x, y, width, height };
|
|
358
358
|
|
|
359
|
-
// Label
|
|
359
|
+
// Label positioned within the range, with optional offset.
|
|
360
|
+
// labelAnchor controls horizontal placement:
|
|
361
|
+
// "left" (default): left edge, text-anchor start
|
|
362
|
+
// "top"/"auto": horizontally centered, text-anchor middle
|
|
363
|
+
// "right": right edge, text-anchor end
|
|
360
364
|
let label: ResolvedLabel | undefined;
|
|
361
365
|
if (annotation.label) {
|
|
362
|
-
const
|
|
366
|
+
const anchor = annotation.labelAnchor ?? 'left';
|
|
367
|
+
const centered = anchor === 'top' || anchor === 'bottom' || anchor === 'auto';
|
|
368
|
+
const baseDx = centered ? 0 : anchor === 'right' ? -4 : 4;
|
|
363
369
|
const baseDy = 14;
|
|
364
370
|
const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
|
|
365
371
|
|
|
372
|
+
const style = makeAnnotationLabelStyle(11, 500, undefined, isDark);
|
|
373
|
+
if (centered) {
|
|
374
|
+
style.textAnchor = 'middle';
|
|
375
|
+
} else if (anchor === 'right') {
|
|
376
|
+
style.textAnchor = 'end';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const baseX = centered ? x + width / 2 : anchor === 'right' ? x + width : x;
|
|
380
|
+
|
|
366
381
|
label = {
|
|
367
382
|
text: annotation.label,
|
|
368
|
-
x:
|
|
383
|
+
x: baseX + labelDelta.dx,
|
|
369
384
|
y: y + labelDelta.dy,
|
|
370
|
-
style
|
|
385
|
+
style,
|
|
371
386
|
visible: true,
|
|
372
387
|
};
|
|
373
388
|
}
|
|
@@ -428,11 +443,15 @@ function resolveRefLineAnnotation(
|
|
|
428
443
|
|
|
429
444
|
// Label at the right end for horizontal, top end for vertical, with optional offset.
|
|
430
445
|
// Horizontal refline labels use text-anchor 'end' so text stays inside the chart.
|
|
446
|
+
// labelAnchor controls which side of the line the label sits on:
|
|
447
|
+
// "top" (default): above horizontal, left of vertical
|
|
448
|
+
// "bottom": below horizontal, right of vertical
|
|
431
449
|
let label: ResolvedLabel | undefined;
|
|
432
450
|
if (annotation.label) {
|
|
433
451
|
const isHorizontal = annotation.y !== undefined;
|
|
452
|
+
const anchor = annotation.labelAnchor ?? 'top';
|
|
434
453
|
const baseDx = isHorizontal ? -4 : 4;
|
|
435
|
-
const baseDy = -4;
|
|
454
|
+
const baseDy = anchor === 'bottom' ? 14 : -4;
|
|
436
455
|
const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
|
|
437
456
|
|
|
438
457
|
const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
|
|
@@ -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/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
|
|
|
17
17
|
const marks = computeBarMarks(spec, scales, chartArea, strategy);
|
|
18
18
|
|
|
19
19
|
// Compute and attach value labels (respects spec.labels.density)
|
|
20
|
-
const labels = computeBarLabels(marks, chartArea, spec.labels.density);
|
|
20
|
+
const labels = computeBarLabels(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
|
}
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -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
|
|
@@ -43,6 +47,7 @@ export function computeBarLabels(
|
|
|
43
47
|
marks: RectMark[],
|
|
44
48
|
_chartArea: { x: number; y: number; width: number; height: number },
|
|
45
49
|
density: LabelDensity = 'auto',
|
|
50
|
+
labelFormat?: string,
|
|
46
51
|
): ResolvedLabel[] {
|
|
47
52
|
// 'none': no labels at all
|
|
48
53
|
if (density === 'none') return [];
|
|
@@ -53,14 +58,23 @@ export function computeBarLabels(
|
|
|
53
58
|
|
|
54
59
|
const candidates: LabelCandidate[] = [];
|
|
55
60
|
|
|
61
|
+
const formatter = buildD3Formatter(labelFormat);
|
|
62
|
+
|
|
56
63
|
for (const mark of targetMarks) {
|
|
57
64
|
// Extract the display value from the aria label.
|
|
58
65
|
// Format is "category: value" or "category, group: value".
|
|
59
66
|
// Use the last colon to split, which handles colons in category names.
|
|
60
67
|
const ariaLabel = mark.aria.label;
|
|
61
68
|
const lastColon = ariaLabel.lastIndexOf(':');
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
69
|
+
const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
|
|
70
|
+
if (!rawValue) continue;
|
|
71
|
+
|
|
72
|
+
// Apply label format if provided (re-parse the number from the aria string)
|
|
73
|
+
let valuePart = rawValue;
|
|
74
|
+
if (formatter) {
|
|
75
|
+
const num = Number(rawValue.replace(/[^0-9.-]/g, ''));
|
|
76
|
+
if (!Number.isNaN(num)) valuePart = formatter(num);
|
|
77
|
+
}
|
|
64
78
|
|
|
65
79
|
const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
|
|
66
80
|
const textHeight = LABEL_FONT_SIZE * 1.2;
|
|
@@ -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/layout/axes.ts
CHANGED
|
@@ -15,7 +15,12 @@ import type {
|
|
|
15
15
|
ResolvedTheme,
|
|
16
16
|
TextStyle,
|
|
17
17
|
} from '@opendata-ai/openchart-core';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
abbreviateNumber,
|
|
20
|
+
buildD3Formatter,
|
|
21
|
+
formatDate,
|
|
22
|
+
formatNumber,
|
|
23
|
+
} from '@opendata-ai/openchart-core';
|
|
19
24
|
import type { ScaleBand } from 'd3-scale';
|
|
20
25
|
import type {
|
|
21
26
|
D3CategoricalScale,
|
|
@@ -147,7 +152,10 @@ function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
|
|
|
147
152
|
|
|
148
153
|
if (resolvedScale.type === 'linear' || resolvedScale.type === 'log') {
|
|
149
154
|
const num = value as number;
|
|
150
|
-
if (formatStr)
|
|
155
|
+
if (formatStr) {
|
|
156
|
+
const fmt = buildD3Formatter(formatStr);
|
|
157
|
+
if (fmt) return fmt(num);
|
|
158
|
+
}
|
|
151
159
|
// Abbreviate large numbers for axis labels
|
|
152
160
|
if (Math.abs(num) >= 1000) return abbreviateNumber(num);
|
|
153
161
|
return formatNumber(num);
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -127,8 +127,10 @@ export function computeDimensions(
|
|
|
127
127
|
left: padding + (isRadial ? padding : axisMargin),
|
|
128
128
|
};
|
|
129
129
|
|
|
130
|
-
// Dynamic right margin for line/area end-of-line labels
|
|
131
|
-
|
|
130
|
+
// Dynamic right margin for line/area end-of-line labels.
|
|
131
|
+
// Only reserve space when labels will actually render (density != 'none').
|
|
132
|
+
const labelDensity = spec.labels.density;
|
|
133
|
+
if ((spec.type === 'line' || spec.type === 'area') && labelDensity !== 'none') {
|
|
132
134
|
// Estimate label width from longest series name (color encoding domain)
|
|
133
135
|
const colorField = encoding.color?.field;
|
|
134
136
|
if (colorField) {
|