@opendata-ai/openchart-engine 2.10.0 → 2.12.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.
@@ -0,0 +1,104 @@
1
+ import type { RectMark } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { computeColumnLabels } from '../labels';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Fixtures
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const chartArea = { x: 0, y: 0, width: 400, height: 300 };
10
+
11
+ function makeMark(index: number, value: number): RectMark {
12
+ const height = Math.abs(value) * 5;
13
+ const y = value >= 0 ? 300 - height : 300;
14
+ return {
15
+ type: 'rect',
16
+ x: index * 80,
17
+ y,
18
+ width: 60,
19
+ height,
20
+ fill: '#4e79a7',
21
+ data: { category: `Cat${index}`, value },
22
+ aria: { label: `Cat${index}: ${value}` },
23
+ };
24
+ }
25
+
26
+ const marks: RectMark[] = [
27
+ makeMark(0, 10),
28
+ makeMark(1, 20),
29
+ makeMark(2, 30),
30
+ makeMark(3, 40),
31
+ makeMark(4, 50),
32
+ ];
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Tests
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe('computeColumnLabels density modes', () => {
39
+ it('density "auto" runs collision detection and produces labels', () => {
40
+ const labels = computeColumnLabels(marks, chartArea, 'auto');
41
+ expect(labels.length).toBeGreaterThan(0);
42
+ expect(labels.every((l) => typeof l.visible === 'boolean')).toBe(true);
43
+ });
44
+
45
+ it('density "all" shows every label as visible', () => {
46
+ const labels = computeColumnLabels(marks, chartArea, 'all');
47
+ expect(labels).toHaveLength(marks.length);
48
+ expect(labels.every((l) => l.visible === true)).toBe(true);
49
+ });
50
+
51
+ it('density "none" returns empty array', () => {
52
+ const labels = computeColumnLabels(marks, chartArea, 'none');
53
+ expect(labels).toHaveLength(0);
54
+ });
55
+
56
+ it('density "endpoints" returns only first and last labels', () => {
57
+ const labels = computeColumnLabels(marks, chartArea, 'endpoints');
58
+ expect(labels).toHaveLength(2);
59
+ expect(labels[0].text).toBe('10');
60
+ expect(labels[1].text).toBe('50');
61
+ });
62
+
63
+ it('density "endpoints" with single mark returns that mark', () => {
64
+ const labels = computeColumnLabels([marks[0]], chartArea, 'endpoints');
65
+ expect(labels).toHaveLength(1);
66
+ expect(labels[0].text).toBe('10');
67
+ });
68
+
69
+ it('default density is "auto"', () => {
70
+ const withAuto = computeColumnLabels(marks, chartArea, 'auto');
71
+ const withDefault = computeColumnLabels(marks, chartArea);
72
+ expect(withDefault.length).toBe(withAuto.length);
73
+ });
74
+ });
75
+
76
+ describe('computeColumnLabels positioning', () => {
77
+ it('places positive value labels above the column', () => {
78
+ const labels = computeColumnLabels([makeMark(0, 20)], chartArea, 'all');
79
+ expect(labels).toHaveLength(1);
80
+ const mark = makeMark(0, 20);
81
+ // Label y should be above the column top
82
+ expect(labels[0].y).toBeLessThan(mark.y);
83
+ });
84
+
85
+ it('places negative value labels below the column', () => {
86
+ const negativeMark = makeMark(0, -15);
87
+ const labels = computeColumnLabels([negativeMark], chartArea, 'all');
88
+ expect(labels).toHaveLength(1);
89
+ // Label y should be below the column bottom
90
+ expect(labels[0].y).toBeGreaterThan(negativeMark.y + negativeMark.height);
91
+ });
92
+
93
+ it('centers labels horizontally on the column', () => {
94
+ const labels = computeColumnLabels([makeMark(0, 20)], chartArea, 'all');
95
+ const mark = makeMark(0, 20);
96
+ const markCenter = mark.x + mark.width / 2;
97
+ expect(labels[0].x).toBe(markCenter);
98
+ });
99
+
100
+ it('applies labelFormat to numeric values', () => {
101
+ const labels = computeColumnLabels([makeMark(0, 1234)], chartArea, 'all', ',.0f');
102
+ expect(labels[0].text).toBe('1,234');
103
+ });
104
+ });
@@ -0,0 +1,98 @@
1
+ import type { PointMark } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { computeDotLabels } from '../labels';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Fixtures
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const chartArea = { x: 0, y: 0, width: 400, height: 300 };
10
+
11
+ function makeMark(index: number, value: number): PointMark {
12
+ return {
13
+ type: 'point',
14
+ cx: value * 5,
15
+ cy: index * 40 + 20,
16
+ r: 6,
17
+ fill: '#4e79a7',
18
+ stroke: '#4e79a7',
19
+ strokeWidth: 1,
20
+ data: { category: `Cat${index}`, value },
21
+ aria: { label: `Cat${index}: ${value}` },
22
+ };
23
+ }
24
+
25
+ const marks: PointMark[] = [
26
+ makeMark(0, 10),
27
+ makeMark(1, 20),
28
+ makeMark(2, 30),
29
+ makeMark(3, 40),
30
+ makeMark(4, 50),
31
+ ];
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Tests
35
+ // ---------------------------------------------------------------------------
36
+
37
+ describe('computeDotLabels density modes', () => {
38
+ it('density "auto" runs collision detection and produces labels', () => {
39
+ const labels = computeDotLabels(marks, chartArea, 'auto');
40
+ expect(labels.length).toBeGreaterThan(0);
41
+ expect(labels.every((l) => typeof l.visible === 'boolean')).toBe(true);
42
+ });
43
+
44
+ it('density "all" shows every label as visible', () => {
45
+ const labels = computeDotLabels(marks, chartArea, 'all');
46
+ expect(labels).toHaveLength(marks.length);
47
+ expect(labels.every((l) => l.visible === true)).toBe(true);
48
+ });
49
+
50
+ it('density "none" returns empty array', () => {
51
+ const labels = computeDotLabels(marks, chartArea, 'none');
52
+ expect(labels).toHaveLength(0);
53
+ });
54
+
55
+ it('density "endpoints" returns only first and last labels', () => {
56
+ const labels = computeDotLabels(marks, chartArea, 'endpoints');
57
+ expect(labels).toHaveLength(2);
58
+ expect(labels[0].text).toBe('10');
59
+ expect(labels[1].text).toBe('50');
60
+ });
61
+
62
+ it('density "endpoints" with single mark returns that mark', () => {
63
+ const labels = computeDotLabels([marks[0]], chartArea, 'endpoints');
64
+ expect(labels).toHaveLength(1);
65
+ expect(labels[0].text).toBe('10');
66
+ });
67
+
68
+ it('default density is "auto"', () => {
69
+ const withAuto = computeDotLabels(marks, chartArea, 'auto');
70
+ const withDefault = computeDotLabels(marks, chartArea);
71
+ expect(withDefault.length).toBe(withAuto.length);
72
+ });
73
+ });
74
+
75
+ describe('computeDotLabels positioning', () => {
76
+ it('places labels to the right of the dot', () => {
77
+ const labels = computeDotLabels([marks[0]], chartArea, 'all');
78
+ expect(labels).toHaveLength(1);
79
+ // Label x should be to the right of the dot center + radius
80
+ expect(labels[0].x).toBeGreaterThan(marks[0].cx + marks[0].r);
81
+ });
82
+
83
+ it('vertically centers labels on the dot', () => {
84
+ const labels = computeDotLabels([marks[0]], chartArea, 'all');
85
+ const textHeight = 11 * 1.2; // LABEL_FONT_SIZE * 1.2
86
+ // Label y should be roughly centered on the dot's cy
87
+ expect(labels[0].y).toBeCloseTo(marks[0].cy - textHeight / 2, 0);
88
+ });
89
+
90
+ it('returns empty for marks with no parseable value', () => {
91
+ const badMark: PointMark = {
92
+ ...marks[0],
93
+ aria: { label: 'no-colon-here' },
94
+ };
95
+ const labels = computeDotLabels([badMark], chartArea, 'all');
96
+ expect(labels).toHaveLength(0);
97
+ });
98
+ });
@@ -0,0 +1,132 @@
1
+ import type { ArcMark } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { computePieLabels } from '../labels';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Fixtures
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const chartArea = { x: 0, y: 0, width: 400, height: 400 };
10
+ const center = { x: 200, y: 200 };
11
+ const outerRadius = 150;
12
+
13
+ function makeArc(category: string, value: number, startAngle: number, endAngle: number): ArcMark {
14
+ const midAngle = (startAngle + endAngle) / 2;
15
+ const centroidRadius = outerRadius * 0.6;
16
+ return {
17
+ type: 'arc',
18
+ path: '', // SVG path not needed for label computation
19
+ centroid: {
20
+ x: center.x + Math.sin(midAngle) * centroidRadius,
21
+ y: center.y - Math.cos(midAngle) * centroidRadius,
22
+ },
23
+ center,
24
+ innerRadius: 0,
25
+ outerRadius,
26
+ startAngle,
27
+ endAngle,
28
+ fill: '#4e79a7',
29
+ stroke: '#ffffff',
30
+ strokeWidth: 2,
31
+ data: { category, value },
32
+ aria: { label: `${category}: ${value} (${Math.round(value)}%)` },
33
+ };
34
+ }
35
+
36
+ // Three slices: top-right, bottom-right, left
37
+ const marks: ArcMark[] = [
38
+ makeArc('Alpha', 50, 0, Math.PI * 0.8),
39
+ makeArc('Beta', 30, Math.PI * 0.8, Math.PI * 1.4),
40
+ makeArc('Gamma', 20, Math.PI * 1.4, Math.PI * 2),
41
+ ];
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Tests
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe('computePieLabels density modes', () => {
48
+ it('density "auto" runs collision detection and produces labels', () => {
49
+ const labels = computePieLabels(marks, chartArea, 'auto');
50
+ expect(labels.length).toBeGreaterThan(0);
51
+ expect(labels.every((l) => typeof l.visible === 'boolean')).toBe(true);
52
+ });
53
+
54
+ it('density "all" shows every label as visible', () => {
55
+ const labels = computePieLabels(marks, chartArea, 'all');
56
+ expect(labels).toHaveLength(marks.length);
57
+ expect(labels.every((l) => l.visible === true)).toBe(true);
58
+ });
59
+
60
+ it('density "none" returns empty array', () => {
61
+ const labels = computePieLabels(marks, chartArea, 'none');
62
+ expect(labels).toHaveLength(0);
63
+ });
64
+
65
+ it('density "endpoints" returns only first and last labels', () => {
66
+ const labels = computePieLabels(marks, chartArea, 'endpoints');
67
+ expect(labels).toHaveLength(2);
68
+ expect(labels[0].text).toBe('Alpha');
69
+ expect(labels[1].text).toBe('Gamma');
70
+ });
71
+
72
+ it('density "endpoints" with single mark returns that mark', () => {
73
+ const labels = computePieLabels([marks[0]], chartArea, 'endpoints');
74
+ expect(labels).toHaveLength(1);
75
+ expect(labels[0].text).toBe('Alpha');
76
+ });
77
+
78
+ it('default density is "auto"', () => {
79
+ const withAuto = computePieLabels(marks, chartArea, 'auto');
80
+ const withDefault = computePieLabels(marks, chartArea);
81
+ expect(withDefault.length).toBe(withAuto.length);
82
+ });
83
+
84
+ it('returns empty for empty marks array', () => {
85
+ const labels = computePieLabels([], chartArea, 'all');
86
+ expect(labels).toHaveLength(0);
87
+ });
88
+ });
89
+
90
+ describe('computePieLabels positioning', () => {
91
+ it('labels use category name (not value) as text', () => {
92
+ const labels = computePieLabels(marks, chartArea, 'all');
93
+ expect(labels[0].text).toBe('Alpha');
94
+ expect(labels[1].text).toBe('Beta');
95
+ expect(labels[2].text).toBe('Gamma');
96
+ });
97
+
98
+ it('labels are positioned outside the outer radius', () => {
99
+ const labels = computePieLabels(marks, chartArea, 'all');
100
+ for (const label of labels) {
101
+ const dx = label.x - center.x;
102
+ const dy = label.y - center.y;
103
+ const dist = Math.sqrt(dx * dx + dy * dy);
104
+ // Label should be at least at the outer radius distance from center
105
+ // (accounting for text width offset, the anchor point may vary)
106
+ expect(dist).toBeGreaterThan(outerRadius * 0.5);
107
+ }
108
+ });
109
+
110
+ it('visible labels have connector lines to centroid', () => {
111
+ const labels = computePieLabels(marks, chartArea, 'all');
112
+ const visibleLabels = labels.filter((l) => l.visible);
113
+ for (const label of visibleLabels) {
114
+ expect(label.connector).toBeDefined();
115
+ expect(label.connector!.from).toEqual({ x: label.x, y: label.y });
116
+ expect(label.connector!.to).toBeDefined();
117
+ expect(label.connector!.stroke).toBeDefined();
118
+ }
119
+ });
120
+
121
+ it('right-side labels use "start" text anchor', () => {
122
+ // First mark (0 to 0.8*PI) has midAngle ~0.4*PI, sin > 0 => right side
123
+ const labels = computePieLabels([marks[0]], chartArea, 'all');
124
+ expect(labels[0].style.textAnchor).toBe('start');
125
+ });
126
+
127
+ it('left-side labels use "end" text anchor', () => {
128
+ // Third mark (1.4*PI to 2*PI) has midAngle ~1.7*PI, sin(1.7*PI) < 0 => left side
129
+ const labels = computePieLabels([marks[2]], chartArea, 'all');
130
+ expect(labels[0].style.textAnchor).toBe('end');
131
+ });
132
+ });
package/src/compile.ts CHANGED
@@ -25,6 +25,8 @@ import type {
25
25
  } from '@opendata-ai/openchart-core';
