@opendata-ai/openchart-engine 6.25.0 → 6.25.1

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.25.0",
3
+ "version": "6.25.1",
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",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "6.25.0",
51
+ "@opendata-ai/openchart-core": "6.25.1",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
package/src/compile.ts CHANGED
@@ -33,16 +33,21 @@ import type {
33
33
  Transform,
34
34
  } from '@opendata-ai/openchart-core';
35
35
  import {
36
+ AXIS_TITLE_TRAILING_PAD,
36
37
  adaptTheme,
38
+ BREAKPOINT_COMPACT_MAX,
37
39
  computeLabelBounds,
38
40
  estimateTextWidth,
39
41
  generateAltText,
40
42
  generateDataTable,
43
+ getAxisTitleOffset,
41
44
  getBreakpoint,
42
45
  getHeightClass,
43
46
  getLayoutStrategy,
44
47
  resolveTheme,
48
+ TICK_LABEL_OFFSET,
45
49
  } from '@opendata-ai/openchart-core';
50
+ import { format as d3Format } from 'd3-format';
46
51
  import { scaleLinear } from 'd3-scale';
47
52
  import { curveMonotoneX, area as d3area, line as d3line } from 'd3-shape';
48
53
  import { computeAnnotations } from './annotations/compute';
@@ -548,18 +553,30 @@ function estimateYAxisLabelWidth(
548
553
  }
549
554
 
550
555
  // Quantitative/temporal: estimate from the largest value
556
+ const yAxisFormat = (encoding.y.axis as Record<string, unknown> | undefined)?.format as
557
+ | string
558
+ | undefined;
551
559
  let maxAbsVal = 0;
552
560
  for (const row of data) {
553
561
  const v = Number(row[yField]);
554
562
  if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
555
563
  }
556
564
  let sampleLabel: string;
557
- if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
558
- else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
559
- else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
560
- else if (maxAbsVal >= 100) sampleLabel = '100';
561
- else if (maxAbsVal >= 10) sampleLabel = '10';
562
- else sampleLabel = '0.0';
565
+ if (yAxisFormat) {
566
+ try {
567
+ const fmt = d3Format(yAxisFormat);
568
+ sampleLabel = fmt(maxAbsVal);
569
+ } catch {
570
+ sampleLabel = String(maxAbsVal);
571
+ }
572
+ } else {
573
+ if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
574
+ else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
575
+ else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
576
+ else if (maxAbsVal >= 100) sampleLabel = '100';
577
+ else if (maxAbsVal >= 10) sampleLabel = '10';
578
+ else sampleLabel = '0.0';
579
+ }
563
580
  const hasNeg = data.some((r) => Number(r[yField]) < 0);
564
581
  const labelEst = (hasNeg ? '-' : '') + sampleLabel;
565
582
  return estimateTextWidth(labelEst, baseFontSize, 400) + 10;
@@ -596,13 +613,24 @@ function compileLayerIndependent(
596
613
  );
597
614
  }
598
615
 
599
- // Estimate right-axis label width to reserve margin space
616
+ // Estimate right-axis label width to reserve margin space.
617
+ // Tick labels sit at chartEdge+6 and extend rightward by their width.
618
+ // The rotated title sits at chartEdge+45 and extends by half the font height.
619
+ // These overlap spatially, so use max (not sum) to mirror the left-margin pattern.
600
620
  const theme = resolveTheme(layerSpec.theme ?? leaf1.theme);
601
621
  const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11;
602
622
  const rightAxisWidth = estimateYAxisLabelWidth(leaf1.data, leaf1.encoding, axisFontSize);
603
- // Add space for the rotated axis title if present (match left-axis 45px clearance)
604
623
  const hasRightAxisTitle = !!leaf1.encoding?.y?.axis?.title;
605
- const rightReserve = rightAxisWidth + (hasRightAxisTitle ? 45 : 0);
624
+ const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
625
+ const bodyFontSize = theme.fonts?.sizes?.body ?? 13;
626
+ const axisTitleOffset = getAxisTitleOffset(options.width);
627
+ const halfGlyph = Math.ceil(bodyFontSize / 2);
628
+ const titleExtent = hasRightAxisTitle
629
+ ? axisTitleOffset +
630
+ halfGlyph +
631
+ (options.width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD)
632
+ : 0;
633
+ const rightReserve = Math.max(tickExtent, titleExtent);
606
634
 
