@opendata-ai/openchart-engine 6.25.0 → 6.25.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.25.0",
3
+ "version": "6.25.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",
@@ -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.2",
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';
@@ -495,7 +500,15 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
495
500
  seenLabels.add(entry.label);
496
501
  }
497
502
 
498
- for (const leaf of leaves) {
503
+ // Sort leaves by zIndex for render order while preserving original indices
504
+ // for axis assignment. Default zIndex is the array position.
505
+ const indexedLeaves = leaves.map((leaf, i) => ({
506
+ leaf,
507
+ zIndex: (leaf as ChartSpec).zIndex ?? i,
508
+ }));
509
+ indexedLeaves.sort((a, b) => a.zIndex - b.zIndex);
510
+
511
+ for (const { leaf } of indexedLeaves) {
499
512
  const leafLayout = compileChart(leaf as unknown, options);
500
513
 
501
514
  allMarks.push(...leafLayout.marks);
@@ -548,18 +561,30 @@ function estimateYAxisLabelWidth(
548
561
  }
549
562
 
550
563
  // Quantitative/temporal: estimate from the largest value
564
+ const yAxisFormat = (encoding.y.axis as Record<string, unknown> | undefined)?.format as
565
+ | string
566
+ | undefined;
551
567
  let maxAbsVal = 0;
552
568
  for (const row of data) {
553
569
  const v = Number(row[yField]);
554
570
  if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
555
571
  }
556
572
  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';
573
+ if (yAxisFormat) {
574
+ try {
575
+ const fmt = d3Format(yAxisFormat);
576
+ sampleLabel = fmt(maxAbsVal);
577
+ } catch {
578
+ sampleLabel = String(maxAbsVal);
579
+ }
580
+ } else {
581
+ if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
582
+ else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
583
+ else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
584
+ else if (maxAbsVal >= 100) sampleLabel = '100';
585
+ else if (maxAbsVal >= 10) sampleLabel = '10';
586
+ else sampleLabel = '0.0';
587
+ }
563
588
  const hasNeg = data.some((r) => Number(r[yField]) < 0);
564
589
  const labelEst = (hasNeg ? '-' : '') + sampleLabel;
565
590
  return estimateTextWidth(labelEst, baseFontSize, 400) + 10;
@@ -596,13 +621,24 @@ function compileLayerIndependent(
596
621
  );
597
622
  }
598
623
 
599
- // Estimate right-axis label width to reserve margin space
624
+ // Estimate right-axis label width to reserve margin space.
625
+ // Tick labels sit at chartEdge+6 and extend rightward by their width.
626
+ // The rotated title sits at chartEdge+45 and extends by half the font height.
627
+ // These overlap spatially, so use max (not sum) to mirror the left-margin pattern.
600
628
  const theme = resolveTheme(layerSpec.theme ?? leaf1.theme);
601
629
  const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11;
602
630
  const rightAxisWidth = estimateYAxisLabelWidth(leaf1.data, leaf1.encoding, axisFontSize);
603
- // Add space for the rotated axis title if present (match left-axis 45px clearance)
604
631
  const hasRightAxisTitle = !!leaf1.encoding?.y?.axis?.title;
605
- const rightReserve = rightAxisWidth + (hasRightAxisTitle ? 45 : 0);
632
+ const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
633
+ const bodyFontSize = theme.fonts?.sizes?.body ?? 13;
634
+ const axisTitleOffset = getAxisTitleOffset(options.width);
635
+ const halfGlyph = Math.ceil(bodyFontSize / 2);
636
+ const titleExtent = hasRightAxisTitle
637
+ ? axisTitleOffset +
638
+ halfGlyph +
639
+ (options.width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD)
640
+ : 0;
641
+ const rightReserve = Math.max(tickExtent, titleExtent);
606
642
 
607
643
  const optionsWithReserve: CompileOptions = {
608
644
  ...options,
@@ -793,6 +829,14 @@ function compileLayerIndependent(
793
829
  }
794
830
  }
795
831
 
832
+ // Determine mark render order. By default, layer 0 paints first (behind),
833
+ // layer 1 paints second (on top). zIndex on the original leaf specs can
834
+ // reverse this so e.g. a line in layer 0 renders on top of bars in layer 1.
835
+ const z0 = leaf0.zIndex ?? 0;
836
+ const z1 = leaf1.zIndex ?? 1;
837
+ const marks =
838
+ z0 <= z1 ? [...adjustedMarks0, ...taggedMarks1] : [...taggedMarks1, ...adjustedMarks0];
839
+
796
840
  return {
797
841
  ...layout0,
798
842
  axes: {
@@ -800,7 +844,7 @@ function compileLayerIndependent(
800
844
  y: layout0.axes.y,
801
845
  y2: y2Axis,
802
846
  },
803
- marks: [...adjustedMarks0, ...taggedMarks1],
847
+ marks,
804
848
  legend: {
805
849
  ...layout0.legend,
806
850
  entries: mergedLegendEntries,
@@ -960,6 +1004,7 @@ function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec
960
1004
  data: allData,
961
1005
  // Layer-level chrome overrides leaf chrome
962
1006
  chrome: layerSpec.chrome ?? leaves[0].chrome,
1007
+ annotations: layerSpec.annotations ?? leaves[0].annotations,
963
1008
  labels: layerSpec.labels ?? leaves[0].labels,
964
1009
  legend: layerSpec.legend ?? leaves[0].legend,
965
1010
  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;