@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/dist/index.js +315 -266
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/compile.ts +56 -11
- package/src/layout/axes/ticks.ts +4 -3
- package/src/layout/dimensions.ts +54 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.25.
|
|
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.
|
|
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
|
|
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 (
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
|
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
|
|
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,
|
package/src/layout/axes/ticks.ts
CHANGED
|
@@ -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
|
|
215
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -21,7 +21,23 @@ import type {
|
|
|
21
21
|
ResolvedChrome,
|
|
22
22
|
ResolvedTheme,
|
|
23
23
|
} from '@opendata-ai/openchart-core';
|
|
24
|
-
import {
|
|
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:
|
|
165
|
-
right:
|
|
189
|
+
top: topPad + chrome.topHeight + topAxisGap,
|
|
190
|
+
right: hPad + (isRadial ? hPad : axisMargin),
|
|
166
191
|
bottom: padding + chrome.bottomHeight + xAxisHeight,
|
|
167
|
-
left:
|
|
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,
|
|
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,
|
|
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 <
|
|
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 =
|
|
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(
|
|
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,
|
|
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 -
|
|
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
|
|
317
|
-
|
|
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
|
|
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 =
|
|
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;
|