Skip to content

Commit 93ceb2b

Browse files
committed
refactor(vanilla): extract renderAxes into renderers/axes.ts
1 parent 6e4c2db commit 93ceb2b

2 files changed

Lines changed: 165 additions & 163 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* Axis rendering: axis line, ticks, tick labels, gridlines, axis title.
3+
*/
4+
5+
import type { AxisLayout, ChartLayout } from '@opendata-ai/openchart-core';
6+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
7+
import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
8+
9+
function renderAxis(
10+
parent: SVGElement,
11+
axis: AxisLayout,
12+
orientation: 'x' | 'y',
13+
layout: ChartLayout,
14+
): void {
15+
const g = createSVGElement('g');
16+
g.setAttribute('class', `oc-axis oc-axis-${orientation}`);
17+
18+
const { area } = layout;
19+
20+
// Only draw axis line for x-axis (bottom baseline).
21+
// Horizontal gridlines already guide y-values, so the vertical y-axis line is redundant.
22+
if (orientation === 'x') {
23+
const line = createSVGElement('line');
24+
line.setAttribute('class', 'oc-axis-line');
25+
setAttrs(line, {
26+
x1: axis.start.x,
27+
y1: axis.start.y,
28+
x2: axis.end.x,
29+
y2: axis.end.y,
30+
stroke: layout.theme.colors.axis,
31+
'stroke-width': 1,
32+
});
33+
g.appendChild(line);
34+
}
35+
36+
// Ticks and labels
37+
// Tick positions are absolute pixel coordinates from D3 scales whose range
38+
// was set to [chartArea.x, chartArea.x + chartArea.width] (and similarly for y).
39+
// Don't add area.x/area.y again or you'll double-offset everything.
40+
for (const tick of axis.ticks) {
41+
if (orientation === 'x') {
42+
// Label (no tick marks -- gridlines provide sufficient reference)
43+
const label = createSVGElement('text');
44+
label.setAttribute('class', 'oc-axis-tick');
45+
46+
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
47+
// Rotated labels: anchor at the rotation pivot point
48+
const labelX = tick.position;
49+
const labelY = area.y + area.height + 6;
50+
setAttrs(label, {
51+
x: labelX,
52+
y: labelY,
53+
'text-anchor': axis.tickAngle < 0 ? 'end' : 'start',
54+
'dominant-baseline': 'central',
55+
transform: `rotate(${axis.tickAngle}, ${labelX}, ${labelY})`,
56+
});
57+
} else {
58+
setAttrs(label, {
59+
x: tick.position,
60+
y: area.y + area.height + 14,
61+
'text-anchor': 'middle',
62+
});
63+
}
64+
65+
applyTextStyle(label, axis.tickLabelStyle);
66+
label.textContent = tick.label;
67+
g.appendChild(label);
68+
} else {
69+
// Label (no tick marks -- gridlines provide sufficient reference)
70+
const label = createSVGElement('text');
71+
label.setAttribute('class', 'oc-axis-tick');
72+
setAttrs(label, {
73+
x: area.x - 6,
74+
y: tick.position,
75+
'text-anchor': 'end',
76+
'dominant-baseline': 'central',
77+
});
78+
applyTextStyle(label, axis.tickLabelStyle);
79+
label.textContent = tick.label;
80+
g.appendChild(label);
81+
}
82+
}
83+
84+
// Gridlines (positions are also absolute from the scales)
85+
for (const gridline of axis.gridlines) {
86+
const gl = createSVGElement('line');
87+
gl.setAttribute('class', 'oc-gridline');
88+
if (orientation === 'y') {
89+
setAttrs(gl, {
90+
x1: area.x,
91+
y1: gridline.position,
92+
x2: area.x + area.width,
93+
y2: gridline.position,
94+
stroke: layout.theme.colors.gridline,
95+
'stroke-width': 1,
96+
'stroke-opacity': 0.6,
97+
});
98+
} else {
99+
setAttrs(gl, {
100+
x1: gridline.position,
101+
y1: area.y,
102+
x2: gridline.position,
103+
y2: area.y + area.height,
104+
stroke: layout.theme.colors.gridline,
105+
'stroke-width': 1,
106+
'stroke-opacity': 0.6,
107+
});
108+
}
109+
g.appendChild(gl);
110+
}
111+
112+
// Axis label
113+
if (axis.label && axis.labelStyle) {
114+
const axisLabel = createSVGElement('text');
115+
axisLabel.setAttribute('class', 'oc-axis-title');
116+
applyTextStyle(axisLabel, axis.labelStyle);
117+
axisLabel.textContent = axis.label;
118+
119+
if (orientation === 'x') {
120+
// Position axis title below tick labels. For rotated labels, compute
121+
// the vertical extent of the rotated ticks and place the title below.
122+
let titleY = area.y + area.height + 35;
123+
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
124+
const angleRad = Math.abs(axis.tickAngle) * (Math.PI / 180);
125+
let maxLabelWidth = 40;
126+
for (const tick of axis.ticks) {
127+
const w = estimateTextWidth(
128+
tick.label,
129+
axis.tickLabelStyle.fontSize,
130+
axis.tickLabelStyle.fontWeight,
131+
);
132+
if (w > maxLabelWidth) maxLabelWidth = w;
133+
}
134+
const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
135+
titleY = area.y + area.height + rotatedHeight + 14;
136+
}
137+
setAttrs(axisLabel, {
138+
x: area.x + area.width / 2,
139+
y: titleY,
140+
'text-anchor': 'middle',
141+
});
142+
} else {
143+
// Rotated y-axis label
144+
setAttrs(axisLabel, {
145+
x: area.x - 45,
146+
y: area.y + area.height / 2,
147+
'text-anchor': 'middle',
148+
transform: `rotate(-90, ${area.x - 45}, ${area.y + area.height / 2})`,
149+
});
150+
}
151+
g.appendChild(axisLabel);
152+
}
153+
154+
parent.appendChild(g);
155+
}
156+
157+
export function renderAxes(parent: SVGElement, layout: ChartLayout): void {
158+
if (layout.axes.x) {
159+
renderAxis(parent, layout.axes.x, 'x', layout);
160+
}
161+
if (layout.axes.y) {
162+
renderAxis(parent, layout.axes.y, 'y', layout);
163+
}
164+
}

