Skip to content

Commit c60be4c

Browse files
committed
refactor(vanilla): extract renderLegend into renderers/legend.ts
1 parent b81ee09 commit c60be4c

2 files changed

Lines changed: 132 additions & 129 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Legend rendering: swatches + labels with wrap/overflow handling.
3+
*/
4+
5+
import type { LegendLayout } from '@opendata-ai/openchart-core';
6+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
7+
import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
8+
9+
export function renderLegend(parent: SVGElement, legend: LegendLayout): void {
10+
if (legend.entries.length === 0) return;
11+
12+
const g = createSVGElement('g');
13+
g.setAttribute('class', 'oc-legend');
14+
g.setAttribute('role', 'list');
15+
g.setAttribute('aria-label', 'Chart legend');
16+
17+
const isHorizontal = legend.position === 'top' || legend.position === 'bottom';
18+
let offsetX = legend.bounds.x;
19+
let offsetY = legend.bounds.y;
20+
21+
for (let i = 0; i < legend.entries.length; i++) {
22+
const entry = legend.entries[i];
23+
24+
// Pre-check: wrap to next line if this entry would overflow bounds
25+
if (isHorizontal && i > 0) {
26+
const labelWidth = estimateTextWidth(
27+
entry.label,
28+
legend.labelStyle.fontSize,
29+
legend.labelStyle.fontWeight,
30+
);
31+
const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
32+
if (offsetX + entryWidth > legend.bounds.x + legend.bounds.width) {
33+
offsetX = legend.bounds.x;
34+
offsetY += legend.swatchSize + 6;
35+
}
36+
}
37+
const entryG = createSVGElement('g');
38+
entryG.setAttribute('class', 'oc-legend-entry');
39+
entryG.setAttribute('role', 'listitem');
40+
entryG.setAttribute('data-legend-index', String(i));
41+
entryG.setAttribute('data-legend-label', entry.label);
42+
if (entry.overflow) {
43+
entryG.setAttribute('data-legend-overflow', 'true');
44+
entryG.setAttribute('aria-label', entry.label);
45+
entryG.setAttribute('opacity', '0.5');
46+
} else {
47+
entryG.setAttribute(
48+
'aria-label',
49+
`${entry.label}: ${entry.active !== false ? 'visible' : 'hidden'}`,
50+
);
51+
entryG.setAttribute('style', 'cursor: pointer');
52+
53+
// Apply dimming for inactive entries
54+
if (entry.active === false) {
55+
entryG.setAttribute('opacity', '0.3');
56+
}
57+
}
58+
59+
// Swatch
60+
if (entry.shape === 'circle') {
61+
const circle = createSVGElement('circle');
62+
setAttrs(circle, {
63+
cx: offsetX + legend.swatchSize / 2,
64+
cy: offsetY + legend.swatchSize / 2,
65+
r: legend.swatchSize / 2,
66+
fill: entry.color,
67+
});
68+
entryG.appendChild(circle);
69+
} else if (entry.shape === 'line') {
70+
// Line swatch: a short line segment with a dot in the middle
71+
const line = createSVGElement('line');
72+
setAttrs(line, {
73+
x1: offsetX,
74+
y1: offsetY + legend.swatchSize / 2,
75+
x2: offsetX + legend.swatchSize,
76+
y2: offsetY + legend.swatchSize / 2,
77+
stroke: entry.color,
78+
'stroke-width': 2,
79+
});
80+
entryG.appendChild(line);
81+
// Small dot at center
82+
const dot = createSVGElement('circle');
83+
setAttrs(dot, {
84+
cx: offsetX + legend.swatchSize / 2,
85+
cy: offsetY + legend.swatchSize / 2,
86+
r: 2.5,
87+
fill: entry.color,
88+
});
89+
entryG.appendChild(dot);
90+
} else {
91+
const rect = createSVGElement('rect');
92+
setAttrs(rect, {
93+
x: offsetX,
94+
y: offsetY,
95+
width: legend.swatchSize,
96+
height: legend.swatchSize,
97+
fill: entry.color,
98+
rx: 2,
99+
});
100+
entryG.appendChild(rect);
101+
}
102+
103+
// Label
104+
const label = createSVGElement('text');
105+
setAttrs(label, {
106+
x: offsetX + legend.swatchSize + legend.swatchGap,
107+
y: offsetY + legend.swatchSize / 2,
108+
'dominant-baseline': 'central',
109+
});
110+
applyTextStyle(label, legend.labelStyle);
111+
label.textContent = entry.label;
112+
entryG.appendChild(label);
113+
114+
g.appendChild(entryG);
115+
116+
// Advance position for next entry
117+
if (isHorizontal) {
118+
const labelWidth = estimateTextWidth(
119+
entry.label,
120+
legend.labelStyle.fontSize,
121+
legend.labelStyle.fontWeight,
122+
);
123+
const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
124+
offsetX += entryWidth;
125+
} else {
126+
offsetY += legend.swatchSize + legend.entryGap;
127+
}
128+
}
129+
130+
parent.appendChild(g);
131+
}

packages/vanilla/src/svg-renderer.ts