607
635
  const optionsWithReserve: CompileOptions = {
608
636
  ...options,
@@ -960,6 +988,7 @@ function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec
960
988
  data: allData,
961
989
  // Layer-level chrome overrides leaf chrome
962
990
  chrome: layerSpec.chrome ?? leaves[0].chrome,
991
+ annotations: layerSpec.annotations ?? leaves[0].annotations,
963
992
  labels: layerSpec.labels ?? leaves[0].labels,
964
993
  legend: layerSpec.legend ?? leaves[0].legend,
965
994
  responsive: layerSpec.responsive ?? leaves[0].responsive,
@@ -211,10 +211,11 @@ export function categoricalTicks(
211
211
  const explicitTickCount = resolvedScale.channel.axis?.tickCount;
212
212
  const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
213
213
 
214
- // Band scales (bar charts) show all category labels by default.
215
- // Only thin when there's an explicit tickCount override or for point/ordinal scales.
214
+ // Band scales show all labels at full density but thin at reduced/minimal
215
+ // to prevent overlap on narrow containers (e.g. 17 bars on mobile).
216
216
  let selectedValues = domain;
217
- if ((resolvedScale.type !== 'band' || explicitTickCount) && domain.length > maxTicks) {
217
+ const shouldThinBand = resolvedScale.type === 'band' && (explicitTickCount || density !== 'full');
218
+ if ((resolvedScale.type !== 'band' || shouldThinBand) && domain.length > maxTicks) {
218
219
  const step = Math.ceil(domain.length / maxTicks);
219
220
  selectedValues = domain.filter((_: string, i: number) => i % step === 0);
220
221
  }
@@ -21,7 +21,23 @@ import type {
21
21
  ResolvedChrome,
22
22
  ResolvedTheme,
23
23
  } from '@opendata-ai/openchart-core';
24
- import { computeChrome, estimateTextWidth } from '@opendata-ai/openchart-core';
24
+ import {
25
+ AXIS_TITLE_TRAILING_PAD,
26
+ BREAKPOINT_COMPACT_MAX,
27
+ computeChrome,
28
+ estimateTextWidth,
29
+ getAxisTitleOffset,
30
+ HPAD_COMPACT_FRACTION,
31
+ HPAD_COMPACT_MIN,
32
+ LABEL_GAP_COMPACT,
33
+ LABEL_GAP_DEFAULT,
34
+ MAX_LEFT_LABEL_FRACTION_COMPACT,
35
+ MAX_LEFT_LABEL_FRACTION_DEFAULT,
36
+ MAX_LEFT_LABEL_FRACTION_MEDIUM,
37
+ MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
38
+ NARROW_VIEWPORT_MAX,
39
+ TOP_PAD_EXTRA_NARROW,
40
+ } from '@opendata-ai/openchart-core';
25
41
  import { format as d3Format } from 'd3-format';
26
42
 
27
43
  import type { NormalizedChartSpec, NormalizedChrome } from '../compiler/types';
@@ -102,6 +118,12 @@ export function computeDimensions(
102
118
  const { width, height } = options;
103
119
 
104
120
  const padding = scalePadding(theme.spacing.padding, width, height);
121
+ // Horizontal padding can be tighter than the chrome text padding on narrow
122
+ // containers because axis titles and tick labels tolerate closer edges.
123
+ const hPad =
124
+ width < BREAKPOINT_COMPACT_MAX
125
+ ? Math.max(Math.round(padding * HPAD_COMPACT_FRACTION), HPAD_COMPACT_MIN)
126
+ : padding;
105
127
  const axisMargin = theme.spacing.axisMargin;
106
128
  const chromeMode = strategy?.chromeMode ?? 'full';
107
129
 
@@ -160,11 +182,14 @@ export function computeDimensions(
160
182
  // added when there's actual chrome content that needs separation from the
161
183
  // chart area. When chrome is empty the margin is just padding.
162
184
  const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin;
185
+ // Extra top padding on narrow viewports prevents iOS Safari from clipping
186
+ // the title chrome behind the browser UI.
187
+ const topPad = width < NARROW_VIEWPORT_MAX ? padding + TOP_PAD_EXTRA_NARROW : padding;
163
188
  const margins: Margins = {
164
- top: padding + chrome.topHeight + topAxisGap,
165
- right: padding + (isRadial ? padding : axisMargin),
189
+ top: topPad + chrome.topHeight + topAxisGap,
190
+ right: hPad + (isRadial ? hPad : axisMargin),
166
191
  bottom: padding + chrome.bottomHeight + xAxisHeight,
167
- left: padding + (isRadial ? padding : axisMargin),
192
+ left: hPad + (isRadial ? hPad : axisMargin),
168
193
  };
169
194
 
170
195
  // Dynamic right margin for line/area end-of-line labels.
@@ -191,7 +216,7 @@ export function computeDimensions(
191
216
  }
192
217
  }
193
218
  if (maxLabelWidth > 0) {
194
- margins.right = Math.max(margins.right, padding + maxLabelWidth + 8);
219
+ margins.right = Math.max(margins.right, hPad + maxLabelWidth + 8);
195
220
  }
196
221
  }
197
222
  }
@@ -232,7 +257,7 @@ export function computeDimensions(
232
257
  textWidth / 2; // centered (top/bottom/auto)
233
258
  const rightOverflow = Math.max(0, baseRightExtent + dx);
234
259
  if (rightOverflow > 0) {
235
- margins.right = Math.max(margins.right, padding + rightOverflow + 12);
260
+ margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
236
261
  }
237
262
  }
238
263
  }
@@ -258,13 +283,18 @@ export function computeDimensions(
258
283
  }
259
284
  if (maxLabelWidth > 0) {
260
285
  // Tighter label-to-chart gap on narrow containers
261
- const labelGap = width < 500 ? 8 : 12;
286
+ const labelGap = width < NARROW_VIEWPORT_MAX ? LABEL_GAP_COMPACT : LABEL_GAP_DEFAULT;
262
287
  // Clamp reservation so bars keep at least ~45% of container width on
263
288
  // narrow viewports. Labels that exceed the cap will be truncated by
264
289
  // the axis renderer (see axes.ts).
265
- const maxLeftFraction = width < 400 ? 0.45 : width < 600 ? 0.55 : 1;
290
+ const maxLeftFraction =
291
+ width < BREAKPOINT_COMPACT_MAX
292
+ ? MAX_LEFT_LABEL_FRACTION_COMPACT
293
+ : width < MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX
294
+ ? MAX_LEFT_LABEL_FRACTION_MEDIUM
295
+ : MAX_LEFT_LABEL_FRACTION_DEFAULT;
266
296
  const maxLeftReserved = Math.floor(width * maxLeftFraction);
267
- const reserved = Math.min(padding + maxLabelWidth + labelGap, maxLeftReserved);
297
+ const reserved = Math.min(hPad + maxLabelWidth + labelGap, maxLeftReserved);
268
298
  margins.left = Math.max(margins.left, reserved);
269
299
  }
270
300
  } else if (encoding.y.type === 'quantitative' || encoding.y.type === 'temporal') {
@@ -306,20 +336,26 @@ export function computeDimensions(
306
336
  theme.fonts.weights.normal,
307
337
  );
308
338
  // 6px gap between label and chart area edge
309
- margins.left = Math.max(margins.left, padding + labelWidth + 10);
339
+ margins.left = Math.max(margins.left, hPad + labelWidth + 10);
310
340
  }
