@opendata-ai/openchart-engine 2.9.1 → 2.11.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.js +317 -62
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +4 -0
- package/src/__tests__/axes.test.ts +183 -2
- package/src/__tests__/legend.test.ts +4 -0
- package/src/annotations/__tests__/compute.test.ts +173 -4
- package/src/annotations/compute.ts +158 -41
- package/src/charts/column/__tests__/labels.test.ts +104 -0
- package/src/charts/dot/__tests__/labels.test.ts +98 -0
- package/src/charts/pie/__tests__/labels.test.ts +132 -0
- package/src/compile.ts +63 -13
- package/src/layout/axes.ts +131 -11
- package/src/layout/dimensions.ts +77 -4
- package/src/legend/compute.ts +105 -7
package/src/compile.ts
CHANGED
|
@@ -25,9 +25,12 @@ import type {
|
|
|
25
25
|
} from '@opendata-ai/openchart-core';
|
|
26
26
|
import {
|
|
27
27
|
adaptTheme,
|
|
28
|
+
BRAND_RESERVE_WIDTH,
|
|
29
|
+
computeLabelBounds,
|
|
28
30
|
generateAltText,
|
|
29
31
|
generateDataTable,
|
|
30
32
|
getBreakpoint,
|
|
33
|
+
getHeightClass,
|
|
31
34
|
getLayoutStrategy,
|
|
32
35
|
resolveTheme,
|
|
33
36
|
} from '@opendata-ai/openchart-core';
|
|
@@ -74,14 +77,41 @@ import { computeTooltipDescriptors } from './tooltips/compute';
|
|
|
74
77
|
// ---------------------------------------------------------------------------
|
|
75
78
|
|
|
76
79
|
/**
|
|
77
|
-
* Compute
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
+
* Compute bounding rects from marks to use as obstacles for annotation nudging.
|
|
81
|
+
*
|
|
82
|
+
* For band-scale charts (bar, dot): groups marks by band row and returns
|
|
83
|
+
* a single obstacle per row spanning the full band height and x-range.
|
|
84
|
+
*
|
|
85
|
+
* For other charts (column, scatter): returns individual mark bounds so
|
|
86
|
+
* annotations avoid overlapping any visible data mark.
|
|
80
87
|
*/
|
|
81
|
-
function
|
|
82
|
-
|
|
88
|
+
function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
|
|
89
|
+
// Band-scale y-axis: group marks by row for efficient obstacle computation
|
|
90
|
+
if (scales.y?.type === 'band') {
|
|
91
|
+
return computeBandRowObstacles(marks, scales);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// All other charts: use individual rect/point mark bounds as obstacles
|
|
95
|
+
const obstacles: Rect[] = [];
|
|
96
|
+
for (const mark of marks) {
|
|
97
|
+
if (mark.type === 'rect') {
|
|
98
|
+
const rm = mark as RectMark;
|
|
99
|
+
obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
|
|
100
|
+
} else if (mark.type === 'point') {
|
|
101
|
+
const pm = mark as PointMark;
|
|
102
|
+
obstacles.push({
|
|
103
|
+
x: pm.cx - pm.r,
|
|
104
|
+
y: pm.cy - pm.r,
|
|
105
|
+
width: pm.r * 2,
|
|
106
|
+
height: pm.r * 2,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return obstacles;
|
|
111
|
+
}
|
|
83
112
|
|
|
84
|
-
|
|
113
|
+
/** Group band-scale marks by row, returning one obstacle per band. */
|
|
114
|
+
function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
|
|
85
115
|
const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
|
|
86
116
|
|
|
87
117
|
for (const mark of marks) {
|
|
@@ -115,7 +145,7 @@ function computeRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
|
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
// Get bandwidth from the band scale
|
|
118
|
-
const bandScale = scales.y
|
|
148
|
+
const bandScale = scales.y!.scale as { bandwidth?: () => number };
|
|
119
149
|
const bandwidth = bandScale.bandwidth?.() ?? 0;
|
|
120
150
|
if (bandwidth === 0) return [];
|
|
121
151
|
|
|
@@ -163,7 +193,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
163
193
|
|
|
164
194
|
// Responsive strategy
|
|
165
195
|
const breakpoint = getBreakpoint(options.width);
|
|
166
|
-
const
|
|
196
|
+
const heightClass = getHeightClass(options.height);
|
|
197
|
+
const strategy = getLayoutStrategy(breakpoint, heightClass);
|
|
167
198
|
|
|
168
199
|
// Apply breakpoint-conditional overrides from the original spec
|
|
169
200
|
const rawSpec = spec as Record<string, unknown>;
|
|
@@ -230,8 +261,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
230
261
|
};
|
|
231
262
|
const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea);
|
|
232
263
|
|
|
233
|
-
// Compute dimensions (accounts for chrome + legend)
|
|
234
|
-
const dims = computeDimensions(chartSpec, options, legendLayout, theme);
|
|
264
|
+
// Compute dimensions (accounts for chrome + legend + responsive strategy)
|
|
265
|
+
const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy);
|
|
235
266
|
const chartArea = dims.chartArea;
|
|
236
267
|
|
|
237
268
|
// Recompute legend bounds relative to actual chart area.
|
|
@@ -313,7 +344,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
313
344
|
// Compute axes (skip for radial charts)
|
|
314
345
|
const axes = isRadial
|
|
315
346
|
? { x: undefined, y: undefined }
|
|
316
|
-
: computeAxes(scales, chartArea, strategy, theme);
|
|
347
|
+
: computeAxes(scales, chartArea, strategy, theme, options.measureText);
|
|
317
348
|
|
|
318
349
|
// Compute gridlines (stored in axes, used by adapters via axes.y.gridlines)
|
|
319
350
|
if (!isRadial) {
|
|
@@ -324,12 +355,31 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
324
355
|
const renderer = getChartRenderer(renderSpec.type);
|
|
325
356
|
const marks: Mark[] = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
|
|
326
357
|
|
|
327
|
-
// Compute annotations from spec, passing legend + mark bounds as obstacles
|
|
358
|
+
// Compute annotations from spec, passing legend + mark + brand bounds as obstacles
|
|
328
359
|
const obstacles: Rect[] = [];
|
|
329
360
|
if (finalLegend.bounds.width > 0) {
|
|
330
361
|
obstacles.push(finalLegend.bounds);
|
|
331
362
|
}
|
|
332
|
-
obstacles.push(...
|
|
363
|
+
obstacles.push(...computeMarkObstacles(marks, scales));
|
|
364
|
+
|
|
365
|
+
// Add visible data label bounds as obstacles so annotations avoid overlapping them
|
|
366
|
+
for (const mark of marks) {
|
|
367
|
+
if (mark.type !== 'area' && mark.label?.visible) {
|
|
368
|
+
obstacles.push(computeLabelBounds(mark.label));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Add brand watermark as an obstacle so annotations avoid overlapping it.
|
|
373
|
+
// The brand is right-aligned on the same baseline as the first bottom chrome element,
|
|
374
|
+
// offset below the chart area by x-axis extent (tick labels + axis title).
|
|
375
|
+
const brandPadding = theme.spacing.padding;
|
|
376
|
+
const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH;
|
|
377
|
+
const xAxisExtent = axes.x?.label ? 48 : axes.x ? 26 : 0;
|
|
378
|
+
const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
|
|
379
|
+
const brandY = firstBottomChrome
|
|
380
|
+
? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y
|
|
381
|
+
: chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
|
|
382
|
+
obstacles.push({ x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH, height: 30 });
|
|
333
383
|
const annotations: ResolvedAnnotation[] = computeAnnotations(
|
|
334
384
|
chartSpec,
|
|
335
385
|
scales,
|
package/src/layout/axes.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
AxisTick,
|
|
12
12
|
Gridline,
|
|
13
13
|
LayoutStrategy,
|
|
14
|
+
MeasureTextFn,
|
|
14
15
|
Rect,
|
|
15
16
|
ResolvedTheme,
|
|
16
17
|
TextStyle,
|
|
@@ -18,6 +19,7 @@ import type {
|
|
|
18
19
|
import {
|
|
19
20
|
abbreviateNumber,
|
|
20
21
|
buildD3Formatter,
|
|
22
|
+
estimateTextWidth,
|
|
21
23
|
formatDate,
|
|
22
24
|
formatNumber,
|
|
23
25
|
} from '@opendata-ai/openchart-core';
|
|
@@ -56,6 +58,15 @@ const HEIGHT_REDUCED_THRESHOLD = 200;
|
|
|
56
58
|
const WIDTH_MINIMAL_THRESHOLD = 150;
|
|
57
59
|
const WIDTH_REDUCED_THRESHOLD = 300;
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Minimum gap between adjacent tick labels as a multiple of font size.
|
|
63
|
+
* At the default 12px axis font, this yields ~12px of breathing room.
|
|
64
|
+
*/
|
|
65
|
+
const MIN_TICK_GAP_FACTOR = 1.0;
|
|
66
|
+
|
|
67
|
+
/** Always show at least this many ticks, even if they overlap. */
|
|
68
|
+
const MIN_TICK_COUNT = 2;
|
|
69
|
+
|
|
59
70
|
/** Ordered densities from most to fewest ticks. */
|
|
60
71
|
const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
|
|
61
72
|
|
|
@@ -94,25 +105,106 @@ export function effectiveDensity(
|
|
|
94
105
|
return density;
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Label overlap detection and thinning
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/** Measure a single label's width using real measurement or heuristic fallback. */
|
|
113
|
+
function measureLabel(
|
|
114
|
+
text: string,
|
|
115
|
+
fontSize: number,
|
|
116
|
+
fontWeight: number,
|
|
117
|
+
measureText?: MeasureTextFn,
|
|
118
|
+
): number {
|
|
119
|
+
return measureText
|
|
120
|
+
? measureText(text, fontSize, fontWeight).width
|
|
121
|
+
: estimateTextWidth(text, fontSize, fontWeight);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Check whether any adjacent tick labels overlap horizontally. */
|
|
125
|
+
export function ticksOverlap(
|
|
126
|
+
ticks: AxisTick[],
|
|
127
|
+
fontSize: number,
|
|
128
|
+
fontWeight: number,
|
|
129
|
+
measureText?: MeasureTextFn,
|
|
130
|
+
): boolean {
|
|
131
|
+
if (ticks.length < 2) return false;
|
|
132
|
+
const minGap = fontSize * MIN_TICK_GAP_FACTOR;
|
|
133
|
+
for (let i = 0; i < ticks.length - 1; i++) {
|
|
134
|
+
const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText);
|
|
135
|
+
const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText);
|
|
136
|
+
const aRight = ticks[i].position + aWidth / 2;
|
|
137
|
+
const bLeft = ticks[i + 1].position - bWidth / 2;
|
|
138
|
+
if (aRight + minGap > bLeft) return true;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Thin a tick array by removing every other tick until labels don't overlap.
|
|
145
|
+
* Always keeps first and last tick. O(log n) iterations max.
|
|
146
|
+
* Returns the original array if no thinning is needed.
|
|
147
|
+
*/
|
|
148
|
+
export function thinTicksUntilFit(
|
|
149
|
+
ticks: AxisTick[],
|
|
150
|
+
fontSize: number,
|
|
151
|
+
fontWeight: number,
|
|
152
|
+
measureText?: MeasureTextFn,
|
|
153
|
+
): AxisTick[] {
|
|
154
|
+
if (!ticksOverlap(ticks, fontSize, fontWeight, measureText)) return ticks;
|
|
155
|
+
|
|
156
|
+
let current = ticks;
|
|
157
|
+
while (current.length > MIN_TICK_COUNT) {
|
|
158
|
+
// Keep first, last, and every other tick in between
|
|
159
|
+
const thinned = [current[0]];
|
|
160
|
+
for (let i = 2; i < current.length - 1; i += 2) {
|
|
161
|
+
thinned.push(current[i]);
|
|
162
|
+
}
|
|
163
|
+
if (current.length > 1) thinned.push(current[current.length - 1]);
|
|
164
|
+
current = thinned;
|
|
165
|
+
|
|
166
|
+
if (!ticksOverlap(current, fontSize, fontWeight, measureText)) break;
|
|
167
|
+
}
|
|
168
|
+
return current;
|
|
169
|
+
}
|
|
170
|
+
|
|
97
171
|
// ---------------------------------------------------------------------------
|
|
98
172
|
// Tick generation
|
|
99
173
|
// ---------------------------------------------------------------------------
|
|
100
174
|
|
|
101
175
|
/** Generate ticks for a continuous scale (linear, time, log). */
|
|
102
|
-
function continuousTicks(
|
|
176
|
+
function continuousTicks(
|
|
177
|
+
resolvedScale: ResolvedScale,
|
|
178
|
+
density: AxisLabelDensity,
|
|
179
|
+
fontSize: number,
|
|
180
|
+
fontWeight: number,
|
|
181
|
+
measureText?: MeasureTextFn,
|
|
182
|
+
): AxisTick[] {
|
|
103
183
|
const scale = resolvedScale.scale as D3ContinuousScale;
|
|
104
|
-
const
|
|
105
|
-
const
|
|
184
|
+
const explicitCount = resolvedScale.channel.axis?.tickCount;
|
|
185
|
+
const count = explicitCount ?? TICK_COUNTS[density];
|
|
186
|
+
const rawTicks: unknown[] = scale.ticks(count);
|
|
106
187
|
|
|
107
|
-
|
|
188
|
+
const ticks = rawTicks.map((value: unknown) => ({
|
|
108
189
|
value,
|
|
109
190
|
position: scale(value as number & Date) as number,
|
|
110
191
|
label: formatTickLabel(value, resolvedScale),
|
|
111
192
|
}));
|
|
193
|
+
|
|
194
|
+
// Respect explicit tickCount: user asked for this many, don't override
|
|
195
|
+
if (explicitCount) return ticks;
|
|
196
|
+
|
|
197
|
+
return thinTicksUntilFit(ticks, fontSize, fontWeight, measureText);
|
|
112
198
|
}
|
|
113
199
|
|
|
114
200
|
/** Generate ticks for a band/point/ordinal scale. */
|
|
115
|
-
function categoricalTicks(
|
|
201
|
+
function categoricalTicks(
|
|
202
|
+
resolvedScale: ResolvedScale,
|
|
203
|
+
density: AxisLabelDensity,
|
|
204
|
+
fontSize: number,
|
|
205
|
+
fontWeight: number,
|
|
206
|
+
measureText?: MeasureTextFn,
|
|
207
|
+
): AxisTick[] {
|
|
116
208
|
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
117
209
|
const domain: string[] = scale.domain();
|
|
118
210
|
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
|
|
@@ -126,7 +218,7 @@ function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensit
|
|
|
126
218
|
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
127
219
|
}
|
|
128
220
|
|
|
129
|
-
|
|
221
|
+
const ticks = selectedValues.map((value: string) => {
|
|
130
222
|
// Band scales: use the center of the band
|
|
131
223
|
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
132
224
|
const pos = bandScale
|
|
@@ -139,6 +231,13 @@ function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensit
|
|
|
139
231
|
label: value,
|
|
140
232
|
};
|
|
141
233
|
});
|
|
234
|
+
|
|
235
|
+
// For non-band scales without explicit tickCount, thin based on label width
|
|
236
|
+
if (resolvedScale.type !== 'band' && !explicitTickCount) {
|
|
237
|
+
return thinTicksUntilFit(ticks, fontSize, fontWeight, measureText);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return ticks;
|
|
142
241
|
}
|
|
143
242
|
|
|
144
243
|
/** Format a tick value based on the scale type. */
|
|
@@ -181,12 +280,14 @@ export interface AxesResult {
|
|
|
181
280
|
* @param chartArea - The chart drawing area.
|
|
182
281
|
* @param strategy - Responsive layout strategy.
|
|
183
282
|
* @param theme - Resolved theme for styling.
|
|
283
|
+
* @param measureText - Optional real text measurement from the adapter.
|
|
184
284
|
*/
|
|
185
285
|
export function computeAxes(
|
|
186
286
|
scales: ResolvedScales,
|
|
187
287
|
chartArea: Rect,
|
|
188
288
|
strategy: LayoutStrategy,
|
|
189
289
|
theme: ResolvedTheme,
|
|
290
|
+
measureText?: MeasureTextFn,
|
|
190
291
|
): AxesResult {
|
|
191
292
|
const result: AxesResult = {};
|
|
192
293
|
const baseDensity = strategy.axisLabelDensity;
|
|
@@ -223,24 +324,43 @@ export function computeAxes(
|
|
|
223
324
|
lineHeight: 1.3,
|
|
224
325
|
};
|
|
225
326
|
|
|
327
|
+
const { fontSize } = tickLabelStyle;
|
|
328
|
+
const { fontWeight } = tickLabelStyle;
|
|
329
|
+
|
|
226
330
|
if (scales.x) {
|
|
227
331
|
const ticks =
|
|
228
332
|
scales.x.type === 'band' || scales.x.type === 'point' || scales.x.type === 'ordinal'
|
|
229
|
-
? categoricalTicks(scales.x, xDensity)
|
|
230
|
-
: continuousTicks(scales.x, xDensity);
|
|
333
|
+
? categoricalTicks(scales.x, xDensity, fontSize, fontWeight, measureText)
|
|
334
|
+
: continuousTicks(scales.x, xDensity, fontSize, fontWeight, measureText);
|
|
231
335
|
|
|
232
336
|
const gridlines: Gridline[] = ticks.map((t) => ({
|
|
233
337
|
position: t.position,
|
|
234
338
|
major: true,
|
|
235
339
|
}));
|
|
236
340
|
|
|
341
|
+
// Auto-rotate labels when band scale labels would overlap.
|
|
342
|
+
// Uses max label width (not average) since one long label is enough to overlap.
|
|
343
|
+
let tickAngle = scales.x.channel.axis?.tickAngle;
|
|
344
|
+
if (tickAngle === undefined && scales.x.type === 'band' && ticks.length > 1) {
|
|
345
|
+
const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
|
|
346
|
+
let maxLabelWidth = 0;
|
|
347
|
+
for (const t of ticks) {
|
|
348
|
+
const w = measureLabel(t.label, fontSize, fontWeight, measureText);
|
|
349
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
350
|
+
}
|
|
351
|
+
// If the widest label exceeds 85% of the bandwidth, rotate to avoid overlap
|
|
352
|
+
if (maxLabelWidth > bandwidth * 0.85) {
|
|
353
|
+
tickAngle = -45;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
237
357
|
result.x = {
|
|
238
358
|
ticks,
|
|
239
359
|
gridlines: scales.x.channel.axis?.grid ? gridlines : [],
|
|
240
360
|
label: scales.x.channel.axis?.label,
|
|
241
361
|
labelStyle: axisLabelStyle,
|
|
242
362
|
tickLabelStyle,
|
|
243
|
-
tickAngle
|
|
363
|
+
tickAngle,
|
|
244
364
|
start: { x: chartArea.x, y: chartArea.y + chartArea.height },
|
|
245
365
|
end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
|
|
246
366
|
};
|
|
@@ -249,8 +369,8 @@ export function computeAxes(
|
|
|
249
369
|
if (scales.y) {
|
|
250
370
|
const ticks =
|
|
251
371
|
scales.y.type === 'band' || scales.y.type === 'point' || scales.y.type === 'ordinal'
|
|
252
|
-
? categoricalTicks(scales.y, yDensity)
|
|
253
|
-
: continuousTicks(scales.y, yDensity);
|
|
372
|
+
? categoricalTicks(scales.y, yDensity, fontSize, fontWeight, measureText)
|
|
373
|
+
: continuousTicks(scales.y, yDensity, fontSize, fontWeight, measureText);
|
|
254
374
|
|
|
255
375
|
const gridlines: Gridline[] = ticks.map((t) => ({
|
|
256
376
|
position: t.position,
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -5,11 +5,16 @@
|
|
|
5
5
|
* LayoutDimensions with the total area, chrome layout, chart drawing area,
|
|
6
6
|
* and margins. The chart area is what's left after subtracting chrome,
|
|
7
7
|
* legend space, and axis margins.
|
|
8
|
+
*
|
|
9
|
+
* Padding and chrome scale down at smaller container sizes to maximize
|
|
10
|
+
* the usable chart area. When the chart area is still too small after
|
|
11
|
+
* scaling, chrome is progressively stripped as a fallback.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
import type {
|
|
11
15
|
CompileOptions,
|
|
12
16
|
Encoding,
|
|
17
|
+
LayoutStrategy,
|
|
13
18
|
LegendLayout,
|
|
14
19
|
Margins,
|
|
15
20
|
Rect,
|
|
@@ -53,6 +58,23 @@ function chromeToInput(chrome: NormalizedChrome): import('@opendata-ai/openchart
|
|
|
53
58
|
};
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Scale padding based on the smaller container dimension.
|
|
63
|
+
* At >= 500px, padding is unchanged. At <= 200px, padding is halved (min 4px).
|
|
64
|
+
* Linear interpolation between 200-500px.
|
|
65
|
+
*/
|
|
66
|
+
function scalePadding(basePadding: number, width: number, height: number): number {
|
|
67
|
+
const minDim = Math.min(width, height);
|
|
68
|
+
if (minDim >= 500) return basePadding;
|
|
69
|
+
if (minDim <= 200) return Math.max(Math.round(basePadding * 0.5), 4);
|
|
70
|
+
const t = (minDim - 200) / 300;
|
|
71
|
+
return Math.max(Math.round(basePadding * (0.5 + t * 0.5)), 4);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Minimum chart area dimensions before guardrails kick in. */
|
|
75
|
+
const MIN_CHART_WIDTH = 60;
|
|
76
|
+
const MIN_CHART_HEIGHT = 40;
|
|
77
|
+
|
|
56
78
|
// ---------------------------------------------------------------------------
|
|
57
79
|
// Public API
|
|
58
80
|
// ---------------------------------------------------------------------------
|
|
@@ -64,6 +86,7 @@ function chromeToInput(chrome: NormalizedChrome): import('@opendata-ai/openchart
|
|
|
64
86
|
* @param options - Compile options (width, height, theme, darkMode).
|
|
65
87
|
* @param legendLayout - Pre-computed legend layout (used to reserve space).
|
|
66
88
|
* @param theme - Already-resolved theme (resolved once in compileChart).
|
|
89
|
+
* @param strategy - Responsive layout strategy (controls chrome mode).
|
|
67
90
|
* @returns LayoutDimensions with chart area rect.
|
|
68
91
|
*/
|
|
69
92
|
export function computeDimensions(
|
|
@@ -71,14 +94,23 @@ export function computeDimensions(
|
|
|
71
94
|
options: CompileOptions,
|
|
72
95
|
legendLayout: LegendLayout,
|
|
73
96
|
theme: ResolvedTheme,
|
|
97
|
+
strategy?: LayoutStrategy,
|
|
74
98
|
): LayoutDimensions {
|
|
75
99
|
const { width, height } = options;
|
|
76
100
|
|
|
77
|
-
const padding = theme.spacing.padding;
|
|
101
|
+
const padding = scalePadding(theme.spacing.padding, width, height);
|
|
78
102
|
const axisMargin = theme.spacing.axisMargin;
|
|
103
|
+
const chromeMode = strategy?.chromeMode ?? 'full';
|
|
79
104
|
|
|
80
|
-
// Compute chrome
|
|
81
|
-
const chrome = computeChrome(
|
|
105
|
+
// Compute chrome with mode and scaled padding
|
|
106
|
+
const chrome = computeChrome(
|
|
107
|
+
chromeToInput(spec.chrome),
|
|
108
|
+
theme,
|
|
109
|
+
width,
|
|
110
|
+
options.measureText,
|
|
111
|
+
chromeMode,
|
|
112
|
+
padding,
|
|
113
|
+
);
|
|
82
114
|
|
|
83
115
|
// Start with the total rect
|
|
84
116
|
const total: Rect = { x: 0, y: 0, width, height };
|
|
@@ -216,12 +248,53 @@ export function computeDimensions(
|
|
|
216
248
|
}
|
|
217
249
|
|
|
218
250
|
// Chart area is what's left after margins
|
|
219
|
-
|
|
251
|
+
let chartArea: Rect = {
|
|
220
252
|
x: margins.left,
|
|
221
253
|
y: margins.top,
|
|
222
254
|
width: Math.max(0, width - margins.left - margins.right),
|
|
223
255
|
height: Math.max(0, height - margins.top - margins.bottom),
|
|
224
256
|
};
|
|
225
257
|
|
|
258
|
+
// Guardrail: if chart area is too small, progressively strip chrome
|
|
259
|
+
if (
|
|
260
|
+
(chartArea.width < MIN_CHART_WIDTH || chartArea.height < MIN_CHART_HEIGHT) &&
|
|
261
|
+
chromeMode !== 'hidden'
|
|
262
|
+
) {
|
|
263
|
+
// Try compact first, then hidden
|
|
264
|
+
const fallbackMode = chromeMode === 'full' ? 'compact' : 'hidden';
|
|
265
|
+
const fallbackChrome = computeChrome(
|
|
266
|
+
chromeToInput(spec.chrome),
|
|
267
|
+
theme,
|
|
268
|
+
width,
|
|
269
|
+
options.measureText,
|
|
270
|
+
fallbackMode as 'compact' | 'hidden',
|
|
271
|
+
padding,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Recalculate top/bottom margins with stripped chrome
|
|
275
|
+
const newTop = padding + fallbackChrome.topHeight + axisMargin;
|
|
276
|
+
const topDelta = margins.top - newTop;
|
|
277
|
+
const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
|
|
278
|
+
const bottomDelta = margins.bottom - newBottom;
|
|
279
|
+
|
|
280
|
+
if (topDelta > 0 || bottomDelta > 0) {
|
|
281
|
+
margins.top =
|
|
282
|
+
newTop +
|
|
283
|
+
(legendLayout.entries.length > 0 && legendLayout.position === 'top'
|
|
284
|
+
? legendLayout.bounds.height + 4
|
|
285
|
+
: 0);
|
|
286
|
+
margins.bottom = newBottom;
|
|
287
|
+
|
|
288
|
+
chartArea = {
|
|
289
|
+
x: margins.left,
|
|
290
|
+
y: margins.top,
|
|
291
|
+
width: Math.max(0, width - margins.left - margins.right),
|
|
292
|
+
height: Math.max(0, height - margins.top - margins.bottom),
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
return { total, chrome: fallbackChrome, chartArea, margins, theme };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
226
299
|
return { total, chrome, chartArea, margins, theme };
|
|
227
300
|
}
|
package/src/legend/compute.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
*
|
|
8
8
|
* The legend is computed early (before marks) so the chartArea accounts
|
|
9
9
|
* for legend space. Entries come from data + encoding, not marks.
|
|
10
|
+
*
|
|
11
|
+
* Overflow protection: when there are too many entries for the available
|
|
12
|
+
* space, entries are truncated and a "+N more" indicator is appended.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
import type {
|
|
@@ -17,7 +20,7 @@ import type {
|
|
|
17
20
|
ResolvedTheme,
|
|
18
21
|
TextStyle,
|
|
19
22
|
} from '@opendata-ai/openchart-core';
|
|
20
|
-
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
23
|
+
import { BRAND_RESERVE_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
21
24
|
|
|
22
25
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
23
26
|
|
|
@@ -31,6 +34,12 @@ const ENTRY_GAP = 16;
|
|
|
31
34
|
const LEGEND_PADDING = 8;
|
|
32
35
|
const LEGEND_RIGHT_WIDTH = 120;
|
|
33
36
|
|
|
37
|
+
/** Max fraction of chart area height for right-positioned legends. */
|
|
38
|
+
const RIGHT_LEGEND_MAX_HEIGHT_RATIO = 0.4;
|
|
39
|
+
|
|
40
|
+
/** Max number of rows for top-positioned legends before truncation. */
|
|
41
|
+
const TOP_LEGEND_MAX_ROWS = 2;
|
|
42
|
+
|
|
34
43
|
// ---------------------------------------------------------------------------
|
|
35
44
|
// Helpers
|
|
36
45
|
// ---------------------------------------------------------------------------
|
|
@@ -68,6 +77,57 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
|
|
|
68
77
|
}));
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Calculate how many entries fit within a given number of horizontal rows.
|
|
82
|
+
*/
|
|
83
|
+
function entriesThatFit(
|
|
84
|
+
entries: LegendEntry[],
|
|
85
|
+
maxWidth: number,
|
|
86
|
+
maxRows: number,
|
|
87
|
+
labelStyle: TextStyle,
|
|
88
|
+
): number {
|
|
89
|
+
let row = 1;
|
|
90
|
+
let rowWidth = 0;
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < entries.length; i++) {
|
|
93
|
+
const labelWidth = estimateTextWidth(
|
|
94
|
+
entries[i].label,
|
|
95
|
+
labelStyle.fontSize,
|
|
96
|
+
labelStyle.fontWeight,
|
|
97
|
+
);
|
|
98
|
+
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
|
|
99
|
+
|
|
100
|
+
if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
|
|
101
|
+
row++;
|
|
102
|
+
rowWidth = entryWidth;
|
|
103
|
+
if (row > maxRows) return i;
|
|
104
|
+
} else {
|
|
105
|
+
rowWidth += entryWidth;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return entries.length;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Truncate entries and add a "+N more" indicator if needed.
|
|
114
|
+
*/
|
|
115
|
+
function truncateEntries(entries: LegendEntry[], maxCount: number): LegendEntry[] {
|
|
116
|
+
if (maxCount >= entries.length || maxCount <= 0) return entries;
|
|
117
|
+
|
|
118
|
+
const truncated = entries.slice(0, maxCount);
|
|
119
|
+
const remaining = entries.length - maxCount;
|
|
120
|
+
truncated.push({
|
|
121
|
+
label: `+${remaining} more`,
|
|
122
|
+
color: '#999999',
|
|
123
|
+
shape: 'square',
|
|
124
|
+
active: false,
|
|
125
|
+
overflow: true,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return truncated;
|
|
129
|
+
}
|
|
130
|
+
|
|
71
131
|
// ---------------------------------------------------------------------------
|
|
72
132
|
// Public API
|
|
73
133
|
// ---------------------------------------------------------------------------
|
|
@@ -87,8 +147,8 @@ export function computeLegend(
|
|
|
87
147
|
theme: ResolvedTheme,
|
|
88
148
|
chartArea: Rect,
|
|
89
149
|
): LegendLayout {
|
|
90
|
-
// Legend explicitly hidden via show: false
|
|
91
|
-
if (spec.legend?.show === false) {
|
|
150
|
+
// Legend explicitly hidden via show: false, or height strategy says no legend
|
|
151
|
+
if (spec.legend?.show === false || strategy.legendMaxHeight === 0) {
|
|
92
152
|
return {
|
|
93
153
|
position: 'top',
|
|
94
154
|
entries: [],
|
|
@@ -106,7 +166,7 @@ export function computeLegend(
|
|
|
106
166
|
};
|
|
107
167
|
}
|
|
108
168
|
|
|
109
|
-
|
|
169
|
+
let entries = extractColorEntries(spec, theme);
|
|
110
170
|
|
|
111
171
|
const labelStyle: TextStyle = {
|
|
112
172
|
fontFamily: theme.fonts.family,
|
|
@@ -143,6 +203,21 @@ export function computeLegend(
|
|
|
143
203
|
SWATCH_SIZE + SWATCH_GAP + maxLabelWidth + LEGEND_PADDING * 2,
|
|
144
204
|
);
|
|
145
205
|
const entryHeight = Math.max(SWATCH_SIZE, labelStyle.fontSize * labelStyle.lineHeight);
|
|
206
|
+
|
|
207
|
+
// Apply max height ratio (default 40% of chart area, strategy can override)
|
|
208
|
+
const maxHeightRatio =
|
|
209
|
+
strategy.legendMaxHeight > 0 ? strategy.legendMaxHeight : RIGHT_LEGEND_MAX_HEIGHT_RATIO;
|
|
210
|
+
const maxLegendHeight = chartArea.height * maxHeightRatio;
|
|
211
|
+
|
|
212
|
+
// Calculate how many entries fit
|
|
213
|
+
const maxEntries = Math.max(
|
|
214
|
+
1,
|
|
215
|
+
Math.floor((maxLegendHeight - LEGEND_PADDING * 2) / (entryHeight + 4)),
|
|
216
|
+
);
|
|
217
|
+
if (entries.length > maxEntries) {
|
|
218
|
+
entries = truncateEntries(entries, maxEntries);
|
|
219
|
+
}
|
|
220
|
+
|
|
146
221
|
const legendHeight =
|
|
147
222
|
entries.length * entryHeight + (entries.length - 1) * 4 + LEGEND_PADDING * 2;
|
|
148
223
|
const clampedHeight = Math.min(legendHeight, chartArea.height);
|
|
@@ -173,13 +248,36 @@ export function computeLegend(
|
|
|
173
248
|
};
|
|
174
249
|
}
|
|
175
250
|
|
|
176
|
-
// Top/bottom-positioned legend: horizontal flow
|
|
251
|
+
// Top/bottom-positioned legend: horizontal flow with overflow protection.
|
|
252
|
+
// Reserve space on the right so legend entries don't overlap the brand watermark.
|
|
253
|
+
const availableWidth = chartArea.width - LEGEND_PADDING * 2 - BRAND_RESERVE_WIDTH;
|
|
254
|
+
const maxFit = entriesThatFit(entries, availableWidth, TOP_LEGEND_MAX_ROWS, labelStyle);
|
|
255
|
+
|
|
256
|
+
if (maxFit < entries.length) {
|
|
257
|
+
entries = truncateEntries(entries, maxFit);
|
|
258
|
+
}
|
|
259
|
+
|
|
177
260
|
const totalWidth = entries.reduce((sum, entry) => {
|
|
178
261
|
const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
179
262
|
return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
|
|
180
263
|
}, 0);
|
|
181
264
|
|
|
182
|
-
|
|
265
|
+
// Calculate actual row count for height
|
|
266
|
+
let rowCount = 1;
|
|
267
|
+
let rowWidth = 0;
|
|
268
|
+
for (const entry of entries) {
|
|
269
|
+
const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
270
|
+
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
|
|
271
|
+
if (rowWidth + entryWidth > availableWidth && rowWidth > 0) {
|
|
272
|
+
rowCount++;
|
|
273
|
+
rowWidth = entryWidth;
|
|
274
|
+
} else {
|
|
275
|
+
rowWidth += entryWidth;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const rowHeight = SWATCH_SIZE + 4;
|
|
280
|
+
const legendHeight = rowCount * rowHeight + LEGEND_PADDING * 2;
|
|
183
281
|
|
|
184
282
|
// Apply user-provided legend offset
|
|
185
283
|
const offsetDx = spec.legend?.offset?.dx ?? 0;
|
|
@@ -194,7 +292,7 @@ export function computeLegend(
|
|
|
194
292
|
(resolvedPosition === 'bottom'
|
|
195
293
|
? chartArea.y + chartArea.height - legendHeight
|
|
196
294
|
: chartArea.y) + offsetDy,
|
|
197
|
-
width: Math.min(totalWidth,
|
|
295
|
+
width: Math.min(totalWidth, availableWidth),
|
|
198
296
|
height: legendHeight,
|
|
199
297
|
},
|
|
200
298
|
labelStyle,
|