@opendata-ai/openchart-engine 6.1.1 → 6.1.2

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": "6.1.1",
3
+ "version": "6.1.2",
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": "6.1.1",
48
+ "@opendata-ai/openchart-core": "6.1.2",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -22,6 +22,7 @@ import type {
22
22
  ResolvedTheme,
23
23
  } from '@opendata-ai/openchart-core';
24
24
  import { computeChrome, estimateTextWidth } from '@opendata-ai/openchart-core';
25
+ import { format as d3Format } from 'd3-format';
25
26
 
26
27
  import type { NormalizedChartSpec, NormalizedChrome } from '../compiler/types';
27
28
 
@@ -187,6 +188,27 @@ export function computeDimensions(
187
188
  }
188
189
  }
189
190
 
191
+ // Reserve right margin for text annotations near the chart's right edge.
192
+ // Without this, annotation text at the last data point clips outside the SVG.
193
+ if (spec.annotations.length > 0 && encoding.x) {
194
+ const xField = encoding.x.field;
195
+ // Find the maximum x value in the data
196
+ let maxX: string | number | undefined;
197
+ for (const row of spec.data) {
198
+ const v = row[xField];
199
+ if (v != null && (maxX == null || String(v) >= String(maxX))) maxX = v as string | number;
200
+ }
201
+ if (maxX != null) {
202
+ const maxXStr = String(maxX);
203
+ for (const ann of spec.annotations) {
204
+ if (ann.type === 'text' && String(ann.x) === maxXStr) {
205
+ const textWidth = estimateTextWidth(ann.text, ann.fontSize ?? 11, ann.fontWeight ?? 600);
206
+ margins.right = Math.max(margins.right, padding + textWidth + 12);
207
+ }
208
+ }
209
+ }
210
+ }
211
+
190
212
  // Dynamic left margin for y-axis labels
191
213
  if (encoding.y && !isRadial) {
192
214
  if (
@@ -209,19 +231,33 @@ export function computeDimensions(
209
231
  } else if (encoding.y.type === 'quantitative' || encoding.y.type === 'temporal') {
210
232
  // Numeric tick labels on the left. Estimate width from the data range.
211
233
  const yField = encoding.y.field;
234
+ const yAxisFormat = (encoding.y.axis as Record<string, unknown> | undefined)?.format as
235
+ | string
236
+ | undefined;
212
237
  let maxAbsVal = 0;
213
238
  for (const row of spec.data) {
214
239
  const v = Number(row[yField]);
215
240
  if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
216
241
  }
217
- // Estimate the formatted label: abbreviateNumber for >= 1000, formatNumber otherwise
242
+
218
243
  let sampleLabel: string;
219
- if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
220
- else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
221
- else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
222
- else if (maxAbsVal >= 100) sampleLabel = '100';
223
- else if (maxAbsVal >= 10) sampleLabel = '10';
224
- else sampleLabel = '0.0';
244
+ if (yAxisFormat) {
245
+ // Use the actual d3-format to produce a realistic label estimate
246
+ try {
247
+ const fmt = d3Format(yAxisFormat);
248
+ sampleLabel = fmt(maxAbsVal);
249
+ } catch {
250
+ sampleLabel = String(maxAbsVal);
251
+ }
252
+ } else {
253
+ // Fallback: estimate from magnitude
254
+ if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
255
+ else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
256
+ else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
257
+ else if (maxAbsVal >= 100) sampleLabel = '100';
258
+ else if (maxAbsVal >= 10) sampleLabel = '10';
259
+ else sampleLabel = '0.0';
260
+ }
225
261
  // Account for negative sign
226
262
  const negPrefix = spec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
227
263
  const labelEst = negPrefix + sampleLabel;
@@ -236,7 +272,8 @@ export function computeDimensions(
236
272
  }
237
273
 
238
274
  // Rotated y-axis label needs extra left margin (rendered at area.x - 45 in SVG)
239
- if (encoding.y?.axis && (encoding.y.axis as Record<string, unknown>).label && !isRadial) {
275
+ const yAxis = encoding.y?.axis as Record<string, unknown> | undefined;
276
+ if (yAxis && (yAxis.title || yAxis.label) && !isRadial) {
240
277
  const rotatedLabelMargin = 45 + Math.ceil(theme.fonts.sizes.body / 2) + 4;
241
278
  margins.left = Math.max(margins.left, padding + rotatedLabelMargin);
242
279
  }