@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.
Files changed (36) hide show
  1. package/dist/index.d.ts +6 -0
  2. package/dist/index.js +865 -3729
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +1989 -0
  6. package/src/__tests__/axes.test.ts +65 -0
  7. package/src/__tests__/compile-snapshot.test.ts +156 -0
  8. package/src/__tests__/legend.test.ts +39 -0
  9. package/src/charts/__tests__/registry.test.ts +6 -0
  10. package/src/charts/_shared/__tests__/density-filter.test.ts +32 -0
  11. package/src/charts/_shared/density-filter.ts +26 -0
  12. package/src/charts/_shared/format-label-value.ts +15 -0
  13. package/src/charts/bar/__tests__/gradient-orient.test.ts +127 -0
  14. package/src/charts/bar/compute.ts +6 -11
  15. package/src/charts/bar/labels.ts +4 -15
  16. package/src/charts/builtin.ts +64 -0
  17. package/src/charts/column/compute.ts +6 -11
  18. package/src/charts/column/labels.ts +4 -19
  19. package/src/charts/dot/labels.ts +4 -19
  20. package/src/charts/pie/labels.ts +4 -6
  21. package/src/charts/registry.ts +6 -0
  22. package/src/compile/__tests__/color-scale-range.test.ts +79 -0
  23. package/src/compile/__tests__/data-clip.test.ts +59 -0
  24. package/src/compile/__tests__/watermark-obstacle.test.ts +93 -0
  25. package/src/compile/color-scale-range.ts +38 -0
  26. package/src/compile/data-clip.ts +33 -0
  27. package/src/compile/watermark-obstacle.ts +54 -0
  28. package/src/compile.ts +20 -97
  29. package/src/layout/axes/thinning.ts +96 -0
  30. package/src/layout/axes/ticks.ts +266 -0
  31. package/src/layout/axes.ts +148 -249
  32. package/src/legend/compute.ts +6 -51
  33. package/src/legend/wrap.ts +94 -0
  34. package/src/sankey/__tests__/node-label-wrap.test.ts +114 -0
  35. package/src/sankey/__tests__/node-sort.test.ts +45 -0
  36. 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 { barRenderer } from './charts/bar';
46
- import { columnRenderer } from './charts/column';
47
- import { dotRenderer } from './charts/dot';
48
- import { areaRenderer, lineRenderer } from './charts/line';
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 { type ChartRenderer, getChartRenderer, registerChartRenderer } from './charts/registry';
56
- import { ruleRenderer } from './charts/rule';
57
- import { scatterRenderer } from './charts/scatter';
58
- import { textRenderer } from './charts/text';
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
- for (const channel of ['x', 'y'] as const) {
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
- if (scales.color) {
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
- // Set default color for single-series charts. If the user set a fill on the mark def
407
- // (string or gradient), that takes priority over the theme's first categorical color.
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
- // Compute gridlines (stored in axes, used by adapters via axes.y.gridlines)
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
- // The brand is right-aligned on the same baseline as the first bottom chrome element,
448
- // offset below the chart area by x-axis extent (tick labels + axis title).
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
+ }