packages/vanilla/src/svg-renderer.ts

Lines changed: 1 addition & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import type {
1313
ArcMark,
1414
AreaMark,
15-
AxisLayout,
1615
ChartLayout,
1716
LineMark,
1817
Mark,
@@ -23,10 +22,10 @@ import type {
2322
TextMarkLayout,
2423
TickMarkLayout,
2524
} from '@opendata-ai/openchart-core';
26-
import { estimateTextWidth } from '@opendata-ai/openchart-core';
2725
import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
2826
import { buildGradientDefs, resolveMarkFill } from './gradient-utils';
2927
import { renderAnnotations } from './renderers/annotations';
28+
import { renderAxes } from './renderers/axes';
3029
import { renderBrand } from './renderers/brand';
3130
import { renderChrome } from './renderers/chrome';
3231
import { renderLegend } from './renderers/legend';
@@ -67,167 +66,6 @@ const EASE_VAR_MAP: Record<string, string> = {
6766
snappy: 'var(--oc-ease-snappy)',
6867
};
6968

70-
// ---------------------------------------------------------------------------
71-
// Axis rendering
72-
// ---------------------------------------------------------------------------
73-
74-
function renderAxis(
75-
parent: SVGElement,
76-
axis: AxisLayout,
77-
orientation: 'x' | 'y',
78-
layout: ChartLayout,
79-
): void {
80-
const g = createSVGElement('g');
81-
g.setAttribute('class', `oc-axis oc-axis-${orientation}`);
82-
83-
const { area } = layout;
84-
85-
// Only draw axis line for x-axis (bottom baseline).
86-
// Horizontal gridlines already guide y-values, so the vertical y-axis line is redundant.
87-
if (orientation === 'x') {
88-
const line = createSVGElement('line');
89-
line.setAttribute('class', 'oc-axis-line');
90-
setAttrs(line, {
91-
x1: axis.start.x,
92-
y1: axis.start.y,
93-
x2: axis.end.x,
94-
y2: axis.end.y,
95-
stroke: layout.theme.colors.axis,
96-
'stroke-width': 1,
97-
});
98-
g.appendChild(line);
99-
}
100-
101-
// Ticks and labels
102-
// Tick positions are absolute pixel coordinates from D3 scales whose range
103-
// was set to [chartArea.x, chartArea.x + chartArea.width] (and similarly for y).
104-
// Don't add area.x/area.y again or you'll double-offset everything.
105-
for (const tick of axis.ticks) {
106-
if (orientation === 'x') {
107-
// Label (no tick marks -- gridlines provide sufficient reference)
108-
const label = createSVGElement('text');
109-
label.setAttribute('class', 'oc-axis-tick');
110-
111-
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
112-
// Rotated labels: anchor at the rotation pivot point
113-
const labelX = tick.position;
114-
const labelY = area.y + area.height + 6;
115-
setAttrs(label, {
116-
x: labelX,
117-
y: labelY,
118-
'text-anchor': axis.tickAngle < 0 ? 'end' : 'start',
119-
'dominant-baseline': 'central',
120-
transform: `rotate(${axis.tickAngle}, ${labelX}, ${labelY})`,
121-
});
122-
} else {
123-
setAttrs(label, {
124-
x: tick.position,
125-
y: area.y + area.height + 14,
126-
'text-anchor': 'middle',
127-
});
128-
}
129-
130-
applyTextStyle(label, axis.tickLabelStyle);
131-
label.textContent = tick.label;
132-
g.appendChild(label);
133-
} else {
134-
// Label (no tick marks -- gridlines provide sufficient reference)
135-
const label = createSVGElement('text');
136-
label.setAttribute('class', 'oc-axis-tick');
137-
setAttrs(label, {
138-
x: area.x - 6,
139-
y: tick.position,
140-
'text-anchor': 'end',
141-
'dominant-baseline': 'central',
142-
});
143-
applyTextStyle(label, axis.tickLabelStyle);
144-
label.textContent = tick.label;
145-
g.appendChild(label);
146-
}
147-
}
148-
149-
// Gridlines (positions are also absolute from the scales)
150-
for (const gridline of axis.gridlines) {
151-
const gl = createSVGElement('line');
152-
gl.setAttribute('class', 'oc-gridline');
153-
if (orientation === 'y') {
154-
setAttrs(gl, {
155-
x1: area.x,
156-
y1: gridline.position,
157-
x2: area.x + area.width,
158-
y2: gridline.position,
159-
stroke: layout.theme.colors.gridline,
160-
'stroke-width': 1,
161-
'stroke-opacity': 0.6,
162-
});
163-
} else {
164-
setAttrs(gl, {
165-
x1: gridline.position,
166-
y1: area.y,
167-
x2: gridline.position,
168-
y2: area.y + area.height,
169-
stroke: layout.theme.colors.gridline,
170-
'stroke-width': 1,
171-
'stroke-opacity': 0.6,
172-
});
173-
}
174-
g.appendChild(gl);
175-
}
176-
177-
// Axis label
178-
if (axis.label && axis.labelStyle) {
179-
const axisLabel = createSVGElement('text');
180-
axisLabel.setAttribute('class', 'oc-axis-title');
181-
applyTextStyle(axisLabel, axis.labelStyle);
182-
axisLabel.textContent = axis.label;
183-
184-
if (orientation === 'x') {
185-
// Position axis title below tick labels. For rotated labels, compute
186-
// the vertical extent of the rotated ticks and place the title below.
187-
let titleY = area.y + area.height + 35;
188-
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
189-
const angleRad = Math.abs(axis.tickAngle) * (Math.PI / 180);
190-
let maxLabelWidth = 40;
191-
for (const tick of axis.ticks) {
192-
const w = estimateTextWidth(
193-
tick.label,
194-
axis.tickLabelStyle.fontSize,
195-
axis.tickLabelStyle.fontWeight,
196-
);
197-
if (w > maxLabelWidth) maxLabelWidth = w;
198-
}
199-
const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
200-
titleY = area.y + area.height + rotatedHeight + 14;
201-
}
202-
setAttrs(axisLabel, {
203-
x: area.x + area.width / 2,
204-
y: titleY,
205-
'text-anchor': 'middle',
206-
});
207-
} else {
208-
// Rotated y-axis label
209-
setAttrs(axisLabel, {
210-
x: area.x - 45,
211-
y: area.y + area.height / 2,
212-
'text-anchor': 'middle',
213-
transform: `rotate(-90, ${area.x - 45}, ${area.y + area.height / 2})`,
214-
});
215-
}
216-
g.appendChild(axisLabel);
217-
}
218-
219-
parent.appendChild(g);
220-
}
221-
222-
function renderAxes(parent: SVGElement, layout: ChartLayout): void {
223-
if (layout.axes.x) {
224-
renderAxis(parent, layout.axes.x, 'x', layout);
225-
}
226-
if (layout.axes.y) {
227-
renderAxis(parent, layout.axes.y, 'y', layout);
228-
}
229-
}
230-
23169
// ---------------------------------------------------------------------------
23270
// Mark rendering (dispatch per mark type)
23371
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)