@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/dist/index.js +305 -264
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/compile.ts +38 -9
- 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.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.
|
|
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 (
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
|
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,
|
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;
|