@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/compile.ts
CHANGED
|
@@ -32,7 +32,6 @@ import type {
|
|
|
32
32
|
} from '@opendata-ai/openchart-core';
|
|
33
33
|
import {
|
|
34
34
|
adaptTheme,
|
|
35
|
-
BRAND_RESERVE_WIDTH,
|
|
36
35
|
computeLabelBounds,
|
|
37
36
|
generateAltText,
|
|
38
37
|
generateDataTable,
|
|
@@ -42,57 +41,21 @@ import {
|
|
|
42
41
|
resolveTheme,
|
|
43
42
|
} from '@opendata-ai/openchart-core';
|
|
44
43
|
import { computeAnnotations } from './annotations/compute';
|
|
45
|
-
import
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
import
|
|
49
|
-
import { donutRenderer, pieRenderer } from './charts/pie';
|
|
44
|
+
// Side-effect import: registers all built-in chart renderers with the
|
|
45
|
+
// registry on module load. Tests that clear the registry can import
|
|
46
|
+
// `registerBuiltinRenderers` from `./charts/builtin` to restore defaults.
|
|
47
|
+
import './charts/builtin';
|
|
50
48
|
import {
|
|
51
49
|
assignAnimationIndices,
|
|
52
50
|
computeMarkObstacles,
|
|
53
51
|
resolveRendererKey,
|
|
54
52
|
} from './charts/post-process';
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
59
|
-
import { tickRenderer } from './charts/tick';
|
|
60
|
-
import { compile as compileSpec, flattenLayers } from './compiler/index';
|
|
61
|
-
|
|
62
|
-
// Register all built-in chart renderers under the new Vega-Lite mark type names.
|
|
63
|
-
// Explicit imports ensure bundlers cannot tree-shake the registrations away.
|
|
64
|
-
//
|
|
65
|
-
// Mark type mapping from old chart types:
|
|
66
|
-
// - 'bar' -> barRenderer (horizontal bars, old 'bar')
|
|
67
|
-
// - 'bar:vertical' is handled by columnRenderer (old 'column')
|
|
68
|
-
// - 'arc' -> pieRenderer (old 'pie'); donutRenderer is also registered
|
|
69
|
-
// - 'point' -> scatterRenderer (old 'scatter')
|
|
70
|
-
// - 'circle' -> dotRenderer (old 'dot')
|
|
71
|
-
// - 'line' and 'area' unchanged
|
|
72
|
-
// - 'text', 'rule', 'tick' are new Vega-Lite mark types
|
|
73
|
-
//
|
|
74
|
-
// For 'bar', orientation is resolved at compile time to dispatch to the right renderer.
|
|
75
|
-
// We register both barRenderer and columnRenderer; the compile function picks based on orientation.
|
|
76
|
-
const builtinRenderers: Record<string, ChartRenderer> = {
|
|
77
|
-
line: lineRenderer,
|
|
78
|
-
area: areaRenderer,
|
|
79
|
-
bar: barRenderer, // horizontal bars
|
|
80
|
-
'bar:vertical': columnRenderer, // vertical bars (old 'column')
|
|
81
|
-
point: scatterRenderer, // old 'scatter'
|
|
82
|
-
arc: pieRenderer, // old 'pie' (donut handled via innerRadius)
|
|
83
|
-
'arc:donut': donutRenderer, // old 'donut'
|
|
84
|
-
circle: dotRenderer, // old 'dot'
|
|
85
|
-
lollipop: dotRenderer, // semantic alias for dot/circle
|
|
86
|
-
text: textRenderer,
|
|
87
|
-
rule: ruleRenderer,
|
|
88
|
-
tick: tickRenderer,
|
|
89
|
-
rect: columnRenderer, // rect uses column renderer (RectMark output) as baseline for heatmaps
|
|
90
|
-
};
|
|
91
|
-
for (const [type, renderer] of Object.entries(builtinRenderers)) {
|
|
92
|
-
registerChartRenderer(type, renderer);
|
|
93
|
-
}
|
|
94
|
-
|
|
53
|
+
import { getChartRenderer } from './charts/registry';
|
|
54
|
+
import { applyColorScaleRange } from './compile/color-scale-range';
|
|
55
|
+
import { filterClippedDomains } from './compile/data-clip';
|
|
56
|
+
import { computeWatermarkObstacle } from './compile/watermark-obstacle';
|
|
95
57
|
import { resolveAnimation } from './compiler/animation';
|
|
58
|
+
import { compile as compileSpec, flattenLayers } from './compiler/index';
|
|
96
59
|
import type { NormalizedChartSpec, NormalizedTableSpec } from './compiler/types';
|
|
97
60
|
import { compileGraph as compileGraphImpl } from './graphs/compile-graph';
|
|
98
61
|
import type { GraphCompilation } from './graphs/types';
|
|
@@ -307,6 +270,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
307
270
|
theme = adaptTheme(theme);
|
|
308
271
|
}
|
|
309
272
|
|
|
273
|
+
// INVARIANT 1 — double legend pass: preliminaryArea → computeDimensions → legendArea → final
|
|
274
|
+
// legend. Breaks a dims/legend dependency cycle. Do not collapse into one call.
|
|
310
275
|
// Compute legend first (needs to reserve space)
|
|
311
276
|
const preliminaryArea: Rect = {
|
|
312
277
|
x: 0,
|
|
@@ -358,19 +323,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
358
323
|
}
|
|
359
324
|
|
|
360
325
|
// Filter clipped scale domains: when scale.clip is true, exclude rows outside the domain
|
|
361
|
-
|
|
362
|
-
const enc = chartSpec.encoding[channel];
|
|
363
|
-
if (!enc?.scale?.clip || !enc.scale.domain) continue;
|
|
364
|
-
const domain = enc.scale.domain;
|
|
365
|
-
const field = enc.field;
|
|
366
|
-
if (Array.isArray(domain) && domain.length === 2 && typeof domain[0] === 'number') {
|
|
367
|
-
const [lo, hi] = domain as [number, number];
|
|
368
|
-
renderData = renderData.filter((row) => {
|
|
369
|
-
const v = Number(row[field]);
|
|
370
|
-
return Number.isFinite(v) && v >= lo && v <= hi;
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
}
|
|
326
|
+
renderData = filterClippedDomains(renderData, chartSpec.encoding);
|
|
374
327
|
|
|
375
328
|
// Build a filtered spec for scales and marks, keeping all other properties intact
|
|
376
329
|
const renderSpec = renderData !== chartSpec.data ? { ...chartSpec, data: renderData } : chartSpec;
|
|
@@ -379,32 +332,11 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
379
332
|
const scales = computeScales(renderSpec, chartArea, renderSpec.data);
|
|
380
333
|
|
|
381
334
|
// Update color scale to use theme palette (only when user hasn't provided an explicit range)
|
|
382
|
-
|
|
383
|
-
const hasExplicitRange = !!(
|
|
384
|
-
renderSpec.encoding.color &&
|
|
385
|
-
'field' in renderSpec.encoding.color &&
|
|
386
|
-
(renderSpec.encoding.color.scale?.range as string[] | undefined)?.length
|
|
387
|
-
);
|
|
388
|
-
if (scales.color.type === 'sequential') {
|
|
389
|
-
// Sequential: use first sequential palette (or fall back to categorical endpoints)
|
|
390
|
-
if (!hasExplicitRange) {
|
|
391
|
-
const seqStops = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
|
|
392
|
-
(scales.color.scale as unknown as import('d3-scale').ScaleLinear<string, string>).range([
|
|
393
|
-
seqStops[0],
|
|
394
|
-
seqStops[seqStops.length - 1],
|
|
395
|
-
]);
|
|
396
|
-
}
|
|
397
|
-
} else {
|
|
398
|
-
if (!hasExplicitRange) {
|
|
399
|
-
(scales.color.scale as import('d3-scale').ScaleOrdinal<string, string>).range(
|
|
400
|
-
theme.colors.categorical,
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|
|
335
|
+
applyColorScaleRange(scales, renderSpec.encoding, theme);
|
|
405
336
|
|
|
406
|
-
//
|
|
407
|
-
//
|
|
337
|
+
// INVARIANT 3 — post-hoc defaultColor: must run AFTER computeScales since resolution needs
|
|
338
|
+
// theme context. Do not move into computeScales (would require threading theme through).
|
|
339
|
+
// If the user set a fill on the mark def, it takes priority over the theme's first categorical.
|
|
408
340
|
scales.defaultColor = chartSpec.markDef.fill ?? theme.colors.categorical[0];
|
|
409
341
|
|
|
410
342
|
// Arc charts (pie/donut) don't use axes or gridlines
|
|
@@ -415,7 +347,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
415
347
|
? { x: undefined, y: undefined }
|
|
416
348
|
: computeAxes(scales, chartArea, strategy, theme, options.measureText);
|
|
417
349
|
|
|
418
|
-
//
|
|
350
|
+
// INVARIANT 2 — computeGridlines mutates `axes` in place. Downstream consumers read
|
|
351
|
+
// axes.y.gridlines off the same object. Do not introduce a copy-on-write.
|
|
419
352
|
if (!isRadial) {
|
|
420
353
|
computeGridlines(axes, chartArea);
|
|
421
354
|
}
|
|
@@ -444,18 +377,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
444
377
|
}
|
|
445
378
|
|
|
446
379
|
// Add brand watermark as an obstacle so annotations avoid overlapping it.
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if (watermark) {
|
|
450
|
-
const brandPadding = theme.spacing.padding;
|
|
451
|
-
const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH;
|
|
452
|
-
const xAxisExtent = axes.x?.label ? 48 : axes.x ? 26 : 0;
|
|
453
|
-
const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
|
|
454
|
-
const brandY = firstBottomChrome
|
|
455
|
-
? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y
|
|
456
|
-
: chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
|
|
457
|
-
obstacles.push({ x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH, height: 30 });
|
|
458
|
-
}
|
|
380
|
+
const watermarkRect = computeWatermarkObstacle(dims, watermark, axes, theme);
|
|
381
|
+
if (watermarkRect) obstacles.push(watermarkRect);
|
|
459
382
|
const annotations: ResolvedAnnotation[] = computeAnnotations(
|
|
460
383
|
chartSpec,
|
|
461
384
|
scales,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tick label overlap detection and density thinning.
|
|
3
|
+
*
|
|
4
|
+
* Horizontal orientation (x-axis): checks label width against adjacent
|
|
5
|
+
* positions. Vertical orientation (y-axis): checks font-based label height
|
|
6
|
+
* against adjacent positions, ignoring text width so wide numeric labels
|
|
7
|
+
* don't trigger aggressive thinning.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AxisTick, MeasureTextFn } from '@opendata-ai/openchart-core';
|
|
11
|
+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Minimum gap between adjacent tick labels as a multiple of font size.
|
|
15
|
+
* At the default 12px axis font, this yields ~12px of breathing room.
|
|
16
|
+
*/
|
|
17
|
+
const MIN_TICK_GAP_FACTOR = 1.0;
|
|
18
|
+
|
|
19
|
+
/** Always show at least this many ticks, even if they overlap. */
|
|
20
|
+
const MIN_TICK_COUNT = 2;
|
|
21
|
+
|
|
22
|
+
/** Measure a single label's width using real measurement or heuristic fallback. */
|
|
23
|
+
export function measureLabel(
|
|
24
|
+
text: string,
|
|
25
|
+
fontSize: number,
|
|
26
|
+
fontWeight: number,
|
|
27
|
+
measureText?: MeasureTextFn,
|
|
28
|
+
): number {
|
|
29
|
+
return measureText
|
|
30
|
+
? measureText(text, fontSize, fontWeight).width
|
|
31
|
+
: estimateTextWidth(text, fontSize, fontWeight);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Check whether any adjacent tick labels overlap along the axis direction. */
|
|
35
|
+
export function ticksOverlap(
|
|
36
|
+
ticks: AxisTick[],
|
|
37
|
+
fontSize: number,
|
|
38
|
+
fontWeight: number,
|
|
39
|
+
measureText?: MeasureTextFn,
|
|
40
|
+
orientation: 'horizontal' | 'vertical' = 'horizontal',
|
|
41
|
+
): boolean {
|
|
42
|
+
if (ticks.length < 2) return false;
|
|
43
|
+
const minGap = fontSize * MIN_TICK_GAP_FACTOR;
|
|
44
|
+
|
|
45
|
+
if (orientation === 'vertical') {
|
|
46
|
+
// Y-axis: labels are stacked vertically. Check if vertical extent
|
|
47
|
+
// (based on font height) overlaps between adjacent ticks.
|
|
48
|
+
// Positions decrease going up in SVG coords, so sort ascending.
|
|
49
|
+
const sorted = [...ticks].sort((a, b) => a.position - b.position);
|
|
50
|
+
const labelHeight = fontSize * 1.2; // lineHeight
|
|
51
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
52
|
+
const aBottom = sorted[i].position + labelHeight / 2;
|
|
53
|
+
const bTop = sorted[i + 1].position - labelHeight / 2;
|
|
54
|
+
if (aBottom + minGap > bTop) return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < ticks.length - 1; i++) {
|
|
60
|
+
const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText);
|
|
61
|
+
const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText);
|
|
62
|
+
const aRight = ticks[i].position + aWidth / 2;
|
|
63
|
+
const bLeft = ticks[i + 1].position - bWidth / 2;
|
|
64
|
+
if (aRight + minGap > bLeft) return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Thin a tick array by removing every other tick until labels don't overlap.
|
|
71
|
+
* Always keeps first and last tick. O(log n) iterations max.
|
|
72
|
+
* Returns the original array if no thinning is needed.
|
|
73
|
+
*/
|
|
74
|
+
export function thinTicksUntilFit(
|
|
75
|
+
ticks: AxisTick[],
|
|
76
|
+
fontSize: number,
|
|
77
|
+
fontWeight: number,
|
|
78
|
+
measureText?: MeasureTextFn,
|
|
79
|
+
orientation: 'horizontal' | 'vertical' = 'horizontal',
|
|
80
|
+
): AxisTick[] {
|
|
81
|
+
if (!ticksOverlap(ticks, fontSize, fontWeight, measureText, orientation)) return ticks;
|
|
82
|
+
|
|
83
|
+
let current = ticks;
|
|
84
|
+
while (current.length > MIN_TICK_COUNT) {
|
|
85
|
+
// Keep first, last, and every other tick in between
|
|
86
|
+
const thinned = [current[0]];
|
|
87
|
+
for (let i = 2; i < current.length - 1; i += 2) {
|
|
88
|
+
thinned.push(current[i]);
|
|
89
|
+
}
|
|
90
|
+
if (current.length > 1) thinned.push(current[current.length - 1]);
|
|
91
|
+
current = thinned;
|
|
92
|
+
|
|
93
|
+
if (!ticksOverlap(current, fontSize, fontWeight, measureText, orientation)) break;
|
|
94
|
+
}
|
|
95
|
+
return current;
|
|
96
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tick generation: produces raw AxisTick[] from a resolved scale.
|
|
3
|
+
*
|
|
4
|
+
* Pure with respect to layout dimensions — positions come from the scale,
|
|
5
|
+
* not from the chart area. Density thinning lives in ./thinning.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AxisLabelDensity, AxisTick } from '@opendata-ai/openchart-core';
|
|
9
|
+
import {
|
|
10
|
+
abbreviateNumber,
|
|
11
|
+
buildD3Formatter,
|
|
12
|
+
buildTemporalFormatter,
|
|
13
|
+
formatDate,
|
|
14
|
+
formatNumber,
|
|
15
|
+
} from '@opendata-ai/openchart-core';
|
|
16
|
+
import type { ScaleBand } from 'd3-scale';
|
|
17
|
+
import type { D3CategoricalScale, D3ContinuousScale, ResolvedScale } from '../scales';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Target pixels-per-tick for continuous axes. The target count is computed as
|
|
21
|
+
* `axisLength / PX_PER_TICK[density]` and then clamped into the count range.
|
|
22
|
+
*
|
|
23
|
+
* Rationale:
|
|
24
|
+
* - Observable Plot uses 50px/tick on y, 80px/tick on x as its baseline.
|
|
25
|
+
* - ONS editorial guidance recommends 6-10 y-gridlines at desktop, 3-6 mobile.
|
|
26
|
+
* - The Economist / FT / NYT typically show 4-6 labeled y-ticks on finished charts.
|
|
27
|
+
*
|
|
28
|
+
* Y gets tighter spacing than X because vertical label extent is the font height
|
|
29
|
+
* (~14px) versus horizontal label extent which can be 60-100px for dates/abbreviated
|
|
30
|
+
* numbers. X uses wider spacing so labels don't need aggressive rotation or thinning.
|
|
31
|
+
*
|
|
32
|
+
* "full" is the publication-ready default; "reduced" and "minimal" step down as the
|
|
33
|
+
* responsive breakpoint system shifts to smaller containers.
|
|
34
|
+
*
|
|
35
|
+
* @internal — these are tuning constants, not part of the configuration API.
|
|
36
|
+
* Consumers should configure tick density through `axis.tickCount` on the spec.
|
|
37
|
+
*/
|
|
38
|
+
const Y_PX_PER_TICK: Record<AxisLabelDensity, number> = {
|
|
39
|
+
full: 55,
|
|
40
|
+
reduced: 90,
|
|
41
|
+
minimal: 140,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const X_PX_PER_TICK: Record<AxisLabelDensity, number> = {
|
|
45
|
+
full: 110,
|
|
46
|
+
reduced: 160,
|
|
47
|
+
minimal: 220,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Count clamps per density. The lower bound keeps a chart from collapsing to
|
|
52
|
+
* a single label on very short axes; the upper bound stops tall/wide charts
|
|
53
|
+
* from growing a ladder of ticks past the point of editorial usefulness.
|
|
54
|
+
*
|
|
55
|
+
* The upper bound is deliberately <=6 for y on standard tiers: D3's
|
|
56
|
+
* `scale.ticks(n)` only produces "nice" step sizes (1, 2, 5 × 10^k), and for
|
|
57
|
+
* many domains the jump from step=10 to step=5 happens between count 6 and 7.
|
|
58
|
+
* Requesting 7 can give back 10, which reads as visually dense. Capping at 6
|
|
59
|
+
* keeps the editorial ~5 gridline average regardless of domain shape.
|
|
60
|
+
*
|
|
61
|
+
* @internal — see PX_PER_TICK comment.
|
|
62
|
+
*/
|
|
63
|
+
const Y_TICK_COUNT_RANGE: Record<AxisLabelDensity, [number, number]> = {
|
|
64
|
+
full: [4, 6],
|
|
65
|
+
reduced: [3, 5],
|
|
66
|
+
minimal: [2, 3],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const X_TICK_COUNT_RANGE: Record<AxisLabelDensity, [number, number]> = {
|
|
70
|
+
full: [3, 6],
|
|
71
|
+
reduced: [3, 5],
|
|
72
|
+
minimal: [2, 3],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fallback tick counts for callers that don't have an axis length handy
|
|
77
|
+
* (categorical band-scale thinning uses this as a cap, and `continuousTicks`
|
|
78
|
+
* uses it when no `targetCount` is provided).
|
|
79
|
+
*
|
|
80
|
+
* @internal
|
|
81
|
+
*/
|
|
82
|
+
const TICK_COUNTS: Record<AxisLabelDensity, number> = {
|
|
83
|
+
full: 7,
|
|
84
|
+
reduced: 5,
|
|
85
|
+
minimal: 3,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compute a target tick count for a continuous axis from its pixel length and
|
|
90
|
+
* density tier. Uses the Plot-style pixels-per-tick heuristic, then clamps
|
|
91
|
+
* into the density's count range.
|
|
92
|
+
*/
|
|
93
|
+
export function targetTickCount(
|
|
94
|
+
axisLength: number,
|
|
95
|
+
density: AxisLabelDensity,
|
|
96
|
+
orientation: 'x' | 'y',
|
|
97
|
+
): number {
|
|
98
|
+
const pxPerTick = orientation === 'y' ? Y_PX_PER_TICK[density] : X_PX_PER_TICK[density];
|
|
99
|
+
const [min, max] =
|
|
100
|
+
orientation === 'y' ? Y_TICK_COUNT_RANGE[density] : X_TICK_COUNT_RANGE[density];
|
|
101
|
+
const raw = Math.round(axisLength / pxPerTick);
|
|
102
|
+
return Math.max(min, Math.min(max, raw));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Set of continuous numeric scale types that should format as numbers. */
|
|
106
|
+
const NUMERIC_SCALE_TYPES = new Set([
|
|
107
|
+
'linear',
|
|
108
|
+
'log',
|
|
109
|
+
'pow',
|
|
110
|
+
'sqrt',
|
|
111
|
+
'symlog',
|
|
112
|
+
'quantile',
|
|
113
|
+
'quantize',
|
|
114
|
+
'threshold',
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
/** Set of temporal scale types. */
|
|
118
|
+
const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
|
|
119
|
+
|
|
120
|
+
/** Format a tick value based on the scale type. */
|
|
121
|
+
function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
|
|
122
|
+
const formatStr = resolvedScale.channel.axis?.format;
|
|
123
|
+
|
|
124
|
+
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
125
|
+
const temporalFmt = buildTemporalFormatter(formatStr);
|
|
126
|
+
if (temporalFmt) return temporalFmt(value as Date);
|
|
127
|
+
const useUtc = resolvedScale.type === 'utc';
|
|
128
|
+
return formatDate(value as Date, undefined, undefined, useUtc);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
|
|
132
|
+
const num = value as number;
|
|
133
|
+
if (formatStr) {
|
|
134
|
+
const fmt = buildD3Formatter(formatStr);
|
|
135
|
+
if (fmt) return fmt(num);
|
|
136
|
+
}
|
|
137
|
+
// Abbreviate large numbers for axis labels
|
|
138
|
+
if (Math.abs(num) >= 1000) return abbreviateNumber(num);
|
|
139
|
+
return formatNumber(num);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return String(value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Generate ticks for a continuous scale (linear, time, log, pow, sqrt, symlog).
|
|
147
|
+
*
|
|
148
|
+
* `targetCount` lets callers that know the axis pixel length pass a
|
|
149
|
+
* density-appropriate count (see `targetTickCount`). When omitted, falls back
|
|
150
|
+
* to the coarse `TICK_COUNTS` tier, which is only used by tests and callers
|
|
151
|
+
* that don't have an axis length.
|
|
152
|
+
*/
|
|
153
|
+
export function continuousTicks(
|
|
154
|
+
resolvedScale: ResolvedScale,
|
|
155
|
+
density: AxisLabelDensity,
|
|
156
|
+
targetCount?: number,
|
|
157
|
+
): AxisTick[] {
|
|
158
|
+
const scale = resolvedScale.scale as D3ContinuousScale;
|
|
159
|
+
|
|
160
|
+
// Discretizing scales (quantile, quantize, threshold) don't have .ticks().
|
|
161
|
+
// Use their domain thresholds as ticks instead.
|
|
162
|
+
if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
|
|
163
|
+
const domain = scale.domain() as unknown[];
|
|
164
|
+
return domain.map((value: unknown) => ({
|
|
165
|
+
value,
|
|
166
|
+
position: (scale as D3ContinuousScale)(value as number & Date) as number,
|
|
167
|
+
label: formatTickLabel(value, resolvedScale),
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const explicitCount = resolvedScale.channel.axis?.tickCount;
|
|
172
|
+
const count = explicitCount ?? targetCount ?? TICK_COUNTS[density];
|
|
173
|
+
return buildContinuousTicks(resolvedScale, count);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build positioned, labeled ticks for a continuous scale at an exact count.
|
|
178
|
+
* Exposed so callers that need to re-request ticks at a lower count (for
|
|
179
|
+
* overlap-driven density adaptation) can regenerate without manual pruning.
|
|
180
|
+
* D3's `scale.ticks(n)` always returns evenly-spaced round values, so
|
|
181
|
+
* requesting a smaller `n` never produces squished neighbors — unlike
|
|
182
|
+
* "keep first+last, drop middle" pruning which can stack the last tick
|
|
183
|
+
* next to an endpoint and cascade to 2 ticks.
|
|
184
|
+
*/
|
|
185
|
+
export function buildContinuousTicks(resolvedScale: ResolvedScale, count: number): AxisTick[] {
|
|
186
|
+
const scale = resolvedScale.scale as D3ContinuousScale;
|
|
187
|
+
if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
|
|
188
|
+
return continuousTicks(resolvedScale, 'full');
|
|
189
|
+
}
|
|
190
|
+
const raw: unknown[] = scale.ticks(count);
|
|
191
|
+
return raw.map((value: unknown) => ({
|
|
192
|
+
value,
|
|
193
|
+
position: scale(value as number & Date) as number,
|
|
194
|
+
label: formatTickLabel(value, resolvedScale),
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** True if this scale supports regenerating ticks at an arbitrary count. */
|
|
199
|
+
export function scaleSupportsTickCount(resolvedScale: ResolvedScale): boolean {
|
|
200
|
+
const scale = resolvedScale.scale as D3ContinuousScale;
|
|
201
|
+
return 'ticks' in scale && typeof scale.ticks === 'function';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Generate ticks for a band/point/ordinal scale. */
|
|
205
|
+
export function categoricalTicks(
|
|
206
|
+
resolvedScale: ResolvedScale,
|
|
207
|
+
density: AxisLabelDensity,
|
|
208
|
+
): AxisTick[] {
|
|
209
|
+
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
210
|
+
const domain: string[] = scale.domain();
|
|
211
|
+
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
|
|
212
|
+
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
213
|
+
|
|
214
|
+
// Band scales (bar charts) show all category labels by default.
|
|
215
|
+
// Only thin when there's an explicit tickCount override or for point/ordinal scales.
|
|
216
|
+
let selectedValues = domain;
|
|
217
|
+
if ((resolvedScale.type !== 'band' || explicitTickCount) && domain.length > maxTicks) {
|
|
218
|
+
const step = Math.ceil(domain.length / maxTicks);
|
|
219
|
+
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const ticks = selectedValues.map((value: string) => {
|
|
223
|
+
// Band scales: use the center of the band
|
|
224
|
+
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
225
|
+
const pos = bandScale
|
|
226
|
+
? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2
|
|
227
|
+
: ((scale(value) as number | undefined) ?? 0);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
value,
|
|
231
|
+
position: pos,
|
|
232
|
+
label: value,
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return ticks;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Resolve explicit tick values from axis config into positioned ticks. */
|
|
240
|
+
export function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedScale): AxisTick[] {
|
|
241
|
+
const scale = resolvedScale.scale;
|
|
242
|
+
return values.map((value) => {
|
|
243
|
+
let position: number;
|
|
244
|
+
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
245
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
246
|
+
position = (scale as D3ContinuousScale)(d as number & Date) as number;
|
|
247
|
+
} else if (
|
|
248
|
+
resolvedScale.type === 'band' ||
|
|
249
|
+
resolvedScale.type === 'point' ||
|
|
250
|
+
resolvedScale.type === 'ordinal'
|
|
251
|
+
) {
|
|
252
|
+
const s = String(value);
|
|
253
|
+
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
254
|
+
position = bandScale
|
|
255
|
+
? (bandScale(s) ?? 0) + bandScale.bandwidth() / 2
|
|
256
|
+
: ((scale(s as string & number) as number | undefined) ?? 0);
|
|
257
|
+
} else {
|
|
258
|
+
position = (scale as D3ContinuousScale)(value as number & Date) as number;
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
value,
|
|
262
|
+
position,
|
|
263
|
+
label: formatTickLabel(value, resolvedScale),
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
}
|