@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
package/src/legend/compute.ts
CHANGED
|
@@ -10,11 +10,19 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Overflow protection: when there are too many entries for the available
|
|
12
12
|
* space, entries are truncated and a "+N more" indicator is appended.
|
|
13
|
+
*
|
|
14
|
+
* The computation is split into two phases:
|
|
15
|
+
* - `computeLegendContent`: everything width-dependent (entry derivation,
|
|
16
|
+
* truncation, wrapping, row count, dimensions). No chart-area positioning.
|
|
17
|
+
* - `placeLegend`: pure placement (computes `bounds` from chartArea).
|
|
18
|
+
*
|
|
19
|
+
* `computeLegend` is a thin compat wrapper that calls both.
|
|
13
20
|
*/
|
|
14
21
|
|
|
15
22
|
import type {
|
|
16
23
|
LayoutStrategy,
|
|
17
24
|
LegendEntry,
|
|
25
|
+
LegendEntryPosition,
|
|
18
26
|
LegendLayout,
|
|
19
27
|
Rect,
|
|
20
28
|
ResolvedTheme,
|
|
@@ -23,8 +31,16 @@ import type {
|
|
|
23
31
|
import { BRAND_RESERVE_WIDTH, COMPACT_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
24
32
|
|
|
25
33
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
34
|
+
import type { MeasureFn } from '../layout/plan';
|
|
26
35
|
import { countColorSeries, resolveSuppression } from './suppression';
|
|
27
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
ENTRY_GAP,
|
|
38
|
+
ENTRY_GAP_COMPACT,
|
|
39
|
+
legendGap,
|
|
40
|
+
measureLegendWrap,
|
|
41
|
+
SWATCH_GAP,
|
|
42
|
+
SWATCH_SIZE,
|
|
43
|
+
} from './wrap';
|
|
28
44
|
|
|
29
45
|
// ---------------------------------------------------------------------------
|
|
30
46
|
// Constants
|
|
@@ -39,6 +55,32 @@ const RIGHT_LEGEND_MAX_HEIGHT_RATIO = 0.4;
|
|
|
39
55
|
/** Max number of rows for top-positioned legends before truncation. */
|
|
40
56
|
const TOP_LEGEND_MAX_ROWS = 2;
|
|
41
57
|
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// LegendContent -- pre-computed content before placement
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/** Pre-computed legend content produced before final layout. */
|
|
63
|
+
export interface LegendContent {
|
|
64
|
+
entries: LegendEntry[];
|
|
65
|
+
position: 'top' | 'bottom' | 'right' | 'bottom-right' | 'inline';
|
|
66
|
+
labelStyle: TextStyle;
|
|
67
|
+
rowCount: number;
|
|
68
|
+
totalWidth: number;
|
|
69
|
+
height: number;
|
|
70
|
+
entryGap: number;
|
|
71
|
+
swatchSize: number;
|
|
72
|
+
swatchGap: number;
|
|
73
|
+
swatchChipFill: string;
|
|
74
|
+
/** Width of the legend box (for right legends this is the column width). */
|
|
75
|
+
legendWidth: number;
|
|
76
|
+
/** User-provided legend offset, threaded through for placement. */
|
|
77
|
+
offset?: { dx?: number; dy?: number };
|
|
78
|
+
/** Relative entry positions (origin 0,0). Offset by bounds in placeLegend. */
|
|
79
|
+
relativePositions?: LegendEntryPosition[];
|
|
80
|
+
/** Row advance used to build entry positions. */
|
|
81
|
+
rowHeight?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
42
84
|
// ---------------------------------------------------------------------------
|
|
43
85
|
// Helpers
|
|
44
86
|
// ---------------------------------------------------------------------------
|
|
@@ -62,6 +104,21 @@ function categoricalDefaults(theme: ResolvedTheme): {
|
|
|
62
104
|
};
|
|
63
105
|
}
|
|
64
106
|
|
|
107
|
+
function resolveEntryPositions(
|
|
108
|
+
relative: LegendEntryPosition[] | undefined,
|
|
109
|
+
bounds: Rect,
|
|
110
|
+
): LegendEntryPosition[] | undefined {
|
|
111
|
+
if (!relative) return undefined;
|
|
112
|
+
return relative.map((p) => ({
|
|
113
|
+
x: p.x + bounds.x,
|
|
114
|
+
y: p.y + bounds.y,
|
|
115
|
+
labelX: p.labelX + bounds.x,
|
|
116
|
+
labelY: p.labelY + bounds.y,
|
|
117
|
+
width: p.width,
|
|
118
|
+
row: p.row,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
65
122
|
/** Determine the swatch shape based on mark type. */
|
|
66
123
|
function swatchShapeForType(markType: string): LegendEntry['shape'] {
|
|
67
124
|
switch (markType) {
|
|
@@ -147,26 +204,57 @@ function truncateEntries(entries: LegendEntry[], maxCount: number): LegendEntry[
|
|
|
147
204
|
return truncated;
|
|
148
205
|
}
|
|
149
206
|
|
|
207
|
+
/** Build an empty LegendContent for suppressed/hidden legends. */
|
|
208
|
+
function emptyContent(position: LegendContent['position'], theme: ResolvedTheme): LegendContent {
|
|
209
|
+
return {
|
|
210
|
+
entries: [],
|
|
211
|
+
position,
|
|
212
|
+
labelStyle: {
|
|
213
|
+
fontFamily: theme.fonts.family,
|
|
214
|
+
fontSize: theme.fonts.sizes.small,
|
|
215
|
+
fontWeight: theme.fonts.weights.normal,
|
|
216
|
+
fill: theme.colors.text,
|
|
217
|
+
lineHeight: 1.3,
|
|
218
|
+
},
|
|
219
|
+
rowCount: 0,
|
|
220
|
+
totalWidth: 0,
|
|
221
|
+
height: 0,
|
|
222
|
+
legendWidth: 0,
|
|
223
|
+
...categoricalDefaults(theme),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
150
227
|
// ---------------------------------------------------------------------------
|
|
151
228
|
// Public API
|
|
152
229
|
// ---------------------------------------------------------------------------
|
|
153
230
|
|
|
154
231
|
/**
|
|
155
|
-
* Compute legend
|
|
232
|
+
* Compute legend content (entries, dimensions, wrapping) without placement.
|
|
233
|
+
*
|
|
234
|
+
* This is the "measure" half of legend computation. It determines which
|
|
235
|
+
* entries to show, how they wrap/truncate, and the resulting dimensions,
|
|
236
|
+
* but does not compute a bounding box in chart coordinates.
|
|
156
237
|
*
|
|
157
238
|
* @param spec - Normalized chart spec.
|
|
158
239
|
* @param strategy - Responsive layout strategy.
|
|
159
240
|
* @param theme - Resolved theme.
|
|
160
|
-
* @param
|
|
161
|
-
* @
|
|
241
|
+
* @param availableWidth - Width constraint for layout (e.g. chartArea.width).
|
|
242
|
+
* @param availableHeight - Height constraint for right-legend truncation.
|
|
243
|
+
* @param watermark - Whether the brand watermark is shown (reserves space for bottom legends).
|
|
244
|
+
* @param measure - Optional text measurement function; defaults to estimateTextWidth.
|
|
245
|
+
* @returns LegendContent with entries, dimensions, but no bounds.
|
|
162
246
|
*/
|
|
163
|
-
export function
|
|
247
|
+
export function computeLegendContent(
|
|
164
248
|
spec: NormalizedChartSpec,
|
|
165
249
|
strategy: LayoutStrategy,
|
|
166
250
|
theme: ResolvedTheme,
|
|
167
|
-
|
|
251
|
+
availableWidth: number,
|
|
252
|
+
availableHeight: number,
|
|
168
253
|
watermark: boolean = true,
|
|
169
|
-
|
|
254
|
+
measure?: MeasureFn,
|
|
255
|
+
): LegendContent {
|
|
256
|
+
const measureWidth = measure ?? estimateTextWidth;
|
|
257
|
+
|
|
170
258
|
// Sparkline mode: legend hidden by default unless the user opted in. Color
|
|
171
259
|
// scales still resolve normally (legend hidden != no colors), so multi-series
|
|
172
260
|
// sparklines retain their categorical palette.
|
|
@@ -174,20 +262,7 @@ export function computeLegend(
|
|
|
174
262
|
|
|
175
263
|
// Legend explicitly hidden via show: false, or height strategy says no legend
|
|
176
264
|
if (sparklineHidden || spec.legend?.show === false || strategy.legendMaxHeight === 0) {
|
|
177
|
-
return
|
|
178
|
-
type: 'categorical' as const,
|
|
179
|
-
position: 'top',
|
|
180
|
-
entries: [],
|
|
181
|
-
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
182
|
-
labelStyle: {
|
|
183
|
-
fontFamily: theme.fonts.family,
|
|
184
|
-
fontSize: theme.fonts.sizes.small,
|
|
185
|
-
fontWeight: theme.fonts.weights.normal,
|
|
186
|
-
fill: theme.colors.text,
|
|
187
|
-
lineHeight: 1.3,
|
|
188
|
-
},
|
|
189
|
-
...categoricalDefaults(theme),
|
|
190
|
-
};
|
|
265
|
+
return emptyContent('top', theme);
|
|
191
266
|
}
|
|
192
267
|
|
|
193
268
|
let entries = extractColorEntries(spec, theme);
|
|
@@ -195,7 +270,7 @@ export function computeLegend(
|
|
|
195
270
|
// Consult the shared suppression truth table so the legend, endpoint column,
|
|
196
271
|
// and end-of-line labels stay in sync. The helper returns
|
|
197
272
|
// `showTraditionalLegend: false` when the endpoint column auto-takes over
|
|
198
|
-
// for
|
|
273
|
+
// for >=2-series line/area charts (and the user hasn't forced the legend on).
|
|
199
274
|
const seriesCount = countColorSeries(spec);
|
|
200
275
|
const suppression = resolveSuppression(spec, {
|
|
201
276
|
seriesCount,
|
|
@@ -220,20 +295,15 @@ export function computeLegend(
|
|
|
220
295
|
|
|
221
296
|
// No entries = empty legend with no space
|
|
222
297
|
if (entries.length === 0) {
|
|
223
|
-
return
|
|
224
|
-
type: 'categorical' as const,
|
|
225
|
-
position: resolvedPosition,
|
|
226
|
-
entries: [],
|
|
227
|
-
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
228
|
-
labelStyle,
|
|
229
|
-
...categoricalDefaults(theme),
|
|
230
|
-
};
|
|
298
|
+
return emptyContent(resolvedPosition, theme);
|
|
231
299
|
}
|
|
232
300
|
|
|
301
|
+
const offset = spec.legend?.offset;
|
|
302
|
+
|
|
233
303
|
if (resolvedPosition === 'right' || resolvedPosition === 'bottom-right') {
|
|
234
304
|
// Right-positioned legend: vertical stack
|
|
235
305
|
const maxLabelWidth = Math.max(
|
|
236
|
-
...entries.map((e) =>
|
|
306
|
+
...entries.map((e) => measureWidth(e.label, labelStyle.fontSize, labelStyle.fontWeight)),
|
|
237
307
|
);
|
|
238
308
|
const legendWidth = Math.min(
|
|
239
309
|
LEGEND_RIGHT_WIDTH,
|
|
@@ -244,7 +314,7 @@ export function computeLegend(
|
|
|
244
314
|
// Apply max height ratio (default 40% of chart area, strategy can override)
|
|
245
315
|
const maxHeightRatio =
|
|
246
316
|
strategy.legendMaxHeight > 0 ? strategy.legendMaxHeight : RIGHT_LEGEND_MAX_HEIGHT_RATIO;
|
|
247
|
-
const maxLegendHeight =
|
|
317
|
+
const maxLegendHeight = availableHeight * maxHeightRatio;
|
|
248
318
|
|
|
249
319
|
// Calculate how many entries fit
|
|
250
320
|
const maxFromSpace = Math.max(
|
|
@@ -260,34 +330,37 @@ export function computeLegend(
|
|
|
260
330
|
|
|
261
331
|
const legendHeight =
|
|
262
332
|
entries.length * entryHeight + (entries.length - 1) * 4 + LEGEND_PADDING * 2;
|
|
263
|
-
const clampedHeight = Math.min(legendHeight,
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
333
|
+
const clampedHeight = Math.min(legendHeight, availableHeight);
|
|
334
|
+
|
|
335
|
+
const rightEntryGap = 4;
|
|
336
|
+
const relativePositions: LegendEntryPosition[] = [];
|
|
337
|
+
for (let i = 0; i < entries.length; i++) {
|
|
338
|
+
const ex = 0;
|
|
339
|
+
const ey = i * (entryHeight + rightEntryGap);
|
|
340
|
+
const labelWidth = measureWidth(entries[i].label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
341
|
+
relativePositions.push({
|
|
342
|
+
x: ex,
|
|
343
|
+
y: ey,
|
|
344
|
+
labelX: ex + SWATCH_SIZE + SWATCH_GAP,
|
|
345
|
+
labelY: ey + SWATCH_SIZE / 2,
|
|
346
|
+
width: SWATCH_SIZE + SWATCH_GAP + labelWidth + rightEntryGap,
|
|
347
|
+
row: i,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
274
350
|
|
|
275
351
|
return {
|
|
276
|
-
type: 'categorical' as const,
|
|
277
|
-
position: resolvedPosition,
|
|
278
352
|
entries,
|
|
279
|
-
|
|
280
|
-
x: chartArea.x + chartArea.width - legendWidth + offsetDx,
|
|
281
|
-
y: legendY + offsetDy,
|
|
282
|
-
width: legendWidth,
|
|
283
|
-
height: clampedHeight,
|
|
284
|
-
},
|
|
353
|
+
position: resolvedPosition,
|
|
285
354
|
labelStyle,
|
|
355
|
+
rowCount: entries.length,
|
|
356
|
+
totalWidth: legendWidth,
|
|
357
|
+
height: clampedHeight,
|
|
358
|
+
legendWidth,
|
|
359
|
+
offset,
|
|
286
360
|
...categoricalDefaults(theme),
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
entryGap: 4,
|
|
361
|
+
entryGap: rightEntryGap,
|
|
362
|
+
relativePositions,
|
|
363
|
+
rowHeight: entryHeight + rightEntryGap,
|
|
291
364
|
};
|
|
292
365
|
}
|
|
293
366
|
|
|
@@ -296,11 +369,11 @@ export function computeLegend(
|
|
|
296
369
|
// watermark. Top legends don't need this since the brand renders at the bottom.
|
|
297
370
|
const reserveBrand = watermark && resolvedPosition === 'bottom';
|
|
298
371
|
// Tighten gaps on narrow viewports so horizontal legends keep fitting on one row.
|
|
299
|
-
const isCompact =
|
|
372
|
+
const isCompact = availableWidth < COMPACT_WIDTH;
|
|
300
373
|
const effectivePadding = isCompact ? 2 : LEGEND_PADDING;
|
|
301
374
|
const effectiveEntryGap = isCompact ? ENTRY_GAP_COMPACT : ENTRY_GAP;
|
|
302
|
-
const
|
|
303
|
-
|
|
375
|
+
const contentWidth =
|
|
376
|
+
availableWidth - effectivePadding * 2 - (reserveBrand ? BRAND_RESERVE_WIDTH : 0);
|
|
304
377
|
|
|
305
378
|
// Apply symbolLimit first if set (minimum 1), then fit remaining entries to available rows.
|
|
306
379
|
if (spec.legend?.symbolLimit != null) {
|
|
@@ -319,10 +392,11 @@ export function computeLegend(
|
|
|
319
392
|
: TOP_LEGEND_MAX_ROWS;
|
|
320
393
|
const { fittingCount } = measureLegendWrap(
|
|
321
394
|
entries,
|
|
322
|
-
|
|
395
|
+
contentWidth,
|
|
323
396
|
labelStyle,
|
|
324
397
|
maxRows,
|
|
325
398
|
effectiveEntryGap,
|
|
399
|
+
measureWidth,
|
|
326
400
|
);
|
|
327
401
|
|
|
328
402
|
if (fittingCount < entries.length) {
|
|
@@ -330,43 +404,206 @@ export function computeLegend(
|
|
|
330
404
|
}
|
|
331
405
|
|
|
332
406
|
const totalWidth = entries.reduce((sum, entry) => {
|
|
333
|
-
const labelWidth =
|
|
407
|
+
const labelWidth = measureWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
334
408
|
return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + effectiveEntryGap;
|
|
335
409
|
}, 0);
|
|
336
410
|
|
|
337
411
|
// Calculate actual row count for height (recompute after truncation).
|
|
338
|
-
const
|
|
412
|
+
const finalWrap = measureLegendWrap(
|
|
339
413
|
entries,
|
|
340
|
-
|
|
414
|
+
contentWidth,
|
|
341
415
|
labelStyle,
|
|
342
416
|
undefined,
|
|
343
417
|
effectiveEntryGap,
|
|
418
|
+
measureWidth,
|
|
344
419
|
);
|
|
420
|
+
const { rowCount } = finalWrap;
|
|
345
421
|
|
|
346
422
|
const rowHeight = SWATCH_SIZE + 4;
|
|
347
423
|
const legendHeight = rowCount * rowHeight + effectivePadding * 2;
|
|
348
424
|
|
|
349
|
-
//
|
|
350
|
-
const
|
|
351
|
-
|
|
425
|
+
// Build relative positions (origin 0,0), offset by bounds in placeLegend
|
|
426
|
+
const relativePositions: LegendEntryPosition[] = [];
|
|
427
|
+
for (let i = 0; i < entries.length; i++) {
|
|
428
|
+
const placement = finalWrap.placements[i];
|
|
429
|
+
if (!placement) break;
|
|
430
|
+
const ex = placement.xOffset;
|
|
431
|
+
const ey = placement.row * rowHeight;
|
|
432
|
+
const labelWidth = measureWidth(entries[i].label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
433
|
+
relativePositions.push({
|
|
434
|
+
x: ex,
|
|
435
|
+
y: ey,
|
|
436
|
+
labelX: ex + SWATCH_SIZE + SWATCH_GAP,
|
|
437
|
+
labelY: ey + SWATCH_SIZE / 2,
|
|
438
|
+
width: SWATCH_SIZE + SWATCH_GAP + labelWidth + effectiveEntryGap,
|
|
439
|
+
row: placement.row,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
352
442
|
|
|
353
443
|
return {
|
|
354
|
-
type: 'categorical' as const,
|
|
355
|
-
position: resolvedPosition,
|
|
356
444
|
entries,
|
|
357
|
-
|
|
358
|
-
x: chartArea.x + offsetDx,
|
|
359
|
-
y:
|
|
360
|
-
(resolvedPosition === 'bottom'
|
|
361
|
-
? chartArea.y + chartArea.height - legendHeight
|
|
362
|
-
: chartArea.y) + offsetDy,
|
|
363
|
-
width: Math.min(totalWidth, availableWidth),
|
|
364
|
-
height: legendHeight,
|
|
365
|
-
},
|
|
445
|
+
position: resolvedPosition,
|
|
366
446
|
labelStyle,
|
|
447
|
+
rowCount,
|
|
448
|
+
totalWidth,
|
|
449
|
+
height: legendHeight,
|
|
450
|
+
legendWidth: Math.min(totalWidth, contentWidth),
|
|
451
|
+
offset,
|
|
367
452
|
...categoricalDefaults(theme),
|
|
368
|
-
// Top/bottom legends honor the compact-viewport-aware entry gap so
|
|
369
|
-
// chips stay readable on narrow widths.
|
|
370
453
|
entryGap: effectiveEntryGap,
|
|
454
|
+
relativePositions,
|
|
455
|
+
rowHeight,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Place a pre-computed legend into chart coordinates.
|
|
461
|
+
*
|
|
462
|
+
* This is the "layout" half of legend computation. It takes a `LegendContent`
|
|
463
|
+
* (from `computeLegendContent`) and produces a full `LegendLayout` with a
|
|
464
|
+
* positioned bounding box.
|
|
465
|
+
*
|
|
466
|
+
* @param content - Pre-computed legend content.
|
|
467
|
+
* @param chartArea - The final chart area (legend space already reserved in margins).
|
|
468
|
+
* @param containerWidth - Full container width, used for responsive legend gap.
|
|
469
|
+
* @param _theme - Resolved theme (reserved for future use).
|
|
470
|
+
* @param xAxisHeight - Height of the x-axis, used for bottom legend positioning.
|
|
471
|
+
* @returns Full LegendLayout with bounds.
|
|
472
|
+
*/
|
|
473
|
+
export function placeLegend(
|
|
474
|
+
content: LegendContent,
|
|
475
|
+
chartArea: Rect,
|
|
476
|
+
containerWidth: number,
|
|
477
|
+
_theme: ResolvedTheme,
|
|
478
|
+
xAxisHeight: number,
|
|
479
|
+
): LegendLayout {
|
|
480
|
+
const { position, entries, labelStyle, legendWidth, height, offset } = content;
|
|
481
|
+
|
|
482
|
+
// Empty legend = no bounds
|
|
483
|
+
if (entries.length === 0) {
|
|
484
|
+
return {
|
|
485
|
+
type: 'categorical' as const,
|
|
486
|
+
position,
|
|
487
|
+
entries: [],
|
|
488
|
+
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
489
|
+
labelStyle,
|
|
490
|
+
swatchSize: content.swatchSize,
|
|
491
|
+
swatchGap: content.swatchGap,
|
|
492
|
+
entryGap: content.entryGap,
|
|
493
|
+
swatchChipFill: content.swatchChipFill,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const offsetDx = offset?.dx ?? 0;
|
|
498
|
+
const offsetDy = offset?.dy ?? 0;
|
|
499
|
+
const gap = legendGap(containerWidth);
|
|
500
|
+
|
|
501
|
+
let boundsX: number;
|
|
502
|
+
let boundsY: number;
|
|
503
|
+
|
|
504
|
+
if (position === 'right' || position === 'bottom-right') {
|
|
505
|
+
boundsX = chartArea.x + chartArea.width + 8 + offsetDx;
|
|
506
|
+
boundsY =
|
|
507
|
+
(position === 'bottom-right' ? chartArea.y + chartArea.height - height : chartArea.y) +
|
|
508
|
+
offsetDy;
|
|
509
|
+
} else if (position === 'bottom') {
|
|
510
|
+
boundsX = chartArea.x + offsetDx;
|
|
511
|
+
boundsY = chartArea.y + chartArea.height + xAxisHeight + gap + offsetDy;
|
|
512
|
+
} else {
|
|
513
|
+
// top
|
|
514
|
+
boundsX = chartArea.x + offsetDx;
|
|
515
|
+
boundsY = chartArea.y - gap - height + offsetDy;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const bounds = { x: boundsX, y: boundsY, width: legendWidth, height };
|
|
519
|
+
|
|
520
|
+
const entryPositions = resolveEntryPositions(content.relativePositions, bounds);
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
type: 'categorical' as const,
|
|
524
|
+
position,
|
|
525
|
+
entries,
|
|
526
|
+
bounds,
|
|
527
|
+
labelStyle,
|
|
528
|
+
swatchSize: content.swatchSize,
|
|
529
|
+
swatchGap: content.swatchGap,
|
|
530
|
+
entryGap: content.entryGap,
|
|
531
|
+
swatchChipFill: content.swatchChipFill,
|
|
532
|
+
entryPositions,
|
|
533
|
+
rowHeight: content.rowHeight,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Compute legend layout for a chart spec.
|
|
539
|
+
*
|
|
540
|
+
* Thin wrapper that calls `computeLegendContent` then `placeLegend`.
|
|
541
|
+
* Existing callers are untouched.
|
|
542
|
+
*
|
|
543
|
+
* @param spec - Normalized chart spec.
|
|
544
|
+
* @param strategy - Responsive layout strategy.
|
|
545
|
+
* @param theme - Resolved theme.
|
|
546
|
+
* @param chartArea - The available chart area (before legend space is reserved).
|
|
547
|
+
* @returns LegendLayout with position, entries, and bounds.
|
|
548
|
+
*/
|
|
549
|
+
export function computeLegend(
|
|
550
|
+
spec: NormalizedChartSpec,
|
|
551
|
+
strategy: LayoutStrategy,
|
|
552
|
+
theme: ResolvedTheme,
|
|
553
|
+
chartArea: Rect,
|
|
554
|
+
watermark: boolean = true,
|
|
555
|
+
): LegendLayout {
|
|
556
|
+
const content = computeLegendContent(
|
|
557
|
+
spec,
|
|
558
|
+
strategy,
|
|
559
|
+
theme,
|
|
560
|
+
chartArea.width,
|
|
561
|
+
chartArea.height,
|
|
562
|
+
watermark,
|
|
563
|
+
);
|
|
564
|
+
const { position, entries, labelStyle, legendWidth, height, offset: off } = content;
|
|
565
|
+
const offsetDx = off?.dx ?? 0;
|
|
566
|
+
const offsetDy = off?.dy ?? 0;
|
|
567
|
+
let boundsX = chartArea.x + offsetDx;
|
|
568
|
+
let boundsY = chartArea.y + offsetDy;
|
|
569
|
+
const boundsW = legendWidth;
|
|
570
|
+
if (entries.length === 0) {
|
|
571
|
+
return {
|
|
572
|
+
type: 'categorical' as const,
|
|
573
|
+
position,
|
|
574
|
+
entries: [],
|
|
575
|
+
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
576
|
+
labelStyle,
|
|
577
|
+
swatchSize: content.swatchSize,
|
|
578
|
+
swatchGap: content.swatchGap,
|
|
579
|
+
entryGap: content.entryGap,
|
|
580
|
+
swatchChipFill: content.swatchChipFill,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
if (position === 'right' || position === 'bottom-right') {
|
|
584
|
+
boundsX = chartArea.x + chartArea.width - legendWidth + offsetDx;
|
|
585
|
+
boundsY =
|
|
586
|
+
(position === 'bottom-right' ? chartArea.y + chartArea.height - height : chartArea.y) +
|
|
587
|
+
offsetDy;
|
|
588
|
+
} else if (position === 'bottom') {
|
|
589
|
+
boundsY = chartArea.y + chartArea.height - height + offsetDy;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const bounds = { x: boundsX, y: boundsY, width: boundsW, height };
|
|
593
|
+
|
|
594
|
+
const entryPositions = resolveEntryPositions(content.relativePositions, bounds);
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
type: 'categorical' as const,
|
|
598
|
+
position,
|
|
599
|
+
entries,
|
|
600
|
+
bounds,
|
|
601
|
+
labelStyle,
|
|
602
|
+
swatchSize: content.swatchSize,
|
|
603
|
+
swatchGap: content.swatchGap,
|
|
604
|
+
entryGap: content.entryGap,
|
|
605
|
+
swatchChipFill: content.swatchChipFill,
|
|
606
|
+
entryPositions,
|
|
607
|
+
rowHeight: content.rowHeight,
|
|
371
608
|
};
|
|
372
609
|
}
|
package/src/legend/wrap.ts
CHANGED
|
@@ -50,6 +50,8 @@ export interface LegendWrapResult {
|
|
|
50
50
|
fittingCount: number;
|
|
51
51
|
/** Width (in px) of each row — callers can use for alignment. */
|
|
52
52
|
rowWidths: number[];
|
|
53
|
+
/** Per-entry placement: wrap row and x offset within that row. */
|
|
54
|
+
placements: Array<{ row: number; xOffset: number }>;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/**
|
|
@@ -66,34 +68,36 @@ export function measureLegendWrap(
|
|
|
66
68
|
labelStyle: TextStyle,
|
|
67
69
|
maxRows?: number,
|
|
68
70
|
entryGap: number = ENTRY_GAP,
|
|
71
|
+
measure?: (text: string, fontSize: number, fontWeight: number) => number,
|
|
69
72
|
): LegendWrapResult {
|
|
70
73
|
if (entries.length === 0) {
|
|
71
|
-
return { rowCount: 0, fittingCount: 0, rowWidths: [] };
|
|
74
|
+
return { rowCount: 0, fittingCount: 0, rowWidths: [], placements: [] };
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
const measureWidth = measure ?? estimateTextWidth;
|
|
78
|
+
|
|
74
79
|
let rowCount = 1;
|
|
75
80
|
let rowWidth = 0;
|
|
76
81
|
const rowWidths: number[] = [];
|
|
82
|
+
const placements: Array<{ row: number; xOffset: number }> = [];
|
|
77
83
|
let fittingCount = entries.length;
|
|
78
84
|
let fittingCountLocked = false;
|
|
79
85
|
|
|
80
86
|
for (let i = 0; i < entries.length; i++) {
|
|
81
|
-
const labelWidth =
|
|
82
|
-
entries[i].label,
|
|
83
|
-
labelStyle.fontSize,
|
|
84
|
-
labelStyle.fontWeight,
|
|
85
|
-
);
|
|
87
|
+
const labelWidth = measureWidth(entries[i].label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
86
88
|
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + entryGap;
|
|
87
89
|
|
|
88
90
|
if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
|
|
89
91
|
rowWidths.push(rowWidth);
|
|
90
92
|
rowCount++;
|
|
93
|
+
placements.push({ row: rowCount - 1, xOffset: 0 });
|
|
91
94
|
rowWidth = entryWidth;
|
|
92
95
|
if (!fittingCountLocked && maxRows != null && rowCount > maxRows) {
|
|
93
96
|
fittingCount = i;
|
|
94
97
|
fittingCountLocked = true;
|
|
95
98
|
}
|
|
96
99
|
} else {
|
|
100
|
+
placements.push({ row: rowCount - 1, xOffset: rowWidth });
|
|
97
101
|
rowWidth += entryWidth;
|
|
98
102
|
}
|
|
99
103
|
}
|
|
@@ -101,5 +105,5 @@ export function measureLegendWrap(
|
|
|
101
105
|
// Flush the final row width so rowWidths has one entry per row.
|
|
102
106
|
rowWidths.push(rowWidth);
|
|
103
107
|
|
|
104
|
-
return { rowCount, fittingCount, rowWidths };
|
|
108
|
+
return { rowCount, fittingCount, rowWidths, placements };
|
|
105
109
|
}
|
|
@@ -37,9 +37,9 @@ describe('sankey node-label wrapping', () => {
|
|
|
37
37
|
},
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
//
|
|
40
|
+
// 300px wide total is cramped for the labels above across 3 columns,
|
|
41
41
|
// forcing per-label maxWidth to be well below the longest text width.
|
|
42
|
-
const layout = compileSankey(spec, { width:
|
|
42
|
+
const layout = compileSankey(spec, { width: 300, height: 300 });
|
|
43
43
|
|
|
44
44
|
// Every node should have a finite maxWidth set (a proxy for "engine
|
|
45
45
|
// participated in wrap geometry") when overflow-compression kicked in.
|