26
26
  import {
27
27
  adaptTheme,
28
+ BRAND_RESERVE_WIDTH,
29
+ computeLabelBounds,
28
30
  generateAltText,
29
31
  generateDataTable,
30
32
  getBreakpoint,
@@ -75,14 +77,41 @@ import { computeTooltipDescriptors } from './tooltips/compute';
75
77
  // ---------------------------------------------------------------------------
76
78
 
77
79
  /**
78
- * Compute per-row bounding rects for band-scale charts (dot, bar).
79
- * Each obstacle covers the full band height and x-range of marks in that row,
80
- * giving the annotation nudge system awareness of data marks.
80
+ * Compute bounding rects from marks to use as obstacles for annotation nudging.
81
+ *
82
+ * For band-scale charts (bar, dot): groups marks by band row and returns
83
+ * a single obstacle per row spanning the full band height and x-range.
84
+ *
85
+ * For other charts (column, scatter): returns individual mark bounds so
86
+ * annotations avoid overlapping any visible data mark.
81
87
  */
82
- function computeRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
83
- if (!scales.y || scales.y.type !== 'band') return [];
88
+ function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
89
+ // Band-scale y-axis: group marks by row for efficient obstacle computation
90
+ if (scales.y?.type === 'band') {
91
+ return computeBandRowObstacles(marks, scales);
92
+ }
93
+
94
+ // All other charts: use individual rect/point mark bounds as obstacles
95
+ const obstacles: Rect[] = [];
96
+ for (const mark of marks) {
97
+ if (mark.type === 'rect') {
98
+ const rm = mark as RectMark;
99
+ obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
100
+ } else if (mark.type === 'point') {
101
+ const pm = mark as PointMark;
102
+ obstacles.push({
103
+ x: pm.cx - pm.r,
104
+ y: pm.cy - pm.r,
105
+ width: pm.r * 2,
106
+ height: pm.r * 2,
107
+ });
108
+ }
109
+ }
110
+ return obstacles;
111
+ }
84
112
 
