@opendata-ai/openchart-engine 7.2.3 → 7.3.0
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.d.ts +84 -36
- package/dist/index.js +4013 -2872
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +139 -63
- package/src/__tests__/axes.test.ts +67 -1
- package/src/__tests__/compile-chart.test.ts +6 -0
- package/src/__tests__/compile-layer.test.ts +49 -0
- package/src/__tests__/dimensions.test.ts +7 -2
- package/src/__tests__/helpers/invariants.ts +267 -0
- package/src/__tests__/known-bugs.test.ts +113 -0
- package/src/__tests__/layout-fuzz.test.ts +342 -0
- package/src/__tests__/layout-invariants.test.ts +533 -0
- package/src/__tests__/legend.test.ts +37 -0
- package/src/annotations/__tests__/compute.test.ts +8 -15
- package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
- package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
- package/src/annotations/__tests__/placement.test.ts +316 -0
- package/src/annotations/collisions.ts +19 -7
- package/src/annotations/compute.ts +257 -16
- package/src/annotations/geometry.ts +73 -15
- package/src/annotations/placement.ts +588 -0
- package/src/annotations/resolve-text.ts +58 -10
- package/src/charts/__tests__/post-process.test.ts +3 -3
- package/src/charts/bar/__tests__/compute.test.ts +67 -0
- package/src/charts/bar/compute.ts +8 -1
- package/src/charts/post-process.ts +21 -7
- package/src/compile/layer.ts +31 -9
- package/src/compile.ts +85 -148
- package/src/index.ts +9 -0
- package/src/layout/__tests__/metrics.test.ts +97 -0
- package/src/layout/axes.ts +93 -1
- package/src/layout/dimensions.ts +80 -98
- package/src/layout/plan.ts +524 -0
- package/src/layout/shared.ts +44 -0
- package/src/legend/compute.ts +315 -78
- package/src/legend/wrap.ts +11 -7
- package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
- package/src/tables/__tests__/heatmap.test.ts +24 -20
- package/src/tables/heatmap.ts +25 -9
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CompileOptions,
|
|
3
|
+
Encoding,
|
|
4
|
+
LayoutStrategy,
|
|
5
|
+
MeasureTextFn,
|
|
6
|
+
Rect,
|
|
7
|
+
ResolvedChrome,
|
|
8
|
+
ResolvedTheme,
|
|
9
|
+
} from '@opendata-ai/openchart-core';
|
|
10
|
+
import {
|
|
11
|
+
AXIS_TITLE_TRAILING_PAD,
|
|
12
|
+
abbreviateNumber,
|
|
13
|
+
axisTitleOffset,
|
|
14
|
+
BREAKPOINT_COMPACT_MAX,
|
|
15
|
+
computeChrome,
|
|
16
|
+
computeXAxisExtentFromLabels,
|
|
17
|
+
estimateTextWidth,
|
|
18
|
+
formatNumber,
|
|
19
|
+
HPAD_COMPACT_FRACTION,
|
|
20
|
+
HPAD_COMPACT_MIN,
|
|
21
|
+
LABEL_GAP_COMPACT,
|
|
22
|
+
LABEL_GAP_DEFAULT,
|
|
23
|
+
MAX_LEFT_LABEL_FRACTION_COMPACT,
|
|
24
|
+
MAX_LEFT_LABEL_FRACTION_DEFAULT,
|
|
25
|
+
MAX_LEFT_LABEL_FRACTION_MEDIUM,
|
|
26
|
+
MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
|
|
27
|
+
NARROW_VIEWPORT_MAX,
|
|
28
|
+
TOP_PAD_EXTRA_NARROW,
|
|
29
|
+
} from '@opendata-ai/openchart-core';
|
|
30
|
+
import { format as d3Format } from 'd3-format';
|
|
31
|
+
|
|
32
|
+
import type { NormalizedChartSpec } from '../compiler/types';
|
|
33
|
+
import { computeLegendContent, type LegendContent } from '../legend/compute';
|
|
34
|
+
import { legendGap } from '../legend/wrap';
|
|
35
|
+
import { buildContinuousTicks, scaleSupportsTickCount, targetTickCount } from './axes/ticks';
|
|
36
|
+
import { computeScales } from './scales';
|
|
37
|
+
import { bottomMargin, chromeToInput, INLINE_TICK_OVERHANG_PAD, scalePadding } from './shared';
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// MeasureFn -- simplified width-only text measurement
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/** Width-only text measurement function used by the layout planner. */
|
|
44
|
+
export type MeasureFn = (text: string, fontSize: number, fontWeight: number) => number;
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// LayoutPlan -- frozen dimensions from the measure phase
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/** Frozen layout dimensions produced by the measure phase. */
|
|
51
|
+
export interface LayoutPlan {
|
|
52
|
+
leftGutter: number;
|
|
53
|
+
xAxisExtent: number;
|
|
54
|
+
legendContent: LegendContent;
|
|
55
|
+
chrome: ResolvedChrome;
|
|
56
|
+
yTickValues: unknown[];
|
|
57
|
+
yTickCount: number;
|
|
58
|
+
converged: boolean;
|
|
59
|
+
inlineYLabelInset: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// createMeasureFn -- adapter from MeasureTextFn to MeasureFn
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wraps an optional adapter-provided `MeasureTextFn` (returns `{ width, height }`)
|
|
68
|
+
* into a simpler `MeasureFn` that returns width only. Falls back to the
|
|
69
|
+
* heuristic `estimateTextWidth` when no real measurement is available.
|
|
70
|
+
*/
|
|
71
|
+
export function createMeasureFn(measureText?: MeasureTextFn): MeasureFn {
|
|
72
|
+
if (measureText) {
|
|
73
|
+
return (text: string, fontSize: number, fontWeight: number) =>
|
|
74
|
+
measureText(text, fontSize, fontWeight).width;
|
|
75
|
+
}
|
|
76
|
+
return estimateTextWidth;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Helpers
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/** Format a sample label from magnitude for gutter seeding. */
|
|
84
|
+
function sampleLabelFromMagnitude(maxAbsVal: number): string {
|
|
85
|
+
return maxAbsVal >= 1_000 ? abbreviateNumber(maxAbsVal) : formatNumber(maxAbsVal);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// resolveLayoutPlan
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Measure real axis labels and legend content, then freeze the chart layout.
|
|
94
|
+
*
|
|
95
|
+
* Runs up to 2 iterations: build provisional scales at an estimated gutter,
|
|
96
|
+
* generate real y-axis tick labels, measure their widths, and check whether
|
|
97
|
+
* the gutter changed. If it converged on the first pass, we're done;
|
|
98
|
+
* otherwise a second pass locks in the final gutter as max(pass0, pass1).
|
|
99
|
+
*
|
|
100
|
+
* @param chartSpec - UNFILTERED spec (used for legend -- retains all series)
|
|
101
|
+
* @param renderSpec - FILTERED spec (used for measuring data and scales)
|
|
102
|
+
* @param options - CompileOptions (width, height, measureText, etc.)
|
|
103
|
+
* @param theme - Resolved theme
|
|
104
|
+
* @param strategy - Responsive layout strategy
|
|
105
|
+
* @param watermark - Whether the brand watermark is shown
|
|
106
|
+
* @param measure - Width-only text measurement function
|
|
107
|
+
*/
|
|
108
|
+
export function resolveLayoutPlan(
|
|
109
|
+
chartSpec: NormalizedChartSpec,
|
|
110
|
+
renderSpec: NormalizedChartSpec,
|
|
111
|
+
options: CompileOptions,
|
|
112
|
+
theme: ResolvedTheme,
|
|
113
|
+
strategy: LayoutStrategy,
|
|
114
|
+
watermark: boolean,
|
|
115
|
+
measure: MeasureFn,
|
|
116
|
+
): LayoutPlan {
|
|
117
|
+
const { width, height } = options;
|
|
118
|
+
const padding = scalePadding(theme.spacing.padding, width, height);
|
|
119
|
+
const hPad =
|
|
120
|
+
width < BREAKPOINT_COMPACT_MAX
|
|
121
|
+
? Math.max(Math.round(padding * HPAD_COMPACT_FRACTION), HPAD_COMPACT_MIN)
|
|
122
|
+
: padding;
|
|
123
|
+
const encoding = renderSpec.encoding as Encoding;
|
|
124
|
+
const isRadial = renderSpec.markType === 'arc';
|
|
125
|
+
const axisMargin = theme.spacing.axisMargin;
|
|
126
|
+
|
|
127
|
+
// Resolve chromeMode
|
|
128
|
+
let chromeMode = strategy?.chromeMode ?? 'full';
|
|
129
|
+
if (renderSpec.display === 'sparkline' && !renderSpec.userExplicit?.chrome) {
|
|
130
|
+
chromeMode = 'hidden';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// -----------------------------------------------------------------------
|
|
134
|
+
// Sparkline / radial: trivial plan (no y-axis measurement needed)
|
|
135
|
+
// -----------------------------------------------------------------------
|
|
136
|
+
if (renderSpec.display === 'sparkline' || isRadial) {
|
|
137
|
+
const legendContent = computeLegendContent(
|
|
138
|
+
chartSpec,
|
|
139
|
+
strategy,
|
|
140
|
+
theme,
|
|
141
|
+
width,
|
|
142
|
+
height,
|
|
143
|
+
watermark,
|
|
144
|
+
measure,
|
|
145
|
+
);
|
|
146
|
+
const bottomLegendReservation =
|
|
147
|
+
legendContent.entries.length > 0 && legendContent.position === 'bottom'
|
|
148
|
+
? legendContent.height + legendGap(width)
|
|
149
|
+
: 0;
|
|
150
|
+
const chrome = computeChrome(
|
|
151
|
+
chromeToInput(renderSpec.chrome),
|
|
152
|
+
theme,
|
|
153
|
+
width,
|
|
154
|
+
options.measureText,
|
|
155
|
+
chromeMode,
|
|
156
|
+
padding,
|
|
157
|
+
watermark,
|
|
158
|
+
bottomLegendReservation,
|
|
159
|
+
);
|
|
160
|
+
return {
|
|
161
|
+
leftGutter: hPad,
|
|
162
|
+
xAxisExtent: 0,
|
|
163
|
+
legendContent,
|
|
164
|
+
chrome,
|
|
165
|
+
yTickValues: [],
|
|
166
|
+
yTickCount: 0,
|
|
167
|
+
converged: true,
|
|
168
|
+
inlineYLabelInset: 0,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// -----------------------------------------------------------------------
|
|
173
|
+
// Resolve y-axis inline status
|
|
174
|
+
// -----------------------------------------------------------------------
|
|
175
|
+
const yAxisCfg = (encoding.y?.axis as Record<string, unknown> | undefined) ?? undefined;
|
|
176
|
+
const yTickPositionExplicit = yAxisCfg?.tickPosition as 'inline' | 'gutter' | undefined;
|
|
177
|
+
const yIsContinuous = encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal';
|
|
178
|
+
const yIsLineOrArea = renderSpec.markType === 'line' || renderSpec.markType === 'area';
|
|
179
|
+
const yAxisOrient = yAxisCfg?.orient as string | undefined;
|
|
180
|
+
// Sparkline display already returned early above, so no sparkline guard needed here.
|
|
181
|
+
const yIsInline =
|
|
182
|
+
yTickPositionExplicit === 'inline' ||
|
|
183
|
+
(yTickPositionExplicit === undefined &&
|
|
184
|
+
yIsLineOrArea &&
|
|
185
|
+
yIsContinuous &&
|
|
186
|
+
yAxisOrient !== 'right');
|
|
187
|
+
const yAxisSuppressed = encoding.y?.axis === false;
|
|
188
|
+
|
|
189
|
+
// -----------------------------------------------------------------------
|
|
190
|
+
// Seed gutter from formatted max |y|
|
|
191
|
+
// -----------------------------------------------------------------------
|
|
192
|
+
let leftGutter = hPad + axisMargin; // base: pad + axis margin
|
|
193
|
+
if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
|
|
194
|
+
if (
|
|
195
|
+
encoding.y.type === 'nominal' ||
|
|
196
|
+
encoding.y.type === 'ordinal' ||
|
|
197
|
+
renderSpec.markType === 'bar' ||
|
|
198
|
+
renderSpec.markType === 'circle' ||
|
|
199
|
+
renderSpec.markType === 'lollipop'
|
|
200
|
+
) {
|
|
201
|
+
// Category labels: measure real data values
|
|
202
|
+
const yField = encoding.y.field;
|
|
203
|
+
const yLabelField = yAxisCfg?.labelField as string | undefined;
|
|
204
|
+
let maxLabelWidth = 0;
|
|
205
|
+
for (const row of renderSpec.data) {
|
|
206
|
+
const label = String(row[yField] ?? '');
|
|
207
|
+
let w = measure(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
208
|
+
if (yLabelField) {
|
|
209
|
+
const subtitle = String(row[yLabelField] ?? '');
|
|
210
|
+
if (subtitle) {
|
|
211
|
+
const gap = theme.fonts.sizes.axisTick * 0.6;
|
|
212
|
+
const subtitleWidth = measure(
|
|
213
|
+
subtitle,
|
|
214
|
+
theme.fonts.sizes.axisTick,
|
|
215
|
+
theme.fonts.weights.normal,
|
|
216
|
+
);
|
|
217
|
+
w += gap + subtitleWidth;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
221
|
+
}
|
|
222
|
+
if (maxLabelWidth > 0) {
|
|
223
|
+
const labelGap = width < NARROW_VIEWPORT_MAX ? LABEL_GAP_COMPACT : LABEL_GAP_DEFAULT;
|
|
224
|
+
const maxLeftFraction =
|
|
225
|
+
width < BREAKPOINT_COMPACT_MAX
|
|
226
|
+
? MAX_LEFT_LABEL_FRACTION_COMPACT
|
|
227
|
+
: width < MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX
|
|
228
|
+
? MAX_LEFT_LABEL_FRACTION_MEDIUM
|
|
229
|
+
: MAX_LEFT_LABEL_FRACTION_DEFAULT;
|
|
230
|
+
const maxLeftReserved = Math.floor(width * maxLeftFraction);
|
|
231
|
+
const reserved = Math.min(hPad + maxLabelWidth + labelGap, maxLeftReserved);
|
|
232
|
+
leftGutter = Math.max(leftGutter, reserved);
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
// Quantitative: seed with a formatted label (better than '1.5B' guess)
|
|
236
|
+
const yField = encoding.y.field;
|
|
237
|
+
const yAxisFormat = yAxisCfg?.format as string | undefined;
|
|
238
|
+
let maxAbsVal = 0;
|
|
239
|
+
for (const row of renderSpec.data) {
|
|
240
|
+
const v = Number(row[yField]);
|
|
241
|
+
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
|
|
242
|
+
}
|
|
243
|
+
let sampleLabel: string;
|
|
244
|
+
if (yAxisFormat) {
|
|
245
|
+
try {
|
|
246
|
+
sampleLabel = d3Format(yAxisFormat)(maxAbsVal);
|
|
247
|
+
} catch {
|
|
248
|
+
sampleLabel = String(maxAbsVal);
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
sampleLabel = sampleLabelFromMagnitude(maxAbsVal);
|
|
252
|
+
}
|
|
253
|
+
const negPrefix = renderSpec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
|
|
254
|
+
const labelWidth = measure(
|
|
255
|
+
negPrefix + sampleLabel,
|
|
256
|
+
theme.fonts.sizes.axisTick,
|
|
257
|
+
theme.fonts.weights.normal,
|
|
258
|
+
);
|
|
259
|
+
leftGutter = Math.max(leftGutter, hPad + labelWidth + 10);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// -----------------------------------------------------------------------
|
|
264
|
+
// X-axis setup (doesn't change between iterations)
|
|
265
|
+
// -----------------------------------------------------------------------
|
|
266
|
+
const xAxisSuppressed = encoding.x?.axis === false;
|
|
267
|
+
const xAxis = (!xAxisSuppressed && encoding.x?.axis) as Record<string, unknown> | undefined;
|
|
268
|
+
const hasXAxisTitle = !!xAxis?.title;
|
|
269
|
+
const xTickAngle = xAxis?.labelAngle as number | undefined;
|
|
270
|
+
|
|
271
|
+
// Collect x labels for extent computation
|
|
272
|
+
const xLabels: string[] = [];
|
|
273
|
+
if (!isRadial && !xAxisSuppressed && encoding.x) {
|
|
274
|
+
const xField = encoding.x.field;
|
|
275
|
+
if (encoding.x.type === 'nominal' || encoding.x.type === 'ordinal') {
|
|
276
|
+
const seen = new Set<string>();
|
|
277
|
+
for (const row of renderSpec.data) {
|
|
278
|
+
const label = String(row[xField] ?? '');
|
|
279
|
+
if (!seen.has(label)) {
|
|
280
|
+
seen.add(label);
|
|
281
|
+
xLabels.push(label);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// -----------------------------------------------------------------------
|
|
288
|
+
// Iteration loop (max 2)
|
|
289
|
+
// -----------------------------------------------------------------------
|
|
290
|
+
let prev: { gutter: number; rowCount: number; xAxisExtent: number } | null = null;
|
|
291
|
+
let finalPlan: LayoutPlan | null = null;
|
|
292
|
+
|
|
293
|
+
for (let iter = 0; iter < 2; iter++) {
|
|
294
|
+
// Legend content
|
|
295
|
+
const legendAvailWidth = width - padding - leftGutter;
|
|
296
|
+
const legendContent = computeLegendContent(
|
|
297
|
+
chartSpec,
|
|
298
|
+
strategy,
|
|
299
|
+
theme,
|
|
300
|
+
legendAvailWidth,
|
|
301
|
+
height,
|
|
302
|
+
watermark,
|
|
303
|
+
measure,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Chrome
|
|
307
|
+
const bottomLegendReservation =
|
|
308
|
+
legendContent.entries.length > 0 && legendContent.position === 'bottom'
|
|
309
|
+
? legendContent.height + legendGap(width)
|
|
310
|
+
: 0;
|
|
311
|
+
const chrome = computeChrome(
|
|
312
|
+
chromeToInput(renderSpec.chrome),
|
|
313
|
+
theme,
|
|
314
|
+
width,
|
|
315
|
+
options.measureText,
|
|
316
|
+
chromeMode,
|
|
317
|
+
padding,
|
|
318
|
+
watermark,
|
|
319
|
+
bottomLegendReservation,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// X-axis extent
|
|
323
|
+
const xAxisExtent =
|
|
324
|
+
isRadial || xAxisSuppressed
|
|
325
|
+
? 0
|
|
326
|
+
: computeXAxisExtentFromLabels({
|
|
327
|
+
labels: xLabels,
|
|
328
|
+
tickAngle: xTickAngle,
|
|
329
|
+
hasTitle: hasXAxisTitle,
|
|
330
|
+
tickFontSize: theme.fonts.sizes.axisTick,
|
|
331
|
+
tickFontWeight: theme.fonts.weights.normal,
|
|
332
|
+
xAxisHeight: theme.spacing.xAxisHeight,
|
|
333
|
+
measure,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Top margin
|
|
337
|
+
const topPad = width < NARROW_VIEWPORT_MAX ? padding + TOP_PAD_EXTRA_NARROW : padding;
|
|
338
|
+
const hasTopLegend = legendContent.entries.length > 0 && legendContent.position === 'top';
|
|
339
|
+
const gap = legendGap(width);
|
|
340
|
+
const inlineTickOverhang = yIsInline
|
|
341
|
+
? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
|
|
342
|
+
: 0;
|
|
343
|
+
const topAxisGap = axisMargin + inlineTickOverhang;
|
|
344
|
+
const effectiveTopAxisGap = hasTopLegend ? inlineTickOverhang : topAxisGap;
|
|
345
|
+
|
|
346
|
+
let topMargin = topPad + chrome.topHeight;
|
|
347
|
+
if (hasTopLegend) topMargin += legendContent.height + gap;
|
|
348
|
+
topMargin += effectiveTopAxisGap;
|
|
349
|
+
|
|
350
|
+
const bMargin = bottomMargin(chrome.bottomHeight, padding, xAxisExtent);
|
|
351
|
+
const chartHeight = Math.max(0, height - topMargin - bMargin);
|
|
352
|
+
|
|
353
|
+
// Provisional area
|
|
354
|
+
const rightMargin = hPad + (isRadial ? hPad : axisMargin);
|
|
355
|
+
const rightAxisReserve = options.rightAxisReserve ?? 0;
|
|
356
|
+
const provisionalArea: Rect = {
|
|
357
|
+
x: leftGutter,
|
|
358
|
+
y: topMargin,
|
|
359
|
+
width: Math.max(0, width - leftGutter - Math.max(rightMargin, hPad + rightAxisReserve)),
|
|
360
|
+
height: chartHeight,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// Build provisional scales and generate real y ticks
|
|
364
|
+
let yTickValues: unknown[] = [];
|
|
365
|
+
let yTickCount = 0;
|
|
366
|
+
let newGutter = leftGutter;
|
|
367
|
+
|
|
368
|
+
if (encoding.y && yIsContinuous && !yAxisSuppressed) {
|
|
369
|
+
const scales = computeScales(renderSpec, provisionalArea, renderSpec.data);
|
|
370
|
+
if (scales.y && scaleSupportsTickCount(scales.y)) {
|
|
371
|
+
const yTargetCount = targetTickCount(chartHeight, 'full', 'y');
|
|
372
|
+
const axisTickCount = yAxisCfg?.tickCount as number | undefined;
|
|
373
|
+
const effectiveCount = axisTickCount ?? yTargetCount;
|
|
374
|
+
const ticks = buildContinuousTicks(scales.y, effectiveCount);
|
|
375
|
+
yTickValues = ticks.map((t) => t.value);
|
|
376
|
+
yTickCount = ticks.length;
|
|
377
|
+
|
|
378
|
+
// Measure real tick labels for gutter
|
|
379
|
+
if (!yIsInline) {
|
|
380
|
+
let maxLabelWidth = 0;
|
|
381
|
+
for (const t of ticks) {
|
|
382
|
+
const w = measure(t.label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
383
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
384
|
+
}
|
|
385
|
+
newGutter = hPad + maxLabelWidth + 10;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Convergence check
|
|
391
|
+
const converged =
|
|
392
|
+
prev != null &&
|
|
393
|
+
Math.abs(newGutter - prev.gutter) <= 0.5 &&
|
|
394
|
+
legendContent.rowCount === prev.rowCount &&
|
|
395
|
+
Math.abs(xAxisExtent - prev.xAxisExtent) <= 0.5;
|
|
396
|
+
|
|
397
|
+
if (converged || iter === 1) {
|
|
398
|
+
// On non-convergence at iter=1: gutter = max(iter0, iter1)
|
|
399
|
+
const finalGutter = prev && !converged ? Math.max(prev.gutter, newGutter) : newGutter;
|
|
400
|
+
|
|
401
|
+
// If non-convergent, regenerate ticks once at the final height
|
|
402
|
+
if (prev && !converged && encoding.y && yIsContinuous && !yAxisSuppressed) {
|
|
403
|
+
const finalProvArea: Rect = {
|
|
404
|
+
x: finalGutter,
|
|
405
|
+
y: topMargin,
|
|
406
|
+
width: Math.max(0, width - finalGutter - Math.max(rightMargin, hPad + rightAxisReserve)),
|
|
407
|
+
height: chartHeight,
|
|
408
|
+
};
|
|
409
|
+
const scales = computeScales(renderSpec, finalProvArea, renderSpec.data);
|
|
410
|
+
if (scales.y && scaleSupportsTickCount(scales.y)) {
|
|
411
|
+
const yTargetCount = targetTickCount(chartHeight, 'full', 'y');
|
|
412
|
+
const axisTickCount = yAxisCfg?.tickCount as number | undefined;
|
|
413
|
+
const effectiveCount = axisTickCount ?? yTargetCount;
|
|
414
|
+
const ticks = buildContinuousTicks(scales.y, effectiveCount);
|
|
415
|
+
yTickValues = ticks.map((t) => t.value);
|
|
416
|
+
yTickCount = ticks.length;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Compute inline y-label inset
|
|
421
|
+
let inlineYLabelInset = 0;
|
|
422
|
+
if (yIsInline && encoding.y && yIsContinuous && encoding.y.axis !== false) {
|
|
423
|
+
if (yTickValues.length > 0) {
|
|
424
|
+
// Build ticks from the final scales to get formatted labels
|
|
425
|
+
const finalProvArea: Rect = {
|
|
426
|
+
x: finalGutter,
|
|
427
|
+
y: topMargin,
|
|
428
|
+
width: Math.max(0, width - finalGutter - rightMargin),
|
|
429
|
+
height: chartHeight,
|
|
430
|
+
};
|
|
431
|
+
const scales = computeScales(renderSpec, finalProvArea, renderSpec.data);
|
|
432
|
+
if (scales.y && scaleSupportsTickCount(scales.y)) {
|
|
433
|
+
const ticks = buildContinuousTicks(scales.y, yTickCount || 5);
|
|
434
|
+
let maxLabelWidth = 0;
|
|
435
|
+
for (const t of ticks) {
|
|
436
|
+
const w = measure(t.label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
437
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
438
|
+
}
|
|
439
|
+
if (maxLabelWidth > 0) {
|
|
440
|
+
inlineYLabelInset = Math.ceil(maxLabelWidth + 8);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
// Fallback: measure from data max
|
|
445
|
+
const yField = encoding.y.field;
|
|
446
|
+
const yAxisFormat = yAxisCfg?.format as string | undefined;
|
|
447
|
+
let maxAbsVal = 0;
|
|
448
|
+
for (const row of renderSpec.data) {
|
|
449
|
+
const v = Number(row[yField]);
|
|
450
|
+
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
|
|
451
|
+
}
|
|
452
|
+
let sample: string;
|
|
453
|
+
if (yAxisFormat) {
|
|
454
|
+
try {
|
|
455
|
+
sample = d3Format(yAxisFormat)(maxAbsVal);
|
|
456
|
+
} catch {
|
|
457
|
+
sample = String(maxAbsVal);
|
|
458
|
+
}
|
|
459
|
+
} else {
|
|
460
|
+
sample = sampleLabelFromMagnitude(maxAbsVal);
|
|
461
|
+
}
|
|
462
|
+
const neg = renderSpec.data.some((r) => Number(r[encoding.y!.field]) < 0) ? '-' : '';
|
|
463
|
+
const maxLabelWidth = measure(
|
|
464
|
+
neg + sample,
|
|
465
|
+
theme.fonts.sizes.axisTick,
|
|
466
|
+
theme.fonts.weights.normal,
|
|
467
|
+
);
|
|
468
|
+
if (maxLabelWidth > 0) {
|
|
469
|
+
inlineYLabelInset = Math.ceil(maxLabelWidth + 8);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Account for rotated y-axis title in the gutter
|
|
475
|
+
let gutterWithTitle = finalGutter;
|
|
476
|
+
const yAxisDef = encoding.y?.axis as Record<string, unknown> | undefined;
|
|
477
|
+
if (yAxisDef && (yAxisDef.title || yAxisDef.label) && !isRadial) {
|
|
478
|
+
let estTickLabelWidth = 0;
|
|
479
|
+
if (encoding.y?.field && yIsContinuous) {
|
|
480
|
+
if (yTickValues.length > 0) {
|
|
481
|
+
const titleProvArea: Rect = {
|
|
482
|
+
x: finalGutter,
|
|
483
|
+
y: topMargin,
|
|
484
|
+
width: Math.max(0, width - finalGutter - hPad),
|
|
485
|
+
height: chartHeight,
|
|
486
|
+
};
|
|
487
|
+
const scales = computeScales(renderSpec, titleProvArea, renderSpec.data);
|
|
488
|
+
if (scales.y && scaleSupportsTickCount(scales.y)) {
|
|
489
|
+
const ticks = buildContinuousTicks(scales.y, yTickCount || 5);
|
|
490
|
+
for (const t of ticks) {
|
|
491
|
+
const w = measure(t.label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
492
|
+
if (w > estTickLabelWidth) estTickLabelWidth = w;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const titleFontSize = theme.fonts.sizes.body;
|
|
498
|
+
const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width);
|
|
499
|
+
const halfGlyph = Math.ceil(titleFontSize / 2);
|
|
500
|
+
const rotatedLabelMargin =
|
|
501
|
+
offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
|
|
502
|
+
gutterWithTitle = Math.max(finalGutter, hPad + rotatedLabelMargin);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
finalPlan = {
|
|
506
|
+
leftGutter: gutterWithTitle,
|
|
507
|
+
xAxisExtent,
|
|
508
|
+
legendContent,
|
|
509
|
+
chrome,
|
|
510
|
+
yTickValues,
|
|
511
|
+
yTickCount,
|
|
512
|
+
converged: prev === null || converged,
|
|
513
|
+
inlineYLabelInset,
|
|
514
|
+
};
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
prev = { gutter: newGutter, rowCount: legendContent.rowCount, xAxisExtent };
|
|
519
|
+
leftGutter = newGutter;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!finalPlan) throw new Error('resolveLayoutPlan: loop exited without producing a plan');
|
|
523
|
+
return finalPlan;
|
|
524
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { NormalizedChrome } from '../compiler/types';
|
|
2
|
+
|
|
3
|
+
/** Convert NormalizedChrome back to a Chrome-compatible shape for computeChrome. */
|
|
4
|
+
export function chromeToInput(
|
|
5
|
+
chrome: NormalizedChrome,
|
|
6
|
+
): import('@opendata-ai/openchart-core').Chrome {
|
|
7
|
+
return {
|
|
8
|
+
eyebrow: chrome.eyebrow,
|
|
9
|
+
title: chrome.title,
|
|
10
|
+
subtitle: chrome.subtitle,
|
|
11
|
+
source: chrome.source,
|
|
12
|
+
byline: chrome.byline,
|
|
13
|
+
footer: chrome.footer,
|
|
14
|
+
brand: chrome.brand,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Scale padding based on the smaller container dimension.
|
|
20
|
+
* At >= 500px, padding is unchanged. At <= 200px, padding is halved (min 4px).
|
|
21
|
+
* Linear interpolation between 200-500px.
|
|
22
|
+
*/
|
|
23
|
+
export function scalePadding(basePadding: number, width: number, height: number): number {
|
|
24
|
+
const minDim = Math.min(width, height);
|
|
25
|
+
if (minDim >= 500) return basePadding;
|
|
26
|
+
if (minDim <= 200) return Math.max(Math.round(basePadding * 0.5), 4);
|
|
27
|
+
const t = (minDim - 200) / 300;
|
|
28
|
+
return Math.max(Math.round(basePadding * (0.5 + t * 0.5)), 4);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compute the bottom margin contribution from chrome.
|
|
33
|
+
* Padding always applies (gap between x-axis ticks and the chrome below).
|
|
34
|
+
* bottomHeight is additive on top.
|
|
35
|
+
*/
|
|
36
|
+
export function bottomMargin(bottomHeight: number, padding: number, xAxisHeight: number): number {
|
|
37
|
+
return padding + bottomHeight + xAxisHeight;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Vertical breathing room added to the inline-tick label height so the
|
|
42
|
+
* topmost tick has clearance from chrome.
|
|
43
|
+
*/
|
|
44
|
+
export const INLINE_TICK_OVERHANG_PAD = 6;
|