311
341
  }
312
342
 
313
- // Rotated y-axis label needs extra left margin (rendered at area.x - 45 in SVG)
343
+ // Rotated y-axis label needs extra left margin (rendered at area.x - offset in SVG).
344
+ // Tighter on compact viewports where horizontal space is scarce.
314
345
  const yAxis = encoding.y?.axis as Record<string, unknown> | undefined;
315
346
  if (yAxis && (yAxis.title || yAxis.label) && !isRadial) {
316
- const rotatedLabelMargin = 45 + Math.ceil(theme.fonts.sizes.body / 2) + 4;
317
- margins.left = Math.max(margins.left, padding + rotatedLabelMargin);
347
+ const axisTitleOffset = getAxisTitleOffset(width);
348
+ const halfGlyph = Math.ceil(theme.fonts.sizes.body / 2);
349
+ const rotatedLabelMargin =
350
+ axisTitleOffset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
351
+ margins.left = Math.max(margins.left, hPad + rotatedLabelMargin);
318
352
  }
319
353
 
320
- // Reserve space for a secondary (right) y-axis in dual-axis charts
354
+ // Reserve space for a secondary (right) y-axis in dual-axis charts.
355
+ // Use Math.max (not +=) to mirror the left-margin pattern: the reserve
356
+ // replaces the base axisMargin when it's larger, instead of stacking.
321
357
  if (options.rightAxisReserve && options.rightAxisReserve > 0) {
322
- margins.right += options.rightAxisReserve;
358
+ margins.right = Math.max(margins.right, hPad + options.rightAxisReserve);
323
359
  }
324
360
 
325
361
  // Reserve legend space
@@ -359,9 +395,10 @@ export function computeDimensions(
359
395
  watermark,
360
396
  );
361
397
 
362
- // Recalculate top/bottom margins with stripped chrome
398
+ // Recalculate top/bottom margins with stripped chrome.
399
+ // Use topPad (not padding) to preserve the iOS Safari clearance on narrow viewports.
363
400
  const fallbackTopAxisGap = isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin;
364
- const newTop = padding + fallbackChrome.topHeight + fallbackTopAxisGap;
401
+ const newTop = topPad + fallbackChrome.topHeight + fallbackTopAxisGap;
365
402
  const topDelta = margins.top - newTop;
366
403
  const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
367
404
  const bottomDelta = margins.bottom - newBottom;