@opendata-ai/openchart-engine 2.9.0 → 2.10.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 +145 -21
- 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 +4 -0
- package/src/__tests__/legend.test.ts +4 -0
- package/src/compile.ts +5 -3
- package/src/layout/axes.ts +22 -1
- package/src/layout/dimensions.ts +77 -4
- package/src/legend/compute.ts +102 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Riley Hilliard",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"typecheck": "tsc --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@opendata-ai/openchart-core": "2.
|
|
48
|
+
"@opendata-ai/openchart-core": "2.10.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -24,6 +24,8 @@ export function makeFullStrategy(): LayoutStrategy {
|
|
|
24
24
|
legendPosition: 'right',
|
|
25
25
|
annotationPosition: 'inline',
|
|
26
26
|
axisLabelDensity: 'full',
|
|
27
|
+
chromeMode: 'full',
|
|
28
|
+
legendMaxHeight: -1,
|
|
27
29
|
};
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -34,6 +36,8 @@ export function makeCompactStrategy(): LayoutStrategy {
|
|
|
34
36
|
legendPosition: 'top',
|
|
35
37
|
annotationPosition: 'tooltip-only',
|
|
36
38
|
axisLabelDensity: 'minimal',
|
|
39
|
+
chromeMode: 'full',
|
|
40
|
+
legendMaxHeight: -1,
|
|
37
41
|
};
|
|
38
42
|
}
|
|
39
43
|
|
|
@@ -32,6 +32,8 @@ const fullStrategy: LayoutStrategy = {
|
|
|
32
32
|
legendPosition: 'right',
|
|
33
33
|
annotationPosition: 'inline',
|
|
34
34
|
axisLabelDensity: 'full',
|
|
35
|
+
chromeMode: 'full',
|
|
36
|
+
legendMaxHeight: -1,
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
const minimalStrategy: LayoutStrategy = {
|
|
@@ -39,6 +41,8 @@ const minimalStrategy: LayoutStrategy = {
|
|
|
39
41
|
legendPosition: 'top',
|
|
40
42
|
annotationPosition: 'tooltip-only',
|
|
41
43
|
axisLabelDensity: 'minimal',
|
|
44
|
+
chromeMode: 'full',
|
|
45
|
+
legendMaxHeight: -1,
|
|
42
46
|
};
|
|
43
47
|
|
|
44
48
|
describe('computeAxes', () => {
|
|
@@ -40,6 +40,8 @@ const fullStrategy: LayoutStrategy = {
|
|
|
40
40
|
legendPosition: 'right',
|
|
41
41
|
annotationPosition: 'inline',
|
|
42
42
|
axisLabelDensity: 'full',
|
|
43
|
+
chromeMode: 'full',
|
|
44
|
+
legendMaxHeight: -1,
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
const compactStrategy: LayoutStrategy = {
|
|
@@ -47,6 +49,8 @@ const compactStrategy: LayoutStrategy = {
|
|
|
47
49
|
legendPosition: 'top',
|
|
48
50
|
annotationPosition: 'tooltip-only',
|
|
49
51
|
axisLabelDensity: 'minimal',
|
|
52
|
+
chromeMode: 'full',
|
|
53
|
+
legendMaxHeight: -1,
|
|
50
54
|
};
|
|
51
55
|
|
|
52
56
|
describe('computeLegend', () => {
|
package/src/compile.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
generateAltText,
|
|
29
29
|
generateDataTable,
|
|
30
30
|
getBreakpoint,
|
|
31
|
+
getHeightClass,
|
|
31
32
|
getLayoutStrategy,
|
|
32
33
|
resolveTheme,
|
|
33
34
|
} from '@opendata-ai/openchart-core';
|
|
@@ -163,7 +164,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
163
164
|
|
|
164
165
|
// Responsive strategy
|
|
165
166
|
const breakpoint = getBreakpoint(options.width);
|
|
166
|
-
const
|
|
167
|
+
const heightClass = getHeightClass(options.height);
|
|
168
|
+
const strategy = getLayoutStrategy(breakpoint, heightClass);
|
|
167
169
|
|
|
168
170
|
// Apply breakpoint-conditional overrides from the original spec
|
|
169
171
|
const rawSpec = spec as Record<string, unknown>;
|
|
@@ -230,8 +232,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
230
232
|
};
|
|
231
233
|
const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea);
|
|
232
234
|
|
|
233
|
-
// Compute dimensions (accounts for chrome + legend)
|
|
234
|
-
const dims = computeDimensions(chartSpec, options, legendLayout, theme);
|
|
235
|
+
// Compute dimensions (accounts for chrome + legend + responsive strategy)
|
|
236
|
+
const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy);
|
|
235
237
|
const chartArea = dims.chartArea;
|
|
236
238
|
|
|
237
239
|
// Recompute legend bounds relative to actual chart area.
|
package/src/layout/axes.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
import {
|
|
19
19
|
abbreviateNumber,
|
|
20
20
|
buildD3Formatter,
|
|
21
|
+
estimateTextWidth,
|
|
21
22
|
formatDate,
|
|
22
23
|
formatNumber,
|
|
23
24
|
} from '@opendata-ai/openchart-core';
|
|
@@ -234,13 +235,33 @@ export function computeAxes(
|
|
|
234
235
|
major: true,
|
|
235
236
|
}));
|
|
236
237
|
|
|
238
|
+
// Auto-rotate labels when band scale labels would overlap.
|
|
239
|
+
// Uses max label width (not average) since one long label is enough to overlap.
|
|
240
|
+
let tickAngle = scales.x.channel.axis?.tickAngle;
|
|
241
|
+
if (tickAngle === undefined && scales.x.type === 'band' && ticks.length > 1) {
|
|
242
|
+
const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
|
|
243
|
+
let maxLabelWidth = 0;
|
|
244
|
+
for (const t of ticks) {
|
|
245
|
+
const w = estimateTextWidth(
|
|
246
|
+
t.label,
|
|
247
|
+
theme.fonts.sizes.axisTick,
|
|
248
|
+
theme.fonts.weights.normal,
|
|
249
|
+
);
|
|
250
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
251
|
+
}
|
|
252
|
+
// If the widest label exceeds 85% of the bandwidth, rotate to avoid overlap
|
|
253
|
+
if (maxLabelWidth > bandwidth * 0.85) {
|
|
254
|
+
tickAngle = -45;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
237
258
|
result.x = {
|
|
238
259
|
ticks,
|
|
239
260
|
gridlines: scales.x.channel.axis?.grid ? gridlines : [],
|
|
240
261
|
label: scales.x.channel.axis?.label,
|
|
241
262
|
labelStyle: axisLabelStyle,
|
|
242
263
|
tickLabelStyle,
|
|
243
|
-
tickAngle
|
|
264
|
+
tickAngle,
|
|
244
265
|
start: { x: chartArea.x, y: chartArea.y + chartArea.height },
|
|
245
266
|
end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
|
|
246
267
|
};
|
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 {
|
|
@@ -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,35 @@ 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
|
+
const availableWidth = chartArea.width - LEGEND_PADDING * 2;
|
|
253
|
+
const maxFit = entriesThatFit(entries, availableWidth, TOP_LEGEND_MAX_ROWS, labelStyle);
|
|
254
|
+
|
|
255
|
+
if (maxFit < entries.length) {
|
|
256
|
+
entries = truncateEntries(entries, maxFit);
|
|
257
|
+
}
|
|
258
|
+
|
|
177
259
|
const totalWidth = entries.reduce((sum, entry) => {
|
|
178
260
|
const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
179
261
|
return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
|
|
180
262
|
}, 0);
|
|
181
263
|
|
|
182
|
-
|
|
264
|
+
// Calculate actual row count for height
|
|
265
|
+
let rowCount = 1;
|
|
266
|
+
let rowWidth = 0;
|
|
267
|
+
for (const entry of entries) {
|
|
268
|
+
const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
269
|
+
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
|
|
270
|
+
if (rowWidth + entryWidth > availableWidth && rowWidth > 0) {
|
|
271
|
+
rowCount++;
|
|
272
|
+
rowWidth = entryWidth;
|
|
273
|
+
} else {
|
|
274
|
+
rowWidth += entryWidth;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const rowHeight = SWATCH_SIZE + 4;
|
|
279
|
+
const legendHeight = rowCount * rowHeight + LEGEND_PADDING * 2;
|
|
183
280
|
|
|
184
281
|
// Apply user-provided legend offset
|
|
185
282
|
const offsetDx = spec.legend?.offset?.dx ?? 0;
|