Skip to content

Commit ec8cf11

Browse files
committed
refactor(engine): extract measureLegendWrap helper for legend and sankey
Consolidate the row-wrap geometry duplicated between legend/compute.ts (used for truncation via fittingCount) and sankey/compile-sankey.ts (used for height reservation via rowCount) into a single helper with a richer return type (rowCount, fittingCount, rowWidths). Behavior preserved exactly: callers keep their own placement logic and maxRows/row-cap rules.
1 parent bb0c870 commit ec8cf11

3 files changed

Lines changed: 101 additions & 65 deletions

File tree

packages/engine/src/legend/compute.ts

Lines changed: 6 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
import { BRAND_RESERVE_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
2424

2525
import type { NormalizedChartSpec } from '../compiler/types';
26+
import { measureLegendWrap } from './wrap';
2627

2728
// ---------------------------------------------------------------------------
2829
// Constants
@@ -92,38 +93,6 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
9293
});
9394
}
9495

95-
/**
96-
* Calculate how many entries fit within a given number of horizontal rows.
97-
*/
98-
function entriesThatFit(
99-
entries: LegendEntry[],
100-
maxWidth: number,
101-
maxRows: number,
102-
labelStyle: TextStyle,
103-
): number {
104-
let row = 1;
105-
let rowWidth = 0;
106-
107-
for (let i = 0; i < entries.length; i++) {
108-
const labelWidth = estimateTextWidth(
109-
entries[i].label,
110-
labelStyle.fontSize,
111-
labelStyle.fontWeight,
112-
);
113-
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
114-
115-
if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
116-
row++;
117-
rowWidth = entryWidth;
118-
if (row > maxRows) return i;
119-
} else {
120-
rowWidth += entryWidth;
121-
}
122-
}
123-
124-
return entries.length;
125-
}
126-
12796
/**
12897
* Truncate entries and add a "+N more" indicator if needed.
12998
*/
@@ -310,30 +279,19 @@ export function computeLegend(
310279
: spec.legend?.columns != null
311280
? Math.ceil(entries.length / spec.legend.columns)
312281
: TOP_LEGEND_MAX_ROWS;
313-
const maxFit = entriesThatFit(entries, availableWidth, maxRows, labelStyle);
282+
const { fittingCount } = measureLegendWrap(entries, availableWidth, labelStyle, maxRows);
314283

315-
if (maxFit < entries.length) {
316-
entries = truncateEntries(entries, maxFit);
284+
if (fittingCount < entries.length) {
285+
entries = truncateEntries(entries, fittingCount);
317286
}
318287

319288
const totalWidth = entries.reduce((sum, entry) => {
320289
const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
321290
return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
322291
}, 0);
323292

324-
// Calculate actual row count for height
325-
let rowCount = 1;
326-
let rowWidth = 0;
327-
for (const entry of entries) {
328-
const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
329-
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
330-
if (rowWidth + entryWidth > availableWidth && rowWidth > 0) {
331-
rowCount++;
332-
rowWidth = entryWidth;
333-
} else {
334-
rowWidth += entryWidth;
335-
}
336-
}
293+
// Calculate actual row count for height (recompute after truncation).
294+
const { rowCount } = measureLegendWrap(entries, availableWidth, labelStyle);
337295

338296
const rowHeight = SWATCH_SIZE + 4;
339297
const legendHeight = rowCount * rowHeight + LEGEND_PADDING * 2;

packages/engine/src/legend/wrap.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Legend row-wrap geometry.
3+
*
4+
* Shared helper for measuring how legend entries flow across horizontal rows
5+
* when wrapped at a max width. Both the main legend compute and the sankey
6+
* legend compile use this to size their legends — the main legend uses
7+
* `fittingCount` for truncation decisions, while sankey uses `rowCount` to
8+
* reserve vertical height.
9+
*
10+
* The geometry matches the existing layout exactly: each entry occupies
11+
* SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP pixels, a new row is
12+
* started when the accumulated row width plus the next entry would exceed
13+
* maxWidth (and the current row is non-empty), and rowWidths captures the
14+
* in-row accumulated width at the point of wrapping.
15+
*/
16+
17+
import type { LegendEntry, TextStyle } from '@opendata-ai/openchart-core';
18+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
19+
20+
// ---------------------------------------------------------------------------
21+
// Constants (must match the legend/sankey compile sites)
22+
// ---------------------------------------------------------------------------
23+
24+
const SWATCH_SIZE = 12;
25+
const SWATCH_GAP = 6;
26+
const ENTRY_GAP = 16;
27+
28+
// ---------------------------------------------------------------------------
29+
// Public API
30+
// ---------------------------------------------------------------------------
31+
32+
export interface LegendWrapResult {
33+
/** Total number of rows the entries occupy when wrapped at maxWidth. */
34+
rowCount: number;
35+
/** Entries that fit within maxRows (for truncation). Equals entries.length when maxRows is not set or all entries fit. */
36+
fittingCount: number;
37+
/** Width (in px) of each row — callers can use for alignment. */
38+
rowWidths: number[];
39+
}
40+
41+
/**
42+
* Measure how legend entries wrap across rows at a given max width.
43+
*
44+
* @param entries - Legend entries to measure.
45+
* @param maxWidth - Maximum width (in px) available for a single row.
46+
* @param labelStyle - Text style used to estimate label widths.
47+
* @param maxRows - Optional cap used only for the `fittingCount` truncation decision. When provided, `fittingCount` will be the index of the first entry that would spill onto a row beyond `maxRows`. `rowCount` is always the real row count regardless of this cap.
48+
*/
49+
export function measureLegendWrap(
50+
entries: LegendEntry[],
51+
maxWidth: number,
52+
labelStyle: TextStyle,
53+
maxRows?: number,
54+
): LegendWrapResult {
55+
if (entries.length === 0) {
56+
return { rowCount: 0, fittingCount: 0, rowWidths: [] };
57+
}
58+
59+
let rowCount = 1;
60+
let rowWidth = 0;
61+
const rowWidths: number[] = [];
62+
let fittingCount = entries.length;
63+
let fittingCountLocked = false;
64+
65+
for (let i = 0; i < entries.length; i++) {
66+
const labelWidth = estimateTextWidth(
67+
entries[i].label,
68+
labelStyle.fontSize,
69+
labelStyle.fontWeight,
70+
);
71+
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
72+
73+
if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
74+
rowWidths.push(rowWidth);
75+
rowCount++;
76+
rowWidth = entryWidth;
77+
if (!fittingCountLocked && maxRows != null && rowCount > maxRows) {
78+
fittingCount = i;
79+
fittingCountLocked = true;
80+
}
81+
} else {
82+
rowWidth += entryWidth;
83+
}
84+
}
85+
86+
// Flush the final row width so rowWidths has one entry per row.
87+
rowWidths.push(rowWidth);
88+
89+
return { rowCount, fittingCount, rowWidths };
90+
}

packages/engine/src/sankey/compile-sankey.ts

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737

3838
import { resolveAnimation } from '../compiler/animation';
3939
import { compile as compileSpec } from '../compiler/index';
40+
import { measureLegendWrap } from '../legend/wrap';
4041
import { type ComputedNode, computeSankeyLayout, generateLinkPath } from './layout';
4142
import type { NormalizedSankeySpec } from './types';
4243

@@ -570,23 +571,10 @@ function buildSankeyLegend(
570571
const ROW_HEIGHT = SWATCH_SIZE + 4;
571572
const availableWidth = area.width;
572573

573-
// Compute row count by simulating horizontal wrapping
574-
let rowCount = 1;
575-
let rowX = 0;
576-
for (const entry of entries) {
577-
const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
578-
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
579-
if (rowX > 0 && rowX + entryWidth > availableWidth) {
580-
rowCount++;
581-
rowX = entryWidth;
582-
} else {
583-
rowX += entryWidth;
584-
}
585-
}
586-
587-
// Cap at 2 rows max
588-
rowCount = Math.min(rowCount, 2);
589-
const legendHeight = rowCount * ROW_HEIGHT;
574+
// Compute row count via shared wrap geometry, then cap at 2 rows.
575+
const { rowCount } = measureLegendWrap(entries, availableWidth, labelStyle);
576+
const cappedRowCount = Math.min(rowCount, 2);
577+
const legendHeight = cappedRowCount * ROW_HEIGHT;
590578

591579
bounds = {
592580
x: area.x,

0 commit comments

Comments
 (0)