@opendata-ai/openchart-engine 6.19.3 → 6.21.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 +6 -0
- package/dist/index.js +865 -3729
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +1989 -0
- package/src/__tests__/axes.test.ts +65 -0
- package/src/__tests__/compile-snapshot.test.ts +156 -0
- package/src/__tests__/legend.test.ts +39 -0
- package/src/charts/__tests__/registry.test.ts +6 -0
- package/src/charts/_shared/__tests__/density-filter.test.ts +32 -0
- package/src/charts/_shared/density-filter.ts +26 -0
- package/src/charts/_shared/format-label-value.ts +15 -0
- package/src/charts/bar/__tests__/gradient-orient.test.ts +127 -0
- package/src/charts/bar/compute.ts +6 -11
- package/src/charts/bar/labels.ts +4 -15
- package/src/charts/builtin.ts +64 -0
- package/src/charts/column/compute.ts +6 -11
- package/src/charts/column/labels.ts +4 -19
- package/src/charts/dot/labels.ts +4 -19
- package/src/charts/pie/labels.ts +4 -6
- package/src/charts/registry.ts +6 -0
- package/src/compile/__tests__/color-scale-range.test.ts +79 -0
- package/src/compile/__tests__/data-clip.test.ts +59 -0
- package/src/compile/__tests__/watermark-obstacle.test.ts +93 -0
- package/src/compile/color-scale-range.ts +38 -0
- package/src/compile/data-clip.ts +33 -0
- package/src/compile/watermark-obstacle.ts +54 -0
- package/src/compile.ts +20 -97
- package/src/layout/axes/thinning.ts +96 -0
- package/src/layout/axes/ticks.ts +266 -0
- package/src/layout/axes.ts +148 -249
- package/src/legend/compute.ts +6 -51
- package/src/legend/wrap.ts +94 -0
- package/src/sankey/__tests__/node-label-wrap.test.ts +114 -0
- package/src/sankey/__tests__/node-sort.test.ts +45 -0
- package/src/sankey/compile-sankey.ts +5 -20
package/src/layout/axes.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Axis computation: tick positions, labels, and axis lines.
|
|
3
3
|
*
|
|
4
4
|
* Generates ticks manually (no d3-axis) so we have full control over
|
|
5
|
-
* responsive tick density and formatting.
|
|
5
|
+
* responsive tick density and formatting. Tick generation and label
|
|
6
|
+
* thinning live in sibling modules under ./axes/.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type {
|
|
@@ -16,33 +17,26 @@ import type {
|
|
|
16
17
|
ResolvedTheme,
|
|
17
18
|
TextStyle,
|
|
18
19
|
} from '@opendata-ai/openchart-core';
|
|
19
|
-
import {
|
|
20
|
-
abbreviateNumber,
|
|
21
|
-
buildD3Formatter,
|
|
22
|
-
buildTemporalFormatter,
|
|
23
|
-
estimateTextWidth,
|
|
24
|
-
formatDate,
|
|
25
|
-
formatNumber,
|
|
26
|
-
} from '@opendata-ai/openchart-core';
|
|
27
20
|
import type { ScaleBand } from 'd3-scale';
|
|
28
|
-
import
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
21
|
+
import { measureLabel, thinTicksUntilFit, ticksOverlap } from './axes/thinning';
|
|
22
|
+
import {
|
|
23
|
+
buildContinuousTicks,
|
|
24
|
+
categoricalTicks,
|
|
25
|
+
continuousTicks,
|
|
26
|
+
resolveExplicitTicks,
|
|
27
|
+
scaleSupportsTickCount,
|
|
28
|
+
targetTickCount,
|
|
29
|
+
} from './axes/ticks';
|
|
30
|
+
import type { ResolvedScales } from './scales';
|
|
31
|
+
|
|
32
|
+
// Re-export pure helpers so external consumers (and tests) continue to import
|
|
33
|
+
// them from './layout/axes'.
|
|
34
|
+
export { thinTicksUntilFit, ticksOverlap } from './axes/thinning';
|
|
34
35
|
|
|
35
36
|
// ---------------------------------------------------------------------------
|
|
36
37
|
// Constants
|
|
37
38
|
// ---------------------------------------------------------------------------
|
|
38
39
|
|
|
39
|
-
/** Base tick counts by axis label density. */
|
|
40
|
-
const TICK_COUNTS: Record<AxisLabelDensity, number> = {
|
|
41
|
-
full: 12,
|
|
42
|
-
reduced: 8,
|
|
43
|
-
minimal: 4,
|
|
44
|
-
};
|
|
45
|
-
|
|
46
40
|
/**
|
|
47
41
|
* Height thresholds for reducing y-axis tick density.
|
|
48
42
|
* Below these pixel heights, we step down the density regardless of the
|
|
@@ -59,15 +53,6 @@ const HEIGHT_REDUCED_THRESHOLD = 200;
|
|
|
59
53
|
const WIDTH_MINIMAL_THRESHOLD = 150;
|
|
60
54
|
const WIDTH_REDUCED_THRESHOLD = 300;
|
|
61
55
|
|
|
62
|
-
/**
|
|
63
|
-
* Minimum gap between adjacent tick labels as a multiple of font size.
|
|
64
|
-
* At the default 12px axis font, this yields ~12px of breathing room.
|
|
65
|
-
*/
|
|
66
|
-
const MIN_TICK_GAP_FACTOR = 1.0;
|
|
67
|
-
|
|
68
|
-
/** Always show at least this many ticks, even if they overlap. */
|
|
69
|
-
const MIN_TICK_COUNT = 2;
|
|
70
|
-
|
|
71
56
|
/** Ordered densities from most to fewest ticks. */
|
|
72
57
|
const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
|
|
73
58
|
|
|
@@ -106,217 +91,94 @@ export function effectiveDensity(
|
|
|
106
91
|
return density;
|
|
107
92
|
}
|
|
108
93
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
text: string,
|
|
116
|
-
fontSize: number,
|
|
117
|
-
fontWeight: number,
|
|
118
|
-
measureText?: MeasureTextFn,
|
|
119
|
-
): number {
|
|
120
|
-
return measureText
|
|
121
|
-
? measureText(text, fontSize, fontWeight).width
|
|
122
|
-
: estimateTextWidth(text, fontSize, fontWeight);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Check whether any adjacent tick labels overlap along the axis direction. */
|
|
126
|
-
export function ticksOverlap(
|
|
127
|
-
ticks: AxisTick[],
|
|
128
|
-
fontSize: number,
|
|
129
|
-
fontWeight: number,
|
|
130
|
-
measureText?: MeasureTextFn,
|
|
131
|
-
orientation: 'horizontal' | 'vertical' = 'horizontal',
|
|
132
|
-
): boolean {
|
|
133
|
-
if (ticks.length < 2) return false;
|
|
134
|
-
const minGap = fontSize * MIN_TICK_GAP_FACTOR;
|
|
135
|
-
|
|
136
|
-
if (orientation === 'vertical') {
|
|
137
|
-
// Y-axis: labels are stacked vertically. Check if vertical extent
|
|
138
|
-
// (based on font height) overlaps between adjacent ticks.
|
|
139
|
-
// Positions decrease going up in SVG coords, so sort ascending.
|
|
140
|
-
const sorted = [...ticks].sort((a, b) => a.position - b.position);
|
|
141
|
-
const labelHeight = fontSize * 1.2; // lineHeight
|
|
142
|
-
for (let i = 0; i < sorted.length - 1; i++) {
|
|
143
|
-
const aBottom = sorted[i].position + labelHeight / 2;
|
|
144
|
-
const bTop = sorted[i + 1].position - labelHeight / 2;
|
|
145
|
-
if (aBottom + minGap > bTop) return true;
|
|
146
|
-
}
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
94
|
+
/**
|
|
95
|
+
* Floor tick count for continuous axes when the axis is long enough to show
|
|
96
|
+
* more than a min/max pair. Keeps the editorial ~5 target when possible.
|
|
97
|
+
* Very short axes bypass this floor and can legitimately fall to 2.
|
|
98
|
+
*/
|
|
99
|
+
const CONTINUOUS_TICK_FLOOR = 4;
|
|
149
100
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
101
|
+
/**
|
|
102
|
+
* How much D3 is allowed to overshoot what we asked for before we treat the
|
|
103
|
+
* output as "too dense" and step down. D3's `scale.ticks(n)` only produces
|
|
104
|
+
* nice step sizes (1, 2, 5 × 10^k, or calendar units for time), so a request
|
|
105
|
+
* for n=6 can come back with 12 quarterly dates or 10 step-5 values. Accepting
|
|
106
|
+
* up to 1.5× target catches the obvious overshoots without trimming acceptable
|
|
107
|
+
* ones.
|
|
108
|
+
*
|
|
109
|
+
* The reference point stays fixed to the initial requested count even as we
|
|
110
|
+
* iterate downward — we're measuring "did this candidate land near the target
|
|
111
|
+
* the caller actually wanted?", not "is the candidate near what we just asked
|
|
112
|
+
* for on this iteration?". If a candidate at n=3 returns 8 ticks, it's still
|
|
113
|
+
* a 1.3× overshoot of the target-6, which is fine.
|
|
114
|
+
*/
|
|
115
|
+
const OVERSHOOT_TOLERANCE = 1.5;
|
|
159
116
|
|
|
160
117
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
118
|
+
* Fit continuous ticks by re-requesting progressively fewer ticks from the
|
|
119
|
+
* scale. D3's `scale.ticks(n)` always returns evenly-spaced round values, so
|
|
120
|
+
* stepping `n` down keeps spacing uniform — unlike middle-pruning which can
|
|
121
|
+
* strand the last tick next to an endpoint and cascade to 2 ticks.
|
|
122
|
+
*
|
|
123
|
+
* Two conditions trigger a step-down:
|
|
124
|
+
* 1. The label heuristic detects overlap at the initial count.
|
|
125
|
+
* 2. D3 overshot the requested count by more than OVERSHOOT_TOLERANCE.
|
|
126
|
+
* (Time scales jump between calendar units; linear scales jump between
|
|
127
|
+
* nice step sizes. Either can return 2× what we asked for.)
|
|
128
|
+
*
|
|
129
|
+
* Falls back to overlap-safe thinning on the best-so-far candidate if no
|
|
130
|
+
* count produces a clean fit. The fallback starts from the smallest candidate
|
|
131
|
+
* that still meets the floor, so `thinTicksUntilFit` never receives the
|
|
132
|
+
* overshot initial set (which was the bug this function exists to avoid).
|
|
164
133
|
*/
|
|
165
|
-
|
|
166
|
-
|
|
134
|
+
function fitContinuousTicks(
|
|
135
|
+
scale: ResolvedScales['x' | 'y'],
|
|
136
|
+
initialTicks: AxisTick[],
|
|
137
|
+
initialCount: number,
|
|
167
138
|
fontSize: number,
|
|
168
139
|
fontWeight: number,
|
|
140
|
+
axisLength: number,
|
|
141
|
+
orientation: 'horizontal' | 'vertical',
|
|
169
142
|
measureText?: MeasureTextFn,
|
|
170
|
-
orientation: 'horizontal' | 'vertical' = 'horizontal',
|
|
171
143
|
): AxisTick[] {
|
|
172
|
-
if (!
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return domain.map((value: unknown) => ({
|
|
202
|
-
value,
|
|
203
|
-
position: (scale as D3ContinuousScale)(value as number & Date) as number,
|
|
204
|
-
label: formatTickLabel(value, resolvedScale),
|
|
205
|
-
}));
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const explicitCount = resolvedScale.channel.axis?.tickCount;
|
|
209
|
-
const count = explicitCount ?? TICK_COUNTS[density];
|
|
210
|
-
const rawTicks: unknown[] = scale.ticks(count);
|
|
211
|
-
|
|
212
|
-
const ticks = rawTicks.map((value: unknown) => ({
|
|
213
|
-
value,
|
|
214
|
-
position: scale(value as number & Date) as number,
|
|
215
|
-
label: formatTickLabel(value, resolvedScale),
|
|
216
|
-
}));
|
|
217
|
-
|
|
218
|
-
return ticks;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Generate ticks for a band/point/ordinal scale. */
|
|
222
|
-
function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity): AxisTick[] {
|
|
223
|
-
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
224
|
-
const domain: string[] = scale.domain();
|
|
225
|
-
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
|
|
226
|
-
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
227
|
-
|
|
228
|
-
// Band scales (bar charts) show all category labels by default.
|
|
229
|
-
// Only thin when there's an explicit tickCount override or for point/ordinal scales.
|
|
230
|
-
let selectedValues = domain;
|
|
231
|
-
if ((resolvedScale.type !== 'band' || explicitTickCount) && domain.length > maxTicks) {
|
|
232
|
-
const step = Math.ceil(domain.length / maxTicks);
|
|
233
|
-
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const ticks = selectedValues.map((value: string) => {
|
|
237
|
-
// Band scales: use the center of the band
|
|
238
|
-
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
239
|
-
const pos = bandScale
|
|
240
|
-
? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2
|
|
241
|
-
: ((scale(value) as number | undefined) ?? 0);
|
|
242
|
-
|
|
243
|
-
return {
|
|
244
|
-
value,
|
|
245
|
-
position: pos,
|
|
246
|
-
label: value,
|
|
247
|
-
};
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
return ticks;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** Set of continuous numeric scale types that should format as numbers. */
|
|
254
|
-
const NUMERIC_SCALE_TYPES = new Set([
|
|
255
|
-
'linear',
|
|
256
|
-
'log',
|
|
257
|
-
'pow',
|
|
258
|
-
'sqrt',
|
|
259
|
-
'symlog',
|
|
260
|
-
'quantile',
|
|
261
|
-
'quantize',
|
|
262
|
-
'threshold',
|
|
263
|
-
]);
|
|
264
|
-
|
|
265
|
-
/** Set of temporal scale types. */
|
|
266
|
-
const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
|
|
267
|
-
|
|
268
|
-
/** Format a tick value based on the scale type. */
|
|
269
|
-
function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
|
|
270
|
-
const formatStr = resolvedScale.channel.axis?.format;
|
|
271
|
-
|
|
272
|
-
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
273
|
-
const temporalFmt = buildTemporalFormatter(formatStr);
|
|
274
|
-
if (temporalFmt) return temporalFmt(value as Date);
|
|
275
|
-
const useUtc = resolvedScale.type === 'utc';
|
|
276
|
-
return formatDate(value as Date, undefined, undefined, useUtc);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
|
|
280
|
-
const num = value as number;
|
|
281
|
-
if (formatStr) {
|
|
282
|
-
const fmt = buildD3Formatter(formatStr);
|
|
283
|
-
if (fmt) return fmt(num);
|
|
144
|
+
if (!scale || !scaleSupportsTickCount(scale)) return initialTicks;
|
|
145
|
+
|
|
146
|
+
const tolerance = initialCount * OVERSHOOT_TOLERANCE;
|
|
147
|
+
const overshoots = initialTicks.length > tolerance;
|
|
148
|
+
const overlaps = ticksOverlap(initialTicks, fontSize, fontWeight, measureText, orientation);
|
|
149
|
+
if (!overshoots && !overlaps) return initialTicks;
|
|
150
|
+
|
|
151
|
+
// Enforce the floor only when the axis is long enough to fit that many
|
|
152
|
+
// labels without overlap. Very short axes can fall below.
|
|
153
|
+
const minThreshold =
|
|
154
|
+
orientation === 'vertical' ? HEIGHT_MINIMAL_THRESHOLD : WIDTH_MINIMAL_THRESHOLD;
|
|
155
|
+
const floor = axisLength >= minThreshold ? CONTINUOUS_TICK_FLOOR : 2;
|
|
156
|
+
|
|
157
|
+
// Track the smallest candidate that meets the floor. If no candidate fits
|
|
158
|
+
// cleanly, we thin this instead of the overshot `initialTicks` so the
|
|
159
|
+
// fallback doesn't reintroduce the cascading-to-2-ticks bug.
|
|
160
|
+
let bestWithinFloor: AxisTick[] | undefined;
|
|
161
|
+
for (let n = initialCount - 1; n >= 2; n--) {
|
|
162
|
+
const candidate = buildContinuousTicks(scale, n);
|
|
163
|
+
const candidateOvershoots = candidate.length > tolerance;
|
|
164
|
+
const candidateOverlaps = ticksOverlap(
|
|
165
|
+
candidate,
|
|
166
|
+
fontSize,
|
|
167
|
+
fontWeight,
|
|
168
|
+
measureText,
|
|
169
|
+
orientation,
|
|
170
|
+
);
|
|
171
|
+
if (!candidateOvershoots && !candidateOverlaps) {
|
|
172
|
+
return candidate;
|
|
284
173
|
}
|
|
285
|
-
|
|
286
|
-
if (Math.abs(num) >= 1000) return abbreviateNumber(num);
|
|
287
|
-
return formatNumber(num);
|
|
174
|
+
if (candidate.length >= floor) bestWithinFloor = candidate;
|
|
288
175
|
}
|
|
289
176
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const scale = resolvedScale.scale;
|
|
296
|
-
return values.map((value) => {
|
|
297
|
-
let position: number;
|
|
298
|
-
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
299
|
-
const d = value instanceof Date ? value : new Date(String(value));
|
|
300
|
-
position = (scale as D3ContinuousScale)(d as number & Date) as number;
|
|
301
|
-
} else if (
|
|
302
|
-
resolvedScale.type === 'band' ||
|
|
303
|
-
resolvedScale.type === 'point' ||
|
|
304
|
-
resolvedScale.type === 'ordinal'
|
|
305
|
-
) {
|
|
306
|
-
const s = String(value);
|
|
307
|
-
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
308
|
-
position = bandScale
|
|
309
|
-
? (bandScale(s) ?? 0) + bandScale.bandwidth() / 2
|
|
310
|
-
: ((scale(s as string & number) as number | undefined) ?? 0);
|
|
311
|
-
} else {
|
|
312
|
-
position = (scale as D3ContinuousScale)(value as number & Date) as number;
|
|
313
|
-
}
|
|
314
|
-
return {
|
|
315
|
-
value,
|
|
316
|
-
position,
|
|
317
|
-
label: formatTickLabel(value, resolvedScale),
|
|
318
|
-
};
|
|
319
|
-
});
|
|
177
|
+
// No candidate fit cleanly. Thin whatever most recently met the floor; if
|
|
178
|
+
// nothing did, synthesize a floor-count set directly from the scale so we
|
|
179
|
+
// never hand the overshot initialTicks to the middle-pruning thinner.
|
|
180
|
+
const fallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
|
|
181
|
+
return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
|
|
320
182
|
}
|
|
321
183
|
|
|
322
184
|
// ---------------------------------------------------------------------------
|
|
@@ -385,19 +247,21 @@ export function computeAxes(
|
|
|
385
247
|
|
|
386
248
|
if (scales.x) {
|
|
387
249
|
const axisConfig = scales.x.channel.axis;
|
|
250
|
+
const isContinuousX =
|
|
251
|
+
scales.x.type !== 'band' && scales.x.type !== 'point' && scales.x.type !== 'ordinal';
|
|
252
|
+
|
|
253
|
+
const xTargetCount = isContinuousX
|
|
254
|
+
? targetTickCount(chartArea.width, xDensity, 'x')
|
|
255
|
+
: undefined;
|
|
388
256
|
|
|
389
257
|
// Use explicit tick values from axis config if provided
|
|
390
258
|
let allTicks: AxisTick[];
|
|
391
259
|
if (axisConfig?.values) {
|
|
392
260
|
allTicks = resolveExplicitTicks(axisConfig.values, scales.x);
|
|
393
|
-
} else if (
|
|
394
|
-
scales.x.type === 'band' ||
|
|
395
|
-
scales.x.type === 'point' ||
|
|
396
|
-
scales.x.type === 'ordinal'
|
|
397
|
-
) {
|
|
261
|
+
} else if (!isContinuousX) {
|
|
398
262
|
allTicks = categoricalTicks(scales.x, xDensity);
|
|
399
263
|
} else {
|
|
400
|
-
allTicks = continuousTicks(scales.x, xDensity);
|
|
264
|
+
allTicks = continuousTicks(scales.x, xDensity, xTargetCount);
|
|
401
265
|
}
|
|
402
266
|
|
|
403
267
|
// Gridlines use the full tick set so they remain visible even when labels
|
|
@@ -410,9 +274,25 @@ export function computeAxes(
|
|
|
410
274
|
// Thin tick labels to prevent overlap (skip for band scales which use
|
|
411
275
|
// auto-rotation, and when the user set an explicit tickCount or values).
|
|
412
276
|
const shouldThin = scales.x.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
277
|
+
let ticks: AxisTick[];
|
|
278
|
+
if (!shouldThin) {
|
|
279
|
+
ticks = allTicks;
|
|
280
|
+
} else if (isContinuousX) {
|
|
281
|
+
// Continuous x-axis: re-request ticks at a lower count on overlap so
|
|
282
|
+
// time-scale quartile/monthly jumps don't leave a too-dense axis.
|
|
283
|
+
ticks = fitContinuousTicks(
|
|
284
|
+
scales.x,
|
|
285
|
+
allTicks,
|
|
286
|
+
xTargetCount ?? allTicks.length,
|
|
287
|
+
fontSize,
|
|
288
|
+
fontWeight,
|
|
289
|
+
chartArea.width,
|
|
290
|
+
'horizontal',
|
|
291
|
+
measureText,
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText);
|
|
295
|
+
}
|
|
416
296
|
|
|
417
297
|
// Auto-rotate labels when band scale labels would overlap.
|
|
418
298
|
// Uses max label width (not average) since one long label is enough to overlap.
|
|
@@ -454,26 +334,45 @@ export function computeAxes(
|
|
|
454
334
|
|
|
455
335
|
if (scales.y) {
|
|
456
336
|
const axisConfig = scales.y.channel.axis;
|
|
337
|
+
const isContinuousY =
|
|
338
|
+
scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
|
|
339
|
+
|
|
340
|
+
// Target count from pixels-per-tick when we have a continuous y-axis.
|
|
341
|
+
const yTargetCount = isContinuousY
|
|
342
|
+
? targetTickCount(chartArea.height, yDensity, 'y')
|
|
343
|
+
: undefined;
|
|
457
344
|
|
|
458
345
|
// Use explicit tick values from axis config if provided
|
|
459
346
|
let allTicks: AxisTick[];
|
|
460
347
|
if (axisConfig?.values) {
|
|
461
348
|
allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
|
|
462
|
-
} else if (
|
|
463
|
-
scales.y.type === 'band' ||
|
|
464
|
-
scales.y.type === 'point' ||
|
|
465
|
-
scales.y.type === 'ordinal'
|
|
466
|
-
) {
|
|
349
|
+
} else if (!isContinuousY) {
|
|
467
350
|
allTicks = categoricalTicks(scales.y, yDensity);
|
|
468
351
|
} else {
|
|
469
|
-
allTicks = continuousTicks(scales.y, yDensity);
|
|
352
|
+
allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
|
|
470
353
|
}
|
|
471
354
|
|
|
472
355
|
// Thin tick labels to prevent overlap (skip for band scales, explicit tickCount, and values).
|
|
473
356
|
const shouldThin = scales.y.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
357
|
+
let ticks: AxisTick[];
|
|
358
|
+
if (!shouldThin) {
|
|
359
|
+
ticks = allTicks;
|
|
360
|
+
} else if (isContinuousY) {
|
|
361
|
+
// Continuous y-axis: re-request ticks at a lower count on overlap so
|
|
362
|
+
// spacing stays uniform and we don't collapse to min/max.
|
|
363
|
+
ticks = fitContinuousTicks(
|
|
364
|
+
scales.y,
|
|
365
|
+
allTicks,
|
|
366
|
+
yTargetCount ?? allTicks.length,
|
|
367
|
+
fontSize,
|
|
368
|
+
fontWeight,
|
|
369
|
+
chartArea.height,
|
|
370
|
+
'vertical',
|
|
371
|
+
measureText,
|
|
372
|
+
);
|
|
373
|
+
} else {
|
|
374
|
+
ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText, 'vertical');
|
|
375
|
+
}
|
|
477
376
|
|
|
478
377
|
// Gridlines match the tick set so every gridline has a label.
|
|
479
378
|
const gridlines: Gridline[] = ticks.map((t) => ({
|
package/src/legend/compute.ts
CHANGED
|
@@ -23,14 +23,12 @@ import type {
|
|
|
23
23
|
import { BRAND_RESERVE_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
24
24
|
|
|
25
25
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
26
|
+
import { ENTRY_GAP, measureLegendWrap, SWATCH_GAP, SWATCH_SIZE } from './wrap';
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
// Constants
|
|
29
30
|
// ---------------------------------------------------------------------------
|
|
30
31
|
|
|
31
|
-
const SWATCH_SIZE = 12;
|
|
32
|
-
const SWATCH_GAP = 6;
|
|
33
|
-
const ENTRY_GAP = 16;
|
|
34
32
|
const LEGEND_PADDING = 8;
|
|
35
33
|
const LEGEND_RIGHT_WIDTH = 120;
|
|
36
34
|
|
|
@@ -92,38 +90,6 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
|
|
|
92
90
|
});
|
|
93
91
|
}
|
|
94
92
|
|
|
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
|
-
|
|
127
93
|
/**
|
|
128
94
|
* Truncate entries and add a "+N more" indicator if needed.
|
|
129
95
|
*/
|
|
@@ -310,10 +276,10 @@ export function computeLegend(
|
|
|
310
276
|
: spec.legend?.columns != null
|
|
311
277
|
? Math.ceil(entries.length / spec.legend.columns)
|
|
312
278
|
: TOP_LEGEND_MAX_ROWS;
|
|
313
|
-
const
|
|
279
|
+
const { fittingCount } = measureLegendWrap(entries, availableWidth, labelStyle, maxRows);
|
|
314
280
|
|
|
315
|
-
if (
|
|
316
|
-
entries = truncateEntries(entries,
|
|
281
|
+
if (fittingCount < entries.length) {
|
|
282
|
+
entries = truncateEntries(entries, fittingCount);
|
|
317
283
|
}
|
|
318
284
|
|
|
319
285
|
const totalWidth = entries.reduce((sum, entry) => {
|
|
@@ -321,19 +287,8 @@ export function computeLegend(
|
|
|
321
287
|
return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
|
|
322
288
|
}, 0);
|
|
323
289
|
|
|
324
|
-
// Calculate actual row count for height
|
|
325
|
-
|
|
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
|
-
}
|
|
290
|
+
// Calculate actual row count for height (recompute after truncation).
|
|
291
|
+
const { rowCount } = measureLegendWrap(entries, availableWidth, labelStyle);
|
|
337
292
|
|
|
338
293
|
const rowHeight = SWATCH_SIZE + 4;
|
|
339
294
|
const legendHeight = rowCount * rowHeight + LEGEND_PADDING * 2;
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
//
|
|
24
|
+
// Single source of truth for legend row geometry. Both compute.ts and the
|
|
25
|
+
// sankey compile site import these so the wrap math here can never drift from
|
|
26
|
+
// the layout math at the call sites.
|
|
27
|
+
|
|
28
|
+
export const SWATCH_SIZE = 12;
|
|
29
|
+
export const SWATCH_GAP = 6;
|
|
30
|
+
export const ENTRY_GAP = 16;
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Public API
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export interface LegendWrapResult {
|
|
37
|
+
/** Total number of rows the entries occupy when wrapped at maxWidth. */
|
|
38
|
+
rowCount: number;
|
|
39
|
+
/** Entries that fit within maxRows (for truncation). Equals entries.length when maxRows is not set or all entries fit. */
|
|
40
|
+
fittingCount: number;
|
|
41
|
+
/** Width (in px) of each row — callers can use for alignment. */
|
|
42
|
+
rowWidths: number[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Measure how legend entries wrap across rows at a given max width.
|
|
47
|
+
*
|
|
48
|
+
* @param entries - Legend entries to measure.
|
|
49
|
+
* @param maxWidth - Maximum width (in px) available for a single row.
|
|
50
|
+
* @param labelStyle - Text style used to estimate label widths.
|
|
51
|
+
* @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.
|
|
52
|
+
*/
|
|
53
|
+
export function measureLegendWrap(
|
|
54
|
+
entries: LegendEntry[],
|
|
55
|
+
maxWidth: number,
|
|
56
|
+
labelStyle: TextStyle,
|
|
57
|
+
maxRows?: number,
|
|
58
|
+
): LegendWrapResult {
|
|
59
|
+
if (entries.length === 0) {
|
|
60
|
+
return { rowCount: 0, fittingCount: 0, rowWidths: [] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let rowCount = 1;
|
|
64
|
+
let rowWidth = 0;
|
|
65
|
+
const rowWidths: number[] = [];
|
|
66
|
+
let fittingCount = entries.length;
|
|
67
|
+
let fittingCountLocked = false;
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < entries.length; i++) {
|
|
70
|
+
const labelWidth = estimateTextWidth(
|
|
71
|
+
entries[i].label,
|
|
72
|
+
labelStyle.fontSize,
|
|
73
|
+
labelStyle.fontWeight,
|
|
74
|
+
);
|
|
75
|
+
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
|
|
76
|
+
|
|
77
|
+
if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
|
|
78
|
+
rowWidths.push(rowWidth);
|
|
79
|
+
rowCount++;
|
|
80
|
+
rowWidth = entryWidth;
|
|
81
|
+
if (!fittingCountLocked && maxRows != null && rowCount > maxRows) {
|
|
82
|
+
fittingCount = i;
|
|
83
|
+
fittingCountLocked = true;
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
rowWidth += entryWidth;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Flush the final row width so rowWidths has one entry per row.
|
|
91
|
+
rowWidths.push(rowWidth);
|
|
92
|
+
|
|
93
|
+
return { rowCount, fittingCount, rowWidths };
|
|
94
|
+
}
|