@opendata-ai/openchart-engine 2.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "2.6.0",
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.6.0",
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",
@@ -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;
@@ -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);