@opendata-ai/openchart-engine 7.8.0 → 7.9.1
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 +63 -4
- package/dist/index.js +1644 -1428
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +31 -0
- package/src/__tests__/axes.test.ts +76 -0
- package/src/__tests__/compile-animation.test.ts +8 -8
- package/src/__tests__/compile-chart.test.ts +5 -5
- package/src/__tests__/dimensions.test.ts +116 -5
- package/src/__tests__/mark-keys.test.ts +294 -0
- package/src/charts/__tests__/post-process.test.ts +5 -26
- package/src/charts/bar/compute.ts +18 -0
- package/src/charts/column/compute.ts +18 -0
- package/src/charts/dot/compute.ts +30 -13
- package/src/charts/line/area.ts +20 -0
- package/src/charts/line/compute.ts +35 -1
- package/src/charts/line/curves.ts +47 -2
- package/src/charts/pie/compute.ts +13 -0
- package/src/charts/post-process.ts +2 -2
- package/src/charts/rule/index.ts +9 -0
- package/src/charts/scatter/compute.ts +15 -0
- package/src/charts/text/index.ts +9 -0
- package/src/charts/tick/index.ts +9 -0
- package/src/compiler/__tests__/animation.test.ts +78 -22
- package/src/compiler/__tests__/keys.test.ts +89 -0
- package/src/compiler/animation.ts +70 -50
- package/src/compiler/keys.ts +54 -0
- package/src/compiler/normalize.ts +1 -1
- package/src/index.ts +19 -1
- package/src/layout/axes.ts +125 -34
- package/src/layout/dimensions.ts +10 -11
- package/src/layout/plan.ts +11 -12
- package/src/tables/__tests__/compile-table.test.ts +5 -5
- package/src/tilemap/compile-tilemap.ts +93 -21
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mark key utilities for data-update transitions.
|
|
3
|
+
*
|
|
4
|
+
* Keys uniquely identify marks across data updates so the renderer can
|
|
5
|
+
* match old marks to new marks and animate the difference. Each chart
|
|
6
|
+
* type stamps keys during its compute phase; these helpers provide the
|
|
7
|
+
* shared serialization and deduplication logic.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Sentinel string for null/undefined values. Uses a Unicode symbol
|
|
11
|
+
* that won't collide with real data values. */
|
|
12
|
+
const NULL_KEY = '∅';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Serialize a datum value into a stable key fragment.
|
|
16
|
+
* - Date -> epoch ms string (avoids locale-dependent Date.toString)
|
|
17
|
+
* - number/string/boolean -> String(v)
|
|
18
|
+
* - null/undefined -> null sentinel
|
|
19
|
+
*/
|
|
20
|
+
export function serializeKeyValue(v: unknown): string {
|
|
21
|
+
if (v === null || v === undefined) return NULL_KEY;
|
|
22
|
+
if (v instanceof Date) return String(v.getTime());
|
|
23
|
+
return String(v);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Append occurrence suffixes to duplicate keys, in order.
|
|
28
|
+
*
|
|
29
|
+
* Only keys that appear more than once get suffixed. Unique keys pass
|
|
30
|
+
* through unchanged so keys remain human-readable when possible.
|
|
31
|
+
* Uses SOH (\x01) as separator to avoid collisions with real data values.
|
|
32
|
+
*/
|
|
33
|
+
export function dedupeKeys(keys: string[]): string[] {
|
|
34
|
+
// Count occurrences
|
|
35
|
+
const counts = new Map<string, number>();
|
|
36
|
+
for (const k of keys) {
|
|
37
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Assign suffixes only to duplicates
|
|
41
|
+
const seen = new Map<string, number>();
|
|
42
|
+
const result: string[] = [];
|
|
43
|
+
for (const k of keys) {
|
|
44
|
+
if (counts.get(k)! > 1) {
|
|
45
|
+
const idx = seen.get(k) ?? 0;
|
|
46
|
+
result.push(`${k}\x01${idx}`);
|
|
47
|
+
seen.set(k, idx + 1);
|
|
48
|
+
} else {
|
|
49
|
+
result.push(k);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
@@ -132,7 +132,7 @@ function inferFieldType(data: DataRow[], field: string): FieldType {
|
|
|
132
132
|
function inferEncodingTypes(encoding: Encoding, data: DataRow[], warnings: string[]): Encoding {
|
|
133
133
|
const result = { ...encoding };
|
|
134
134
|
|
|
135
|
-
for (const channel of ['x', 'y', 'color', 'size', 'detail'] as const) {
|
|
135
|
+
for (const channel of ['x', 'y', 'color', 'size', 'detail', 'key'] as const) {
|
|
136
136
|
const spec = result[channel];
|
|
137
137
|
if (!spec) continue;
|
|
138
138
|
|
package/src/index.ts
CHANGED
|
@@ -26,7 +26,25 @@ export {
|
|
|
26
26
|
// Animation resolution
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
28
28
|
|
|
29
|
-
export {
|
|
29
|
+
export {
|
|
30
|
+
clampStaggerDelay,
|
|
31
|
+
ENTER_DEFAULTS,
|
|
32
|
+
EXIT_DEFAULTS,
|
|
33
|
+
resolveAnimation,
|
|
34
|
+
UPDATE_DEFAULTS,
|
|
35
|
+
} from './compiler/animation';
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Mark keying for data-update transitions
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
export { dedupeKeys, serializeKeyValue } from './compiler/keys';
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Geometry helpers for path reconstruction
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export { buildAreaPath, buildLinePath } from './charts/line/curves';
|
|
30
48
|
|
|
31
49
|
// ---------------------------------------------------------------------------
|
|
32
50
|
// Graph compilation types
|
package/src/layout/axes.ts
CHANGED
|
@@ -43,6 +43,59 @@ import type { ResolvedScales } from './scales';
|
|
|
43
43
|
// them from './layout/axes'.
|
|
44
44
|
export { thinTicksUntilFit, ticksOverlap } from './axes/thinning';
|
|
45
45
|
|
|
46
|
+
/** Scale types that render as a categorical (non-continuous) y-axis. */
|
|
47
|
+
const CATEGORICAL_SCALE_TYPES = new Set(['band', 'point', 'ordinal']);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether the y-axis renders tick labels inline (above their gridline inside
|
|
51
|
+
* the plot) rather than in a left gutter.
|
|
52
|
+
*
|
|
53
|
+
* This is the single source of truth shared by margin reservation (plan.ts,
|
|
54
|
+
* dimensions.ts) and the renderer's title placement. All three must agree or
|
|
55
|
+
* the reserved left margin won't match where the title is drawn.
|
|
56
|
+
*
|
|
57
|
+
* Editorial line/area charts with a continuous y-axis default to inline ticks.
|
|
58
|
+
* Continuity is decided by the RESOLVED scale type, not the field type: an
|
|
59
|
+
* explicit `scale.type` override (e.g. a band scale forced onto a quantitative
|
|
60
|
+
* field) wins, mirroring buildPositionalScale() in scales.ts. Right-side
|
|
61
|
+
* (dual-axis) y-axes always use the gutter. An explicit axis.tickPosition wins
|
|
62
|
+
* over the default.
|
|
63
|
+
*/
|
|
64
|
+
export function yTickPositionIsInline(
|
|
65
|
+
yChannel:
|
|
66
|
+
| {
|
|
67
|
+
type?: string;
|
|
68
|
+
scale?: { type?: string };
|
|
69
|
+
axis?: unknown;
|
|
70
|
+
}
|
|
71
|
+
| undefined,
|
|
72
|
+
markType: string,
|
|
73
|
+
/**
|
|
74
|
+
* Resolved continuity of the y-scale, when the caller already has the built
|
|
75
|
+
* scale (computeAxes). When omitted, continuity is inferred from the encoding
|
|
76
|
+
* (explicit scale.type override, else field type) so the reservation paths
|
|
77
|
+
* predict the same answer computeAxes will produce.
|
|
78
|
+
*/
|
|
79
|
+
resolvedContinuous?: boolean,
|
|
80
|
+
): boolean {
|
|
81
|
+
if (!yChannel) return false;
|
|
82
|
+
const axisCfg = (yChannel.axis as Record<string, unknown> | undefined) ?? undefined;
|
|
83
|
+
const explicit = axisCfg?.tickPosition as 'inline' | 'gutter' | undefined;
|
|
84
|
+
if (explicit) return explicit === 'inline';
|
|
85
|
+
|
|
86
|
+
const isLineOrArea = markType === 'line' || markType === 'area';
|
|
87
|
+
if (!isLineOrArea) return false;
|
|
88
|
+
if ((axisCfg?.orient as string | undefined) === 'right') return false;
|
|
89
|
+
|
|
90
|
+
if (resolvedContinuous !== undefined) return resolvedContinuous;
|
|
91
|
+
|
|
92
|
+
// Predict the resolved scale type: explicit override wins, else field type.
|
|
93
|
+
const explicitScaleType = yChannel.scale?.type;
|
|
94
|
+
return explicitScaleType
|
|
95
|
+
? !CATEGORICAL_SCALE_TYPES.has(explicitScaleType)
|
|
96
|
+
: yChannel.type === 'quantitative' || yChannel.type === 'temporal';
|
|
97
|
+
}
|
|
98
|
+
|
|
46
99
|
// ---------------------------------------------------------------------------
|
|
47
100
|
// Constants
|
|
48
101
|
// ---------------------------------------------------------------------------
|
|
@@ -242,36 +295,46 @@ function fitContinuousTicks(
|
|
|
242
295
|
}
|
|
243
296
|
|
|
244
297
|
/**
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
298
|
+
* Horizontal footprint `[left, right]` of a rotated band tick label, in axis
|
|
299
|
+
* pixels. Rotated labels are anchored at the tick position (the rotation
|
|
300
|
+
* pivot), not centered on it: at a negative angle the renderer right-anchors
|
|
301
|
+
* the text so it extends left of the pivot; at a positive angle it left-anchors
|
|
302
|
+
* so it extends right. The horizontal projection is `width * cos(angle)`.
|
|
303
|
+
*
|
|
304
|
+
* Modeling the footprint as centered (the old behavior) over-estimates each
|
|
305
|
+
* label's reach by half its width in the wrong direction, which made a single
|
|
306
|
+
* wide label (e.g. "2026 (to wk 17)") report overlaps against neighbors that
|
|
307
|
+
* actually clear it — triggering needless thinning of the whole axis.
|
|
249
308
|
*/
|
|
250
|
-
function
|
|
251
|
-
|
|
309
|
+
function labelSpanAtAngle(
|
|
310
|
+
tick: AxisTick,
|
|
252
311
|
angleDeg: number,
|
|
253
312
|
fontSize: number,
|
|
254
313
|
fontWeight: number,
|
|
255
314
|
measureText?: MeasureTextFn,
|
|
256
|
-
):
|
|
257
|
-
if (ticks.length < 2) return false;
|
|
315
|
+
): { left: number; right: number } {
|
|
258
316
|
const angleRad = (Math.abs(angleDeg) * Math.PI) / 180;
|
|
259
317
|
const cosA = angleRad > 0 ? Math.abs(Math.cos(angleRad)) : 1;
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
318
|
+
const projected = measureLabel(tick.label, fontSize, fontWeight, measureText) * cosA;
|
|
319
|
+
if (angleDeg === 0) {
|
|
320
|
+
// Horizontal labels are centered on the tick.
|
|
321
|
+
return { left: tick.position - projected / 2, right: tick.position + projected / 2 };
|
|
322
|
+
}
|
|
323
|
+
if (angleDeg < 0) {
|
|
324
|
+
// Right-anchored: pivot is the right edge, text extends left.
|
|
325
|
+
return { left: tick.position - projected, right: tick.position };
|
|
267
326
|
}
|
|
268
|
-
|
|
327
|
+
// Left-anchored: pivot is the left edge, text extends right.
|
|
328
|
+
return { left: tick.position, right: tick.position + projected };
|
|
269
329
|
}
|
|
270
330
|
|
|
271
331
|
/**
|
|
272
|
-
* Thin band-scale tick labels only
|
|
273
|
-
* effective angle
|
|
274
|
-
*
|
|
332
|
+
* Thin band-scale tick labels only where they actually collide at their
|
|
333
|
+
* effective angle, greedily keeping every label that clears the last one
|
|
334
|
+
* retained. This preserves narrow labels that have room even when a single
|
|
335
|
+
* wide label elsewhere on the axis forces some thinning near it, instead of
|
|
336
|
+
* decimating the whole axis every-other. First and last labels are always
|
|
337
|
+
* kept. Most grouped bar charts keep every label even at -45°.
|
|
275
338
|
*/
|
|
276
339
|
function thinBandTicksIfNeeded(
|
|
277
340
|
ticks: AxisTick[],
|
|
@@ -280,19 +343,42 @@ function thinBandTicksIfNeeded(
|
|
|
280
343
|
fontWeight: number,
|
|
281
344
|
measureText?: MeasureTextFn,
|
|
282
345
|
): AxisTick[] {
|
|
283
|
-
if (
|
|
346
|
+
if (ticks.length < 3) return ticks;
|
|
347
|
+
const minGap = fontSize * 0.5;
|
|
284
348
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
349
|
+
const spans = ticks.map((t) => labelSpanAtAngle(t, angleDeg, fontSize, fontWeight, measureText));
|
|
350
|
+
|
|
351
|
+
// Greedy left-to-right retention: keep a label only if its footprint clears
|
|
352
|
+
// the last kept label's right edge by minGap. Always keep the first label.
|
|
353
|
+
const keep = new Array<boolean>(ticks.length).fill(false);
|
|
354
|
+
keep[0] = true;
|
|
355
|
+
let lastRight = spans[0].right;
|
|
356
|
+
for (let i = 1; i < ticks.length; i++) {
|
|
357
|
+
if (spans[i].left >= lastRight + minGap) {
|
|
358
|
+
keep[i] = true;
|
|
359
|
+
lastRight = spans[i].right;
|
|
290
360
|
}
|
|
291
|
-
if (current.length > 1) thinned.push(current[current.length - 1]);
|
|
292
|
-
current = thinned;
|
|
293
|
-
if (!bandTicksOverlapAtAngle(current, angleDeg, fontSize, fontWeight, measureText)) break;
|
|
294
361
|
}
|
|
295
|
-
|
|
362
|
+
|
|
363
|
+
// Always keep the last label. If keeping it collides with previously kept
|
|
364
|
+
// labels, drop each colliding neighbor so the endpoint stays labeled. A very
|
|
365
|
+
// wide final label (long category name) projects across several bands at an
|
|
366
|
+
// angle, so clear every kept label its footprint overlaps, not just the
|
|
367
|
+
// nearest one — otherwise an earlier kept label still overlaps the endpoint.
|
|
368
|
+
const lastIdx = ticks.length - 1;
|
|
369
|
+
if (!keep[lastIdx]) {
|
|
370
|
+
keep[lastIdx] = true;
|
|
371
|
+
for (let i = lastIdx - 1; i > 0; i--) {
|
|
372
|
+
if (!keep[i]) continue;
|
|
373
|
+
if (spans[i].right + minGap > spans[lastIdx].left) {
|
|
374
|
+
keep[i] = false;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return ticks.filter((_, i) => keep[i]);
|
|
296
382
|
}
|
|
297
383
|
|
|
298
384
|
// ---------------------------------------------------------------------------
|
|
@@ -561,13 +647,18 @@ export function computeAxes(
|
|
|
561
647
|
const yLabelColor = axisConfig?.labelColor;
|
|
562
648
|
// Editorial line/area y-axes default to inline tick labels above their
|
|
563
649
|
// gridlines. Other mark types keep the classic gutter placement. Right-side
|
|
564
|
-
// y-axes (dual-axis) always use gutter.
|
|
650
|
+
// y-axes (dual-axis) always use gutter. Uses the shared predicate so the
|
|
651
|
+
// margin-reservation paths (plan.ts, dimensions.ts) agree with this; here
|
|
652
|
+
// we pass the RESOLVED scale continuity since the scale is already built.
|
|
565
653
|
const isContinuousYAxis =
|
|
566
654
|
scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
655
|
+
const yTickPosition: 'inline' | 'gutter' = yTickPositionIsInline(
|
|
656
|
+
dataContext?.encoding.y,
|
|
657
|
+
dataContext?.markType ?? '',
|
|
658
|
+
isContinuousYAxis,
|
|
659
|
+
)
|
|
660
|
+
? 'inline'
|
|
661
|
+
: 'gutter';
|
|
571
662
|
|
|
572
663
|
// Inline mode hides the axis line and tick marks by default; the gridlines
|
|
573
664
|
// themselves serve as the visual axis. Explicit user overrides win.
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -45,6 +45,7 @@ import type { NormalizedChartSpec } from '../compiler/types';
|
|
|
45
45
|
import { isEndsBoth, predictEndpointLabelsWidth } from '../endpoint-labels/predict';
|
|
46
46
|
import { countColorSeries, resolveSuppression } from '../legend/suppression';
|
|
47
47
|
import { legendGap } from '../legend/wrap';
|
|
48
|
+
import { yTickPositionIsInline } from './axes';
|
|
48
49
|
import { computeMetricBar, type MetricFontSizes, metricBarHeight } from './metrics';
|
|
49
50
|
import type { LayoutPlan } from './plan';
|
|
50
51
|
import { bottomMargin, chromeToInput, INLINE_TICK_OVERHANG_PAD, scalePadding } from './shared';
|
|
@@ -306,16 +307,9 @@ export function computeDimensions(
|
|
|
306
307
|
// gridline inside the chart area; the topmost tick text extends roughly
|
|
307
308
|
// (tickFontSize + INLINE_TICK_OVERHANG_PAD) pixels above area.y, which
|
|
308
309
|
// would otherwise crowd the chrome→chart gap.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
const yIsLineOrAreaPre = spec.markType === 'line' || spec.markType === 'area';
|
|
313
|
-
const yAxisOrientPre = yAxisCfgPre?.orient as 'left' | 'right' | 'top' | 'bottom' | undefined;
|
|
314
|
-
const yIsInlinePre =
|
|
315
|
-
(yTickPositionExplicitPre ??
|
|
316
|
-
(yIsLineOrAreaPre && yIsContinuousPre && yAxisOrientPre !== 'right'
|
|
317
|
-
? 'inline'
|
|
318
|
-
: 'gutter')) === 'inline';
|
|
310
|
+
// Inline y-tick placement must match computeAxes exactly (shared predicate),
|
|
311
|
+
// or the reserved left margin won't line up with where the title is drawn.
|
|
312
|
+
const yIsInlinePre = yTickPositionIsInline(encoding.y, spec.markType);
|
|
319
313
|
const inlineTickOverhang = yIsInlinePre
|
|
320
314
|
? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
|
|
321
315
|
: 0;
|
|
@@ -571,8 +565,13 @@ export function computeDimensions(
|
|
|
571
565
|
const yFieldForTitle = encoding.y?.field;
|
|
572
566
|
const yAxisFormatForTitle = yAxis?.format as string | undefined;
|
|
573
567
|
let estTickLabelWidth = 0;
|
|
568
|
+
// Inline y-tick labels render inside the chart area (above their gridlines),
|
|
569
|
+
// not in a left gutter, so they add no width the rotated title must clear.
|
|
570
|
+
// Counting them here would reserve a phantom gutter and leave a dead gap
|
|
571
|
+
// between the title and the plot.
|
|
574
572
|
if (
|
|
575
573
|
yFieldForTitle &&
|
|
574
|
+
!yIsInlinePre &&
|
|
576
575
|
(encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal')
|
|
577
576
|
) {
|
|
578
577
|
let maxAbsValForTitle = 0;
|
|
@@ -609,7 +608,7 @@ export function computeDimensions(
|
|
|
609
608
|
// We add another halfGlyph here for the title glyph extending the other way,
|
|
610
609
|
// toward the container edge, so the margin reaches the title's outer edge.
|
|
611
610
|
const titleFontSize = theme.fonts.sizes.body;
|
|
612
|
-
const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width);
|
|
611
|
+
const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width, yIsInlinePre);
|
|
613
612
|
const halfGlyph = Math.ceil(titleFontSize / 2);
|
|
614
613
|
const rotatedLabelMargin =
|
|
615
614
|
offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
|
package/src/layout/plan.ts
CHANGED
|
@@ -33,6 +33,7 @@ import type { NormalizedChartSpec } from '../compiler/types';
|
|
|
33
33
|
import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
|
|
34
34
|
import { computeLegendContent, type LegendContent } from '../legend/compute';
|
|
35
35
|
import { legendGap } from '../legend/wrap';
|
|
36
|
+
import { yTickPositionIsInline } from './axes';
|
|
36
37
|
import { resolveBandTickAngle } from './axes/rotation';
|
|
37
38
|
import { buildContinuousTicks, scaleSupportsTickCount, targetTickCount } from './axes/ticks';
|
|
38
39
|
import { computeScales, estimateBandwidth } from './scales';
|
|
@@ -177,17 +178,11 @@ export function resolveLayoutPlan(
|
|
|
177
178
|
// Resolve y-axis inline status
|
|
178
179
|
// -----------------------------------------------------------------------
|
|
179
180
|
const yAxisCfg = (encoding.y?.axis as Record<string, unknown> | undefined) ?? undefined;
|
|
180
|
-
const yTickPositionExplicit = yAxisCfg?.tickPosition as 'inline' | 'gutter' | undefined;
|
|
181
181
|
const yIsContinuous = encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal';
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// Sparkline display already returned early above, so no sparkline guard needed
|
|
185
|
-
const yIsInline =
|
|
186
|
-
yTickPositionExplicit === 'inline' ||
|
|
187
|
-
(yTickPositionExplicit === undefined &&
|
|
188
|
-
yIsLineOrArea &&
|
|
189
|
-
yIsContinuous &&
|
|
190
|
-
yAxisOrient !== 'right');
|
|
182
|
+
// Inline y-tick placement must match computeAxes exactly (shared predicate),
|
|
183
|
+
// or the reserved left margin won't line up with where the title is drawn.
|
|
184
|
+
// Sparkline display already returned early above, so no sparkline guard needed.
|
|
185
|
+
const yIsInline = yTickPositionIsInline(encoding.y, renderSpec.markType);
|
|
191
186
|
const yAxisSuppressed = encoding.y?.axis === false;
|
|
192
187
|
|
|
193
188
|
// -----------------------------------------------------------------------
|
|
@@ -517,7 +512,11 @@ export function resolveLayoutPlan(
|
|
|
517
512
|
const yAxisDef = encoding.y?.axis as Record<string, unknown> | undefined;
|
|
518
513
|
if (yAxisDef && (yAxisDef.title || yAxisDef.label) && !isRadial) {
|
|
519
514
|
let estTickLabelWidth = 0;
|
|
520
|
-
|
|
515
|
+
// Inline y-tick labels sit above their gridlines inside the chart area,
|
|
516
|
+
// not in a left gutter, so they add no width the title must clear.
|
|
517
|
+
// Reserving their width here would leave a dead gap between the title
|
|
518
|
+
// and the plot. Only gutter (non-inline) tick labels count.
|
|
519
|
+
if (encoding.y?.field && yIsContinuous && !yIsInline) {
|
|
521
520
|
if (yTickValues.length > 0) {
|
|
522
521
|
const titleProvArea: Rect = {
|
|
523
522
|
x: finalGutter,
|
|
@@ -536,7 +535,7 @@ export function resolveLayoutPlan(
|
|
|
536
535
|
}
|
|
537
536
|
}
|
|
538
537
|
const titleFontSize = theme.fonts.sizes.body;
|
|
539
|
-
const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width);
|
|
538
|
+
const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width, yIsInline);
|
|
540
539
|
const halfGlyph = Math.ceil(titleFontSize / 2);
|
|
541
540
|
const rotatedLabelMargin =
|
|
542
541
|
offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
|
|
@@ -209,9 +209,9 @@ describe('compileTable', () => {
|
|
|
209
209
|
it('includes resolved animation when spec has animation: true', () => {
|
|
210
210
|
const layout = compileTable({ ...baseSpec, animation: true }, baseOptions);
|
|
211
211
|
expect(layout.animation).toBeDefined();
|
|
212
|
-
expect(layout.animation!.
|
|
213
|
-
expect(layout.animation!.duration).toBe(500);
|
|
214
|
-
expect(layout.animation!.staggerDelay).toBe(80);
|
|
212
|
+
expect(layout.animation!.enter).toBeDefined();
|
|
213
|
+
expect(layout.animation!.enter!.duration).toBe(500);
|
|
214
|
+
expect(layout.animation!.enter!.staggerDelay).toBe(80);
|
|
215
215
|
});
|
|
216
216
|
|
|
217
217
|
it('includes resolved animation with custom config', () => {
|
|
@@ -220,8 +220,8 @@ describe('compileTable', () => {
|
|
|
220
220
|
baseOptions,
|
|
221
221
|
);
|
|
222
222
|
expect(layout.animation).toBeDefined();
|
|
223
|
-
expect(layout.animation!.duration).toBe(800);
|
|
224
|
-
expect(layout.animation!.ease).toBe('snappy');
|
|
223
|
+
expect(layout.animation!.enter!.duration).toBe(800);
|
|
224
|
+
expect(layout.animation!.enter!.ease).toBe('snappy');
|
|
225
225
|
});
|
|
226
226
|
|
|
227
227
|
it('does not include animation when spec omits it', () => {
|
|
@@ -38,6 +38,9 @@ import {
|
|
|
38
38
|
computeChrome,
|
|
39
39
|
estimateTextWidth,
|
|
40
40
|
formatNumber,
|
|
41
|
+
getBreakpoint,
|
|
42
|
+
HPAD_COMPACT_FRACTION,
|
|
43
|
+
HPAD_COMPACT_MIN,
|
|
41
44
|
pickLabelColor,
|
|
42
45
|
resolveTheme,
|
|
43
46
|
SEQUENTIAL_PALETTES,
|
|
@@ -59,6 +62,10 @@ const LEGEND_SWATCH_SIZE = 10;
|
|
|
59
62
|
const LEGEND_SWATCH_GAP = 6;
|
|
60
63
|
const LEGEND_ENTRY_GAP = 16;
|
|
61
64
|
|
|
65
|
+
function clamp(value: number, min: number, max: number): number {
|
|
66
|
+
return Math.min(max, Math.max(min, value));
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
// ---------------------------------------------------------------------------
|
|
63
70
|
// Public API
|
|
64
71
|
// ---------------------------------------------------------------------------
|
|
@@ -118,7 +125,15 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
|
|
|
118
125
|
);
|
|
119
126
|
|
|
120
127
|
// 4. Compute drawing area (total space minus chrome)
|
|
121
|
-
|
|
128
|
+
// On compact (mobile) widths, reclaim horizontal space by shrinking the
|
|
129
|
+
// padding — every reclaimed pixel widens the tiles, which are width-bound at
|
|
130
|
+
// these sizes. Mirrors how the chart pipeline uses HPAD_COMPACT_* on narrow
|
|
131
|
+
// viewports. The tile-area, chrome, and content-height math all read this one
|
|
132
|
+
// value, so overriding it here keeps every derived measurement consistent.
|
|
133
|
+
const padding =
|
|
134
|
+
getBreakpoint(options.width) === 'compact'
|
|
135
|
+
? Math.max(HPAD_COMPACT_MIN, Math.round(theme.spacing.padding * HPAD_COMPACT_FRACTION))
|
|
136
|
+
: theme.spacing.padding;
|
|
122
137
|
const fullArea = {
|
|
123
138
|
x: padding,
|
|
124
139
|
y: padding + chrome.topHeight,
|
|
@@ -269,7 +284,9 @@ function compileQuantitative(
|
|
|
269
284
|
};
|
|
270
285
|
|
|
271
286
|
const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(tilemapSpec.animation);
|
|
272
|
-
|
|
287
|
+
// fullArea.x is the (possibly compact-reduced) padding chosen in compileTileMap;
|
|
288
|
+
// reuse it so contentHeight matches the drawing area exactly.
|
|
289
|
+
const padding = fullArea.x;
|
|
273
290
|
const contentHeight =
|
|
274
291
|
tileGridOffsetY +
|
|
275
292
|
tilePositions.gridHeight +
|
|
@@ -354,11 +371,18 @@ function compileCategorical(
|
|
|
354
371
|
}
|
|
355
372
|
|
|
356
373
|
const showLegend = tilemapSpec.legend?.show !== false;
|
|
374
|
+
const legendLabelFontSize = 11;
|
|
357
375
|
const legendRowHeight = LEGEND_SWATCH_SIZE + 6;
|
|
358
|
-
const
|
|
376
|
+
const legendLabels = categories.map(formatCategoryLabel);
|
|
359
377
|
|
|
378
|
+
// Estimate legend rows against the full width to reserve tile-area height.
|
|
379
|
+
// The renderer wraps against the (narrower) grid width, so we recompute the
|
|
380
|
+
// real row count below and use that for final placement — never under-reserve.
|
|
360
381
|
const legendGap = showLegend ? 8 : 0;
|
|
361
|
-
const
|
|
382
|
+
const estimatedRows = showLegend
|
|
383
|
+
? countLegendRows(legendLabels, fullArea.width, legendLabelFontSize)
|
|
384
|
+
: 0;
|
|
385
|
+
const tileAreaHeight = fullArea.height - legendRowHeight * estimatedRows - legendGap;
|
|
362
386
|
const tilePositions = computeTilePositions(fullArea.width, tileAreaHeight, 5);
|
|
363
387
|
|
|
364
388
|
const tileGridOffsetX = fullArea.x + (fullArea.width - tilePositions.gridWidth) / 2;
|
|
@@ -367,6 +391,13 @@ function compileCategorical(
|
|
|
367
391
|
const legendY = tileGridOffsetY + tilePositions.gridHeight + legendGap;
|
|
368
392
|
const legendWidth = tilePositions.gridWidth;
|
|
369
393
|
|
|
394
|
+
// Reserve height for every wrapped row so a legend that spills onto a second
|
|
395
|
+
// line (common on narrow mobile widths) doesn't collide with the source text.
|
|
396
|
+
const legendRows = showLegend
|
|
397
|
+
? countLegendRows(legendLabels, legendWidth, legendLabelFontSize)
|
|
398
|
+
: 0;
|
|
399
|
+
const legendTotalHeight = showLegend ? legendRowHeight * legendRows : 0;
|
|
400
|
+
|
|
370
401
|
const neutralFill = isDarkMode ? '#1e2a30' : '#e0e0e0';
|
|
371
402
|
const neutralStroke = isDarkMode ? 'rgba(255,255,255,0.08)' : '#d0d0d0';
|
|
372
403
|
|
|
@@ -411,7 +442,7 @@ function compileCategorical(
|
|
|
411
442
|
if (showLegend) {
|
|
412
443
|
const labelStyle: TextStyle = {
|
|
413
444
|
fontFamily: theme.fonts.family,
|
|
414
|
-
fontSize:
|
|
445
|
+
fontSize: legendLabelFontSize,
|
|
415
446
|
fontWeight: 400,
|
|
416
447
|
fill: theme.colors.text,
|
|
417
448
|
lineHeight: 1.2,
|
|
@@ -426,7 +457,7 @@ function compileCategorical(
|
|
|
426
457
|
categoricalLegend = {
|
|
427
458
|
type: 'categorical',
|
|
428
459
|
position: 'bottom',
|
|
429
|
-
bounds: { x: legendX, y: legendY, width: legendWidth, height:
|
|
460
|
+
bounds: { x: legendX, y: legendY, width: legendWidth, height: legendTotalHeight },
|
|
430
461
|
labelStyle,
|
|
431
462
|
entries,
|
|
432
463
|
swatchSize: LEGEND_SWATCH_SIZE,
|
|
@@ -446,7 +477,9 @@ function compileCategorical(
|
|
|
446
477
|
};
|
|
447
478
|
|
|
448
479
|
const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(tilemapSpec.animation);
|
|
449
|
-
|
|
480
|
+
// fullArea.x is the (possibly compact-reduced) padding chosen in compileTileMap;
|
|
481
|
+
// reuse it so contentHeight matches the drawing area exactly.
|
|
482
|
+
const padding = fullArea.x;
|
|
450
483
|
const contentHeight =
|
|
451
484
|
tileGridOffsetY +
|
|
452
485
|
tilePositions.gridHeight +
|
|
@@ -518,9 +551,20 @@ function buildTileMark(opts: TileMarkOptions): TileMapTileMark {
|
|
|
518
551
|
const textColor = hasData ? pickLabelColor(fill, isDarkMode) : '#ffffff';
|
|
519
552
|
const isLightText = textColor === '#ffffff';
|
|
520
553
|
|
|
554
|
+
// A two-line layout (code over value) only fits once tiles are large enough;
|
|
555
|
+
// below that we drop the value and center the code so nothing overflows.
|
|
556
|
+
const showValue = tileSize >= 24;
|
|
557
|
+
|
|
558
|
+
// Scale the code font with the tile so a 2-char label always fits its box,
|
|
559
|
+
// rather than snapping between two fixed sizes. A 2-char string is ~1.2x the
|
|
560
|
+
// font size wide; capping font at ~0.5x tile width keeps it inside the tile
|
|
561
|
+
// with margin, then clamp to a legible [6.5, 11] range.
|
|
562
|
+
const codeFontSize = clamp(Math.round(tileSize * 0.5 * 10) / 10, 6.5, 11);
|
|
563
|
+
const valueFontSize = clamp(Math.round(tileSize * 0.42 * 10) / 10, 6.5, 10);
|
|
564
|
+
|
|
521
565
|
const labelStyle: TextStyle = {
|
|
522
566
|
fontFamily: theme.fonts.family,
|
|
523
|
-
fontSize:
|
|
567
|
+
fontSize: codeFontSize,
|
|
524
568
|
fontWeight: 700,
|
|
525
569
|
fill: textColor,
|
|
526
570
|
lineHeight: 1.2,
|
|
@@ -528,22 +572,27 @@ function buildTileMark(opts: TileMarkOptions): TileMapTileMark {
|
|
|
528
572
|
|
|
529
573
|
const valueLabelStyle: TextStyle = {
|
|
530
574
|
fontFamily: theme.fonts.family,
|
|
531
|
-
fontSize:
|
|
575
|
+
fontSize: valueFontSize,
|
|
532
576
|
fontWeight: 300,
|
|
533
577
|
fill: isLightText ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.45)',
|
|
534
578
|
lineHeight: 1.2,
|
|
535
579
|
};
|
|
536
580
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
581
|
+
// Code sits in the upper third when paired with a value, otherwise centered.
|
|
582
|
+
const codeCenterY = showValue ? tileTopY + tileSize * 0.28 : tileTopY + tileSize * 0.5;
|
|
583
|
+
|
|
584
|
+
const valueLabel = showValue
|
|
585
|
+
? {
|
|
586
|
+
text: formattedValue,
|
|
587
|
+
x: tileCenterX,
|
|
588
|
+
y: tileTopY + tileSize * 0.78,
|
|
589
|
+
style: valueLabelStyle,
|
|
590
|
+
visible: true,
|
|
591
|
+
}
|
|
592
|
+
: { text: '', x: 0, y: 0, style: valueLabelStyle, visible: false };
|
|
593
|
+
|
|
594
|
+
// Keep corners visibly rounded-rectangular, never near-circular on small tiles.
|
|
595
|
+
const cornerRadius = Math.min(TILE_CORNER_RADIUS, Math.round(tileSize * 0.22));
|
|
547
596
|
|
|
548
597
|
const data: Record<string, unknown> = {
|
|
549
598
|
state: stateCode,
|
|
@@ -564,14 +613,14 @@ function buildTileMark(opts: TileMarkOptions): TileMapTileMark {
|
|
|
564
613
|
fillOpacity,
|
|
565
614
|
stroke,
|
|
566
615
|
strokeWidth: TILE_STROKE_WIDTH,
|
|
567
|
-
cornerRadius
|
|
616
|
+
cornerRadius,
|
|
568
617
|
value,
|
|
569
618
|
formattedValue,
|
|
570
619
|
hasData,
|
|
571
620
|
label: {
|
|
572
621
|
text: stateCode,
|
|
573
622
|
x: tileCenterX,
|
|
574
|
-
y:
|
|
623
|
+
y: codeCenterY,
|
|
575
624
|
style: labelStyle,
|
|
576
625
|
visible: true,
|
|
577
626
|
},
|
|
@@ -617,6 +666,29 @@ function formatCategoryLabel(category: string): string {
|
|
|
617
666
|
return category.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
618
667
|
}
|
|
619
668
|
|
|
669
|
+
/**
|
|
670
|
+
* Count how many rows a horizontal categorical legend wraps into for a given
|
|
671
|
+
* available width. Mirrors the wrap logic in the legend renderer so the compiler
|
|
672
|
+
* can reserve enough vertical space and avoid the legend overlapping the source
|
|
673
|
+
* line on narrow (mobile) containers.
|
|
674
|
+
*/
|
|
675
|
+
function countLegendRows(labels: string[], availableWidth: number, fontSize: number): number {
|
|
676
|
+
if (labels.length === 0) return 0;
|
|
677
|
+
let rows = 1;
|
|
678
|
+
let offsetX = 0;
|
|
679
|
+
for (let i = 0; i < labels.length; i++) {
|
|
680
|
+
const labelWidth = estimateTextWidth(labels[i], fontSize);
|
|
681
|
+
const entryWidth = LEGEND_SWATCH_SIZE + LEGEND_SWATCH_GAP + labelWidth + LEGEND_ENTRY_GAP;
|
|
682
|
+
if (i > 0 && offsetX + entryWidth > availableWidth) {
|
|
683
|
+
rows++;
|
|
684
|
+
offsetX = entryWidth;
|
|
685
|
+
} else {
|
|
686
|
+
offsetX += entryWidth;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return rows;
|
|
690
|
+
}
|
|
691
|
+
|
|
620
692
|
// ---------------------------------------------------------------------------
|
|
621
693
|
// Empty layout fallback
|
|
622
694
|
// ---------------------------------------------------------------------------
|