@opendata-ai/openchart-engine 2.5.0 → 2.6.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.5.0",
3
+ "version": "2.6.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.5.0",
48
+ "@opendata-ai/openchart-core": "2.6.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 at the top-left of the range, with optional offset
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 baseDx = 4;
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: x + labelDelta.dx,
383
+ x: baseX + labelDelta.dx,
369
384
  y: y + labelDelta.dy,
370
- style: makeAnnotationLabelStyle(11, 500, undefined, isDark),
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;
@@ -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
  }
@@ -18,6 +18,7 @@ import type {
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
20
  import { estimateTextWidth, resolveCollisions } from '@opendata-ai/openchart-core';
21
+ import { format as d3Format } from 'd3-format';
21
22
 
22
23
  // ---------------------------------------------------------------------------
23
24
  // Constants
@@ -43,6 +44,7 @@ export function computeBarLabels(
43
44
  marks: RectMark[],
44
45
  _chartArea: { x: number; y: number; width: number; height: number },
45
46
  density: LabelDensity = 'auto',
47
+ labelFormat?: string,
46
48
  ): ResolvedLabel[] {
47
49
  // 'none': no labels at all
48
50
  if (density === 'none') return [];
@@ -53,14 +55,43 @@ export function computeBarLabels(
53
55
 
54
56
  const candidates: LabelCandidate[] = [];
55
57
 
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
+ }
79
+
56
80
  for (const mark of targetMarks) {
57
81
  // Extract the display value from the aria label.
58
82
  // Format is "category: value" or "category, group: value".
59
83
  // Use the last colon to split, which handles colons in category names.
60
84
  const ariaLabel = mark.aria.label;
61
85
  const lastColon = ariaLabel.lastIndexOf(':');
62
- const valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
63
- if (!valuePart) continue;
86
+ const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
87
+ if (!rawValue) continue;
88
+
89
+ // Apply label format if provided (re-parse the number from the aria string)
90
+ let valuePart = rawValue;
91
+ if (formatter) {
92
+ const num = Number(rawValue.replace(/[^0-9.-]/g, ''));
93
+ if (!Number.isNaN(num)) valuePart = formatter(num);
94
+ }
64
95
 
65
96
  const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
66
97
  const textHeight = LABEL_FONT_SIZE * 1.2;
@@ -16,6 +16,7 @@ import type {
16
16
  TextStyle,
17
17
  } from '@opendata-ai/openchart-core';
18
18
  import { abbreviateNumber, formatDate, formatNumber } from '@opendata-ai/openchart-core';
19
+ import { format as d3Format } from 'd3-format';
19
20
  import type { ScaleBand } from 'd3-scale';
20
21
  import type {
21
22
  D3CategoricalScale,
@@ -147,7 +148,21 @@ function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
147
148
 
148
149
  if (resolvedScale.type === 'linear' || resolvedScale.type === 'log') {
149
150
  const num = value as number;
150
- if (formatStr) return formatNumber(num);
151
+ 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
+ }
165
+ }
151
166
  // Abbreviate large numbers for axis labels
152
167
  if (Math.abs(num) >= 1000) return abbreviateNumber(num);
153
168
  return formatNumber(num);
@@ -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
- if (spec.type === 'line' || spec.type === 'area') {
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) {