85
- // Group marks by their y-center (rounded), compute x-extent per group
113
+ /** Group band-scale marks by row, returning one obstacle per band. */
114
+ function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
86
115
  const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
87
116
 
88
117
  for (const mark of marks) {
@@ -116,7 +145,7 @@ function computeRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
116
145
  }
117
146
 
118
147
  // Get bandwidth from the band scale
119
- const bandScale = scales.y.scale as { bandwidth?: () => number };
148
+ const bandScale = scales.y!.scale as { bandwidth?: () => number };
120
149
  const bandwidth = bandScale.bandwidth?.() ?? 0;
121
150
  if (bandwidth === 0) return [];
122
151
 
@@ -315,7 +344,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
315
344
  // Compute axes (skip for radial charts)
316
345
  const axes = isRadial
317
346
  ? { x: undefined, y: undefined }
318
- : computeAxes(scales, chartArea, strategy, theme);
347
+ : computeAxes(scales, chartArea, strategy, theme, options.measureText);
319
348
 
320
349
  // Compute gridlines (stored in axes, used by adapters via axes.y.gridlines)
321
350
  if (!isRadial) {
@@ -326,12 +355,31 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
326
355
  const renderer = getChartRenderer(renderSpec.type);
327
356
  const marks: Mark[] = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
328
357
 
329
- // Compute annotations from spec, passing legend + mark bounds as obstacles for collision avoidance
358
+ // Compute annotations from spec, passing legend + mark + brand bounds as obstacles
330
359
  const obstacles: Rect[] = [];
331
360
  if (finalLegend.bounds.width > 0) {
332
361
  obstacles.push(finalLegend.bounds);
333
362
  }
334
- obstacles.push(...computeRowObstacles(marks, scales));
363
+ obstacles.push(...computeMarkObstacles(marks, scales));
364
+
365
+ // Add visible data label bounds as obstacles so annotations avoid overlapping them
366
+ for (const mark of marks) {
367
+ if (mark.type !== 'area' && mark.label?.visible) {
368
+ obstacles.push(computeLabelBounds(mark.label));
369
+ }
370
+ }
371
+
372
+ // Add brand watermark as an obstacle so annotations avoid overlapping it.
373
+ // The brand is right-aligned on the same baseline as the first bottom chrome element,
374
+ // offset below the chart area by x-axis extent (tick labels + axis title).
375
+ const brandPadding = theme.spacing.padding;
376
+ const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH;
377
+ const xAxisExtent = axes.x?.label ? 48 : axes.x ? 26 : 0;
378
+ const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
379
+ const brandY = firstBottomChrome
380
+ ? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y
381
+ : chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
382
+ obstacles.push({ x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH, height: 30 });
335
383
  const annotations: ResolvedAnnotation[] = computeAnnotations(
336
384
  chartSpec,
337
385
  scales,
@@ -11,6 +11,7 @@ import type {
11
11
  AxisTick,
12
12
  Gridline,
13
13
  LayoutStrategy,
14
+ MeasureTextFn,
14
15
  Rect,
15
16
  ResolvedTheme,
16
17
  TextStyle,
@@ -57,6 +58,15 @@ const HEIGHT_REDUCED_THRESHOLD = 200;
57
58
  const WIDTH_MINIMAL_THRESHOLD = 150;
58
59
  const WIDTH_REDUCED_THRESHOLD = 300;
59
60
 
61
+ /**
62
+ * Minimum gap between adjacent tick labels as a multiple of font size.
63
+ * At the default 12px axis font, this yields ~12px of breathing room.
64
+ */
65
+ const MIN_TICK_GAP_FACTOR = 1.0;
66
+
67
+ /** Always show at least this many ticks, even if they overlap. */
68
+ const MIN_TICK_COUNT = 2;
69
+
60
70
  /** Ordered densities from most to fewest ticks. */
61
71
  const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
62
72
 
@@ -95,25 +105,106 @@ export function effectiveDensity(
95
105
  return density;
96
106
  }
97
107
 
108
+ // ---------------------------------------------------------------------------
109
+ // Label overlap detection and thinning
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /** Measure a single label's width using real measurement or heuristic fallback. */
113
+ function measureLabel(
114
+ text: string,
115
+ fontSize: number,
116
+ fontWeight: number,
117
+ measureText?: MeasureTextFn,
118
+ ): number {
119
+ return measureText
120
+ ? measureText(text, fontSize, fontWeight).width
121
+ : estimateTextWidth(text, fontSize, fontWeight);
122
+ }
123
+
124
+ /** Check whether any adjacent tick labels overlap horizontally. */
125
+ export function ticksOverlap(
126
+ ticks: AxisTick[],
127
+ fontSize: number,
128
+ fontWeight: number,
129
+ measureText?: MeasureTextFn,
130
+ ): boolean {
131
+ if (ticks.length < 2) return false;
132
+ const minGap = fontSize * MIN_TICK_GAP_FACTOR;
133
+ for (let i = 0; i < ticks.length - 1; i++) {
134
+ const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText);
135
+ const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText);
136
+ const aRight = ticks[i].position + aWidth / 2;
137
+ const bLeft = ticks[i + 1].position - bWidth / 2;
138
+ if (aRight + minGap > bLeft) return true;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * Thin a tick array by removing every other tick until labels don't overlap.
145
+ * Always keeps first and last tick. O(log n) iterations max.
146
+ * Returns the original array if no thinning is needed.
147
+ */
148
+ export function thinTicksUntilFit(
149
+ ticks: AxisTick[],
150
+ fontSize: number,
151
+ fontWeight: number,
152
+ measureText?: MeasureTextFn,
153
+ ): AxisTick[] {
154
+ if (!ticksOverlap(ticks, fontSize, fontWeight, measureText)) return ticks;
155
+
156
+ let current = ticks;
157
+ while (current.length > MIN_TICK_COUNT) {
158
+ // Keep first, last, and every other tick in between
159
+ const thinned = [current[0]];
160
+ for (let i = 2; i < current.length - 1; i += 2) {
161
+ thinned.push(current[i]);
162
+ }
163
+ if (current.length > 1) thinned.push(current[current.length - 1]);
164
+ current = thinned;
165
+
166
+ if (!ticksOverlap(current, fontSize, fontWeight, measureText)) break;
167
+ }
168
+ return current;
169
+ }
170
+
98
171
  // ---------------------------------------------------------------------------
99
172
  // Tick generation
100
173
  // ---------------------------------------------------------------------------
101
174
 
102
175
  /** Generate ticks for a continuous scale (linear, time, log). */
103
- function continuousTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity): AxisTick[] {
176
+ function continuousTicks(
177
+ resolvedScale: ResolvedScale,
178
+ density: AxisLabelDensity,
179
+ fontSize: number,
180
+ fontWeight: number,
181
+ measureText?: MeasureTextFn,
182
+ ): AxisTick[] {
104
183
  const scale = resolvedScale.scale as D3ContinuousScale;
105
- const count = resolvedScale.channel.axis?.tickCount ?? TICK_COUNTS[density];
106
- const ticks: unknown[] = scale.ticks(count);
184
+ const explicitCount = resolvedScale.channel.axis?.tickCount;
185
+ const count = explicitCount ?? TICK_COUNTS[density];
186
+ const rawTicks: unknown[] = scale.ticks(count);
107
187
 
108
- return ticks.map((value: unknown) => ({
188
+ const ticks = rawTicks.map((value: unknown) => ({
109
189
  value,
110
190
  position: scale(value as number & Date) as number,
111
191
  label: formatTickLabel(value, resolvedScale),
112
192
  }));
193
+
194
+ // Respect explicit tickCount: user asked for this many, don't override
195
+ if (explicitCount) return ticks;
196
+
197
+ return thinTicksUntilFit(ticks, fontSize, fontWeight, measureText);
113
198
  }
114
199
 
115
200
  /** Generate ticks for a band/point/ordinal scale. */
116
- function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity): AxisTick[] {
201
+ function categoricalTicks(
202
+ resolvedScale: ResolvedScale,
203
+ density: AxisLabelDensity,
204
+ fontSize: number,
205
+ fontWeight: number,
206
+ measureText?: MeasureTextFn,
207
+ ): AxisTick[] {
117
208
  const scale = resolvedScale.scale as D3CategoricalScale;
118
209
  const domain: string[] = scale.domain();
119
210
  const explicitTickCount = resolvedScale.channel.axis?.tickCount;
@@ -127,7 +218,7 @@ function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensit
127
218
  selectedValues = domain.filter((_: string, i: number) => i % step === 0);
128
219
  }
129
220
 
130
- return selectedValues.map((value: string) => {
221
+ const ticks = selectedValues.map((value: string) => {
131
222
  // Band scales: use the center of the band
132
223
  const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
133
224
  const pos = bandScale
@@ -140,6 +231,13 @@ function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensit
140
231
  label: value,
141
232
  };
142
233
  });
234
+
235
+ // For non-band scales without explicit tickCount, thin based on label width
236
+ if (resolvedScale.type !== 'band' && !explicitTickCount) {
237
+ return thinTicksUntilFit(ticks, fontSize, fontWeight, measureText);
238
+ }
239
+
240
+ return ticks;
143
241
  }
144
242
 
145
243
  /** Format a tick value based on the scale type. */
@@ -182,12 +280,14 @@ export interface AxesResult {
182
280
  * @param chartArea - The chart drawing area.
183
281
  * @param strategy - Responsive layout strategy.
184
282
  * @param theme - Resolved theme for styling.
283
+ * @param measureText - Optional real text measurement from the adapter.
185
284
  */
186
285
  export function computeAxes(
187
286
  scales: ResolvedScales,
188
287
  chartArea: Rect,
189
288
  strategy: LayoutStrategy,
190
289
  theme: ResolvedTheme,
290
+ measureText?: MeasureTextFn,
191
291
  ): AxesResult {
192
292
  const result: AxesResult = {};
193
293
  const baseDensity = strategy.axisLabelDensity;
@@ -224,11 +324,14 @@ export function computeAxes(
224
324
  lineHeight: 1.3,
225
325
  };
226
326
 
327
+ const { fontSize } = tickLabelStyle;
328
+ const { fontWeight } = tickLabelStyle;
329
+
227
330
  if (scales.x) {
228
331
  const ticks =
229
332
  scales.x.type === 'band' || scales.x.type === 'point' || scales.x.type === 'ordinal'
230
- ? categoricalTicks(scales.x, xDensity)
231
- : continuousTicks(scales.x, xDensity);
333
+ ? categoricalTicks(scales.x, xDensity, fontSize, fontWeight, measureText)
334
+ : continuousTicks(scales.x, xDensity, fontSize, fontWeight, measureText);
232
335
 
233
336
  const gridlines: Gridline[] = ticks.map((t) => ({
234
337
  position: t.position,
@@ -242,11 +345,7 @@ export function computeAxes(
242
345
  const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
243
346
  let maxLabelWidth = 0;
244
347
  for (const t of ticks) {
245
- const w = estimateTextWidth(
246
- t.label,
247
- theme.fonts.sizes.axisTick,
248
- theme.fonts.weights.normal,
249
- );
348
+ const w = measureLabel(t.label, fontSize, fontWeight, measureText);
250
349
  if (w > maxLabelWidth) maxLabelWidth = w;
251
350
  }
252
351
  // If the widest label exceeds 85% of the bandwidth, rotate to avoid overlap
@@ -270,8 +369,8 @@ export function computeAxes(
270
369
  if (scales.y) {
271
370
  const ticks =
272
371
  scales.y.type === 'band' || scales.y.type === 'point' || scales.y.type === 'ordinal'
273
- ? categoricalTicks(scales.y, yDensity)
274
- : continuousTicks(scales.y, yDensity);
372
+ ? categoricalTicks(scales.y, yDensity, fontSize, fontWeight, measureText)
373
+ : continuousTicks(scales.y, yDensity, fontSize, fontWeight, measureText);
275
374
 
276
375
  const gridlines: Gridline[] = ticks.map((t) => ({
277
376
  position: t.position,
@@ -20,7 +20,7 @@ import type {
20
20
  ResolvedTheme,
21
21
  TextStyle,
22
22
  } from '@opendata-ai/openchart-core';
23
- import { estimateTextWidth } from '@opendata-ai/openchart-core';
23
+ import { BRAND_RESERVE_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
24
24
 
25
25
  import type { NormalizedChartSpec } from '../compiler/types';
26
26
 
@@ -248,8 +248,9 @@ export function computeLegend(
248
248
  };
249
249
  }
250
250
 
251
- // Top/bottom-positioned legend: horizontal flow with overflow protection
252
- const availableWidth = chartArea.width - LEGEND_PADDING * 2;
251
+ // Top/bottom-positioned legend: horizontal flow with overflow protection.
252
+ // Reserve space on the right so legend entries don't overlap the brand watermark.
253
+ const availableWidth = chartArea.width - LEGEND_PADDING * 2 - BRAND_RESERVE_WIDTH;
253
254
  const maxFit = entriesThatFit(entries, availableWidth, TOP_LEGEND_MAX_ROWS, labelStyle);
254
255
 
255
256
  if (maxFit < entries.length) {
@@ -291,7 +292,7 @@ export function computeLegend(
291
292
  (resolvedPosition === 'bottom'
292
293
  ? chartArea.y + chartArea.height - legendHeight
293
294
  : chartArea.y) + offsetDy,
294
- width: Math.min(totalWidth, chartArea.width),
295
+ width: Math.min(totalWidth, availableWidth),
295
296
  height: legendHeight,
296
297
  },
297
298
  labelStyle,