Lines changed: 1 addition & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import type {
1414
AreaMark,
1515
AxisLayout,
1616
ChartLayout,
17-
LegendLayout,
1817
LineMark,
1918
Mark,
2019
Point,
@@ -31,6 +30,7 @@ import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
3130
import { buildGradientDefs, resolveMarkFill } from './gradient-utils';
3231
import { renderBrand } from './renderers/brand';
3332
import { renderChrome } from './renderers/chrome';
33+
import { renderLegend } from './renderers/legend';
3434
import { applyTextStyle, createSVGElement, SVG_NS, setAttrs } from './renderers/svg-dom';
3535
import { nextSvgId } from './svg-ids';
3636

@@ -802,134 +802,6 @@ function renderAnnotation(
802802
parent.appendChild(g);
803803
}
804804

805-
// ---------------------------------------------------------------------------
806-
// Legend rendering
807-
// ---------------------------------------------------------------------------
808-
809-
function renderLegend(parent: SVGElement, legend: LegendLayout): void {
810-
if (legend.entries.length === 0) return;
811-
812-
const g = createSVGElement('g');
813-
g.setAttribute('class', 'oc-legend');
814-
g.setAttribute('role', 'list');
815-
g.setAttribute('aria-label', 'Chart legend');
816-
817-
const isHorizontal = legend.position === 'top' || legend.position === 'bottom';
818-
let offsetX = legend.bounds.x;
819-
let offsetY = legend.bounds.y;
820-
821-
for (let i = 0; i < legend.entries.length; i++) {
822-
const entry = legend.entries[i];
823-
824-
// Pre-check: wrap to next line if this entry would overflow bounds
825-
if (isHorizontal && i > 0) {
826-
const labelWidth = estimateTextWidth(
827-
entry.label,
828-
legend.labelStyle.fontSize,
829-
legend.labelStyle.fontWeight,
830-
);
831-
const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
832-
if (offsetX + entryWidth > legend.bounds.x + legend.bounds.width) {
833-
offsetX = legend.bounds.x;
834-
offsetY += legend.swatchSize + 6;
835-
}
836-
}
837-
const entryG = createSVGElement('g');
838-
entryG.setAttribute('class', 'oc-legend-entry');
839-
entryG.setAttribute('role', 'listitem');
840-
entryG.setAttribute('data-legend-index', String(i));
841-
entryG.setAttribute('data-legend-label', entry.label);
842-
if (entry.overflow) {
843-
entryG.setAttribute('data-legend-overflow', 'true');
844-
entryG.setAttribute('aria-label', entry.label);
845-
entryG.setAttribute('opacity', '0.5');
846-
} else {
847-
entryG.setAttribute(
848-
'aria-label',
849-
`${entry.label}: ${entry.active !== false ? 'visible' : 'hidden'}`,
850-
);
851-
entryG.setAttribute('style', 'cursor: pointer');
852-
853-
// Apply dimming for inactive entries
854-
if (entry.active === false) {
855-
entryG.setAttribute('opacity', '0.3');
856-
}
857-
}
858-
859-
// Swatch
860-
if (entry.shape === 'circle') {
861-
const circle = createSVGElement('circle');
862-
setAttrs(circle, {
863-
cx: offsetX + legend.swatchSize / 2,
864-
cy: offsetY + legend.swatchSize / 2,
865-
r: legend.swatchSize / 2,
866-
fill: entry.color,
867-
});
868-
entryG.appendChild(circle);
869-
} else if (entry.shape === 'line') {
870-
// Line swatch: a short line segment with a dot in the middle
871-
const line = createSVGElement('line');
872-
setAttrs(line, {
873-
x1: offsetX,
874-
y1: offsetY + legend.swatchSize / 2,
875-
x2: offsetX + legend.swatchSize,
876-
y2: offsetY + legend.swatchSize / 2,
877-
stroke: entry.color,
878-
'stroke-width': 2,
879-
});
880-
entryG.appendChild(line);
881-
// Small dot at center
882-
const dot = createSVGElement('circle');
883-
setAttrs(dot, {
884-
cx: offsetX + legend.swatchSize / 2,
885-
cy: offsetY + legend.swatchSize / 2,
886-
r: 2.5,
887-
fill: entry.color,
888-
});
889-
entryG.appendChild(dot);
890-
} else {
891-
const rect = createSVGElement('rect');
892-
setAttrs(rect, {
893-
x: offsetX,
894-
y: offsetY,
895-
width: legend.swatchSize,
896-
height: legend.swatchSize,
897-
fill: entry.color,
898-
rx: 2,
899-
});
900-
entryG.appendChild(rect);
901-
}
902-
903-
// Label
904-
const label = createSVGElement('text');
905-
setAttrs(label, {
906-
x: offsetX + legend.swatchSize + legend.swatchGap,
907-
y: offsetY + legend.swatchSize / 2,
908-
'dominant-baseline': 'central',
909-
});
910-
applyTextStyle(label, legend.labelStyle);
911-
label.textContent = entry.label;
912-
entryG.appendChild(label);
913-
914-
g.appendChild(entryG);
915-
916-
// Advance position for next entry
917-
if (isHorizontal) {
918-
const labelWidth = estimateTextWidth(
919-
entry.label,
920-
legend.labelStyle.fontSize,
921-
legend.labelStyle.fontWeight,
922-
);
923-
const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
924-
offsetX += entryWidth;
925-
} else {
926-
offsetY += legend.swatchSize + legend.entryGap;
927-
}
928-
}
929-
930-
parent.appendChild(g);
931-
}
932-
933805
// ---------------------------------------------------------------------------
934806
// Main render function
935807
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)