@opendata-ai/openchart-vanilla 6.28.6 → 7.0.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 +13 -8
- package/dist/index.js +2797 -2356
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +3 -3
- package/src/__tests__/crosshair.test.ts +11 -2
- package/src/__tests__/events.test.ts +55 -10
- package/src/graph/__tests__/canvas-renderer.test.ts +1 -0
- package/src/interactions/chart-events.ts +139 -0
- package/src/interactions/crosshair.ts +228 -0
- package/src/interactions/drag-handler.ts +175 -0
- package/src/interactions/editing-drags.ts +512 -0
- package/src/interactions/index.ts +25 -0
- package/src/interactions/keyboard-nav.ts +111 -0
- package/src/interactions/legend-interaction.ts +38 -0
- package/src/interactions/selection.ts +271 -0
- package/src/interactions/tooltip-events.ts +72 -0
- package/src/mount.ts +182 -1761
- package/src/renderers/annotations.ts +82 -2
- package/src/renderers/axes.ts +18 -1
- package/src/renderers/brand.ts +7 -1
- package/src/renderers/chrome.ts +50 -3
- package/src/renderers/endpoint-labels.ts +164 -0
- package/src/renderers/legend.ts +32 -27
- package/src/renderers/marks.ts +65 -17
- package/src/renderers/metrics.ts +50 -0
- package/src/svg-renderer.ts +80 -20
- package/src/tilemap-mount.ts +6 -6
- package/src/tilemap-renderer.ts +0 -2
- package/src/tooltip.ts +27 -7
|
@@ -85,6 +85,11 @@ function renderAnnotation(
|
|
|
85
85
|
if (annotation.rect) {
|
|
86
86
|
const rect = createSVGElement('rect');
|
|
87
87
|
rect.setAttribute('class', 'oc-annotation-range');
|
|
88
|
+
// Range fills cover large chart-area regions; if they intercept pointer
|
|
89
|
+
// events the voronoi tooltip overlay below stops receiving mousemove
|
|
90
|
+
// inside the range, creating hover dead zones. The label still receives
|
|
91
|
+
// events for annotation click handlers.
|
|
92
|
+
rect.setAttribute('pointer-events', 'none');
|
|
88
93
|
setAttrs(rect, {
|
|
89
94
|
x: annotation.rect.x,
|
|
90
95
|
y: annotation.rect.y,
|
|
@@ -123,6 +128,20 @@ function renderAnnotation(
|
|
|
123
128
|
const c = annotation.label.connector;
|
|
124
129
|
if (c.style === 'curve') {
|
|
125
130
|
renderCurvedArrow(g, c.from, c.to, c.stroke);
|
|
131
|
+
} else if (c.style === 'drop-line') {
|
|
132
|
+
const connector = createSVGElement('line');
|
|
133
|
+
connector.setAttribute('class', 'oc-annotation-connector oc-annotation-drop-line');
|
|
134
|
+
setAttrs(connector, {
|
|
135
|
+
x1: c.from.x,
|
|
136
|
+
y1: c.from.y,
|
|
137
|
+
x2: c.to.x,
|
|
138
|
+
y2: c.to.y,
|
|
139
|
+
stroke: c.stroke,
|
|
140
|
+
'stroke-width': 1,
|
|
141
|
+
'stroke-opacity': 0.6,
|
|
142
|
+
'shape-rendering': 'crispEdges',
|
|
143
|
+
});
|
|
144
|
+
g.appendChild(connector);
|
|
126
145
|
} else {
|
|
127
146
|
const connector = createSVGElement('line');
|
|
128
147
|
connector.setAttribute('class', 'oc-annotation-connector');
|
|
@@ -137,6 +156,52 @@ function renderAnnotation(
|
|
|
137
156
|
});
|
|
138
157
|
g.appendChild(connector);
|
|
139
158
|
}
|
|
159
|
+
|
|
160
|
+
// Endpoint marker: bullseye dot at the data point. Outer ring uses the
|
|
161
|
+
// chart background as fill so it knocks out the line/area beneath; inner
|
|
162
|
+
// dot is the connector color. Skipped for curve style — the arrowhead
|
|
163
|
+
// already serves as the endpoint indicator there.
|
|
164
|
+
if (c.endpoint && c.style !== 'curve') {
|
|
165
|
+
const ring = createSVGElement('circle');
|
|
166
|
+
ring.setAttribute('class', 'oc-annotation-endpoint-ring');
|
|
167
|
+
setAttrs(ring, {
|
|
168
|
+
cx: c.endpoint.x,
|
|
169
|
+
cy: c.endpoint.y,
|
|
170
|
+
r: 5,
|
|
171
|
+
fill: bgColor ?? '#ffffff',
|
|
172
|
+
stroke: c.stroke,
|
|
173
|
+
'stroke-width': 1.5,
|
|
174
|
+
});
|
|
175
|
+
g.appendChild(ring);
|
|
176
|
+
|
|
177
|
+
const dot = createSVGElement('circle');
|
|
178
|
+
dot.setAttribute('class', 'oc-annotation-endpoint-dot');
|
|
179
|
+
setAttrs(dot, {
|
|
180
|
+
cx: c.endpoint.x,
|
|
181
|
+
cy: c.endpoint.y,
|
|
182
|
+
r: 2,
|
|
183
|
+
fill: c.stroke,
|
|
184
|
+
});
|
|
185
|
+
g.appendChild(dot);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Optional anchor dot: rendered AFTER the connector but BEFORE the label
|
|
190
|
+
// text so the dot sits on top of the connector and under any text halo.
|
|
191
|
+
// The engine resolves dot.x/y at the post-gap-pullback connector.to point;
|
|
192
|
+
// the renderer just stamps coordinates.
|
|
193
|
+
if (annotation.dot) {
|
|
194
|
+
const dot = createSVGElement('circle');
|
|
195
|
+
dot.setAttribute('class', 'oc-annotation-dot');
|
|
196
|
+
setAttrs(dot, {
|
|
197
|
+
cx: annotation.dot.x,
|
|
198
|
+
cy: annotation.dot.y,
|
|
199
|
+
r: annotation.dot.radius,
|
|
200
|
+
fill: annotation.dot.fill,
|
|
201
|
+
stroke: annotation.dot.stroke,
|
|
202
|
+
'stroke-width': annotation.dot.strokeWidth,
|
|
203
|
+
});
|
|
204
|
+
g.appendChild(dot);
|
|
140
205
|
}
|
|
141
206
|
|
|
142
207
|
const text = createSVGElement('text');
|
|
@@ -149,9 +214,14 @@ function renderAnnotation(
|
|
|
149
214
|
const lineHeight = fontSize * (annotation.label.style.lineHeight ?? 1.3);
|
|
150
215
|
const isMultiLine = lines.length > 1;
|
|
151
216
|
|
|
152
|
-
// Multi-line text
|
|
217
|
+
// Multi-line text: drop-line connectors keep the resolved side anchor so
|
|
218
|
+
// the label hugs the vertical line. Other connectors center the text for
|
|
219
|
+
// a cleaner look.
|
|
153
220
|
if (isMultiLine) {
|
|
154
|
-
|
|
221
|
+
const isDropLine = annotation.label.connector?.style === 'drop-line';
|
|
222
|
+
if (!isDropLine) {
|
|
223
|
+
text.setAttribute('text-anchor', 'middle');
|
|
224
|
+
}
|
|
155
225
|
for (let i = 0; i < lines.length; i++) {
|
|
156
226
|
const tspan = createSVGElement('tspan');
|
|
157
227
|
setAttrs(tspan, { x: annotation.label.x, dy: i === 0 ? 0 : lineHeight });
|
|
@@ -191,6 +261,16 @@ function renderAnnotation(
|
|
|
191
261
|
}
|
|
192
262
|
|
|
193
263
|
g.appendChild(text);
|
|
264
|
+
|
|
265
|
+
// Optional muted subtitle, positioned by the engine below the primary label.
|
|
266
|
+
if (annotation.subtitle) {
|
|
267
|
+
const sub = createSVGElement('text');
|
|
268
|
+
sub.setAttribute('class', 'oc-annotation-subtitle');
|
|
269
|
+
setAttrs(sub, { x: annotation.subtitle.x, y: annotation.subtitle.y });
|
|
270
|
+
applyTextStyle(sub, annotation.subtitle.style);
|
|
271
|
+
sub.textContent = annotation.subtitle.text;
|
|
272
|
+
g.appendChild(sub);
|
|
273
|
+
}
|
|
194
274
|
}
|
|
195
275
|
|
|
196
276
|
parent.appendChild(g);
|
package/src/renderers/axes.ts
CHANGED
|
@@ -37,7 +37,11 @@ function renderAxis(
|
|
|
37
37
|
): void {
|
|
38
38
|
const g = createSVGElement('g');
|
|
39
39
|
const isRight = orientation === 'y' && axis.orient === 'right';
|
|
40
|
-
|
|
40
|
+
const isInlineY = orientation === 'y' && axis.tickPosition === 'inline' && !isRight;
|
|
41
|
+
g.setAttribute(
|
|
42
|
+
'class',
|
|
43
|
+
`oc-axis oc-axis-${isRight ? 'y2' : orientation}${isInlineY ? ' oc-axis-inline' : ''}`,
|
|
44
|
+
);
|
|
41
45
|
|
|
42
46
|
const { area } = layout;
|
|
43
47
|
|
|
@@ -86,6 +90,19 @@ function renderAxis(
|
|
|
86
90
|
});
|
|
87
91
|
}
|
|
88
92
|
|
|
93
|
+
applyTextStyle(label, axis.tickLabelStyle);
|
|
94
|
+
label.textContent = tick.label;
|
|
95
|
+
g.appendChild(label);
|
|
96
|
+
} else if (isInlineY) {
|
|
97
|
+
// Inline y-tick: label sits above its gridline at the chart-area left
|
|
98
|
+
// edge, no gutter reserved. The gridline itself is the visual axis.
|
|
99
|
+
const label = createSVGElement('text');
|
|
100
|
+
label.setAttribute('class', 'oc-axis-tick oc-axis-tick-inline');
|
|
101
|
+
setAttrs(label, {
|
|
102
|
+
x: area.x,
|
|
103
|
+
y: tick.position - 6,
|
|
104
|
+
'text-anchor': 'start',
|
|
105
|
+
});
|
|
89
106
|
applyTextStyle(label, axis.tickLabelStyle);
|
|
90
107
|
label.textContent = tick.label;
|
|
91
108
|
g.appendChild(label);
|
package/src/renderers/brand.ts
CHANGED
|
@@ -27,9 +27,15 @@ export function renderBrand(parent: SVGElement, layout: ChartLayout): void {
|
|
|
27
27
|
const xAxisExtent = computeXAxisExtent(layout);
|
|
28
28
|
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
|
|
29
29
|
const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
|
|
30
|
+
// When no bottom chrome items exist, derive a fallback offset that still
|
|
31
|
+
// clears any bottom-positioned legend so the brand watermark doesn't
|
|
32
|
+
// overlap legend swatches.
|
|
33
|
+
const { legend } = layout;
|
|
34
|
+
const bottomLegendOffset =
|
|
35
|
+
legend.position === 'bottom' && legend.bounds.height > 0 ? legend.bounds.height + 8 : 0;
|
|
30
36
|
const chromeY = firstBottom
|
|
31
37
|
? bottomOffset + firstBottom.y
|
|
32
|
-
: bottomOffset + layout.theme.spacing.chartToFooter;
|
|
38
|
+
: bottomOffset + layout.theme.spacing.chartToFooter + bottomLegendOffset;
|
|
33
39
|
|
|
34
40
|
const a = createSVGElement('a');
|
|
35
41
|
a.setAttribute('href', BRAND_URL);
|
package/src/renderers/chrome.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
MeasureTextFn,
|
|
8
8
|
ResolvedChromeElement,
|
|
9
9
|
} from '@opendata-ai/openchart-core';
|
|
10
|
-
import { wrapText } from '@opendata-ai/openchart-core';
|
|
10
|
+
import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core';
|
|
11
11
|
import { applyTextStyle, computeXAxisExtent, createSVGElement, setAttrs } from './svg-dom';
|
|
12
12
|
|
|
13
13
|
function renderChromeElement(
|
|
@@ -16,6 +16,7 @@ function renderChromeElement(
|
|
|
16
16
|
className: string,
|
|
17
17
|
chromeKey: string,
|
|
18
18
|
measureText?: MeasureTextFn,
|
|
19
|
+
uppercase = false,
|
|
19
20
|
): void {
|
|
20
21
|
const text = createSVGElement('text');
|
|
21
22
|
setAttrs(text, { x: element.x, y: element.y });
|
|
@@ -23,8 +24,13 @@ function renderChromeElement(
|
|
|
23
24
|
text.setAttribute('class', className);
|
|
24
25
|
text.setAttribute('data-chrome-key', chromeKey);
|
|
25
26
|
|
|
27
|
+
// happy-dom doesn't apply CSS text-transform inside SVG measurement, so
|
|
28
|
+
// pre-uppercase the rendered text for elements that style as uppercase.
|
|
29
|
+
// Browsers honor the CSS rule too, so this is double-applied harmlessly.
|
|
30
|
+
const renderedText = uppercase ? element.text.toUpperCase() : element.text;
|
|
31
|
+
|
|
26
32
|
const lines = wrapText(
|
|
27
|
-
|
|
33
|
+
renderedText,
|
|
28
34
|
element.style.fontSize,
|
|
29
35
|
element.style.fontWeight,
|
|
30
36
|
element.maxWidth,
|
|
@@ -32,7 +38,7 @@ function renderChromeElement(
|
|
|
32
38
|
);
|
|
33
39
|
|
|
34
40
|
if (lines.length === 1) {
|
|
35
|
-
text.textContent =
|
|
41
|
+
text.textContent = renderedText;
|
|
36
42
|
} else {
|
|
37
43
|
const lineHeight = element.style.fontSize * (element.style.lineHeight ?? 1.3);
|
|
38
44
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -53,6 +59,27 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
|
53
59
|
const { chrome, measureText } = layout;
|
|
54
60
|
|
|
55
61
|
// Top chrome: render at their stored y positions (already absolute)
|
|
62
|
+
if (chrome.eyebrow) {
|
|
63
|
+
// Leading accent dot — matches the editorial design system mock.
|
|
64
|
+
// Eyebrow text uses dominantBaseline: hanging, so eyebrow.y is the top of
|
|
65
|
+
// the text. Visual center is roughly y + fontSize * 0.55 (cap height).
|
|
66
|
+
const eyebrow = chrome.eyebrow;
|
|
67
|
+
const dotR = 3;
|
|
68
|
+
const dotGap = 8;
|
|
69
|
+
const dotX = eyebrow.x + dotR;
|
|
70
|
+
const dotY = eyebrow.y + eyebrow.style.fontSize * 0.42;
|
|
71
|
+
const dot = createSVGElement('circle');
|
|
72
|
+
dot.setAttribute('class', 'oc-eyebrow-dot');
|
|
73
|
+
setAttrs(dot, { cx: dotX, cy: dotY, r: dotR });
|
|
74
|
+
dot.setAttribute('fill', eyebrow.style.fill ?? 'currentColor');
|
|
75
|
+
g.appendChild(dot);
|
|
76
|
+
|
|
77
|
+
const shifted: ResolvedChromeElement = {
|
|
78
|
+
...eyebrow,
|
|
79
|
+
x: eyebrow.x + dotR * 2 + dotGap,
|
|
80
|
+
};
|
|
81
|
+
renderChromeElement(g, shifted, 'oc-eyebrow', 'eyebrow', measureText, true);
|
|
82
|
+
}
|
|
56
83
|
if (chrome.title) {
|
|
57
84
|
renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);
|
|
58
85
|
}
|
|
@@ -91,6 +118,26 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
|
91
118
|
measureText,
|
|
92
119
|
);
|
|
93
120
|
}
|
|
121
|
+
if (chrome.brand) {
|
|
122
|
+
const brandY = bottomOffset + chrome.brand.y;
|
|
123
|
+
renderChromeElement(g, { ...chrome.brand, y: brandY }, 'oc-brand', 'brand', measureText);
|
|
124
|
+
// Accent dot to the left of the brand text. text-anchor=end means
|
|
125
|
+
// brand.x is the right edge, so the dot sits 12px left of the measured
|
|
126
|
+
// text's leftmost glyph. Use estimateTextWidth (the same path the
|
|
127
|
+
// engine uses for label sizing) instead of a `length * 0.55em` fudge
|
|
128
|
+
// so wide glyphs (W, M) and narrow ones (i, l) land correctly.
|
|
129
|
+
const textWidth = estimateTextWidth(
|
|
130
|
+
chrome.brand.text,
|
|
131
|
+
chrome.brand.style.fontSize,
|
|
132
|
+
chrome.brand.style.fontWeight,
|
|
133
|
+
);
|
|
134
|
+
const dotX = chrome.brand.x - textWidth - 12;
|
|
135
|
+
const dotY = brandY + chrome.brand.style.fontSize / 2;
|
|
136
|
+
const dot = createSVGElement('circle');
|
|
137
|
+
dot.setAttribute('class', 'oc-brand-dot');
|
|
138
|
+
setAttrs(dot, { cx: dotX, cy: dotY, r: 3 });
|
|
139
|
+
g.appendChild(dot);
|
|
140
|
+
}
|
|
94
141
|
|
|
95
142
|
parent.appendChild(g);
|
|
96
143
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint-labels rendering: right-side per-series label column for multi-series
|
|
3
|
+
* line/area charts. Renders, per entry:
|
|
4
|
+
* - a chip+bar swatch matching the traditional legend (rounded surface chip
|
|
5
|
+
* with a colored bar through its midline)
|
|
6
|
+
* - the colored series label (with wrap support via tspans)
|
|
7
|
+
* - a muted formatted value below the label
|
|
8
|
+
* - an optional thin leader line back to the data point's true y
|
|
9
|
+
* - an optional open-ring marker on the line at the chart's right edge
|
|
10
|
+
*
|
|
11
|
+
* The engine resolves all positions, colors, and styles. This renderer is dumb:
|
|
12
|
+
* it reads `layout.endpointLabels` and stamps SVG. Suppression logic lives in
|
|
13
|
+
* the engine — when entries is empty, the renderer is a no-op.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ChartLayout } from '@opendata-ai/openchart-core';
|
|
17
|
+
import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
|
|
18
|
+
|
|
19
|
+
// Swatch→label and label→value gaps both come from the engine layout
|
|
20
|
+
// (`ep.gap` and `ep.valueGap`), so the renderer never has to keep its own
|
|
21
|
+
// copies in sync. Leader styling is renderer-only — the engine doesn't
|
|
22
|
+
// model stroke width or opacity for the optional connector.
|
|
23
|
+
const LEADER_STROKE_WIDTH = 1;
|
|
24
|
+
const LEADER_OPACITY = 0.45;
|
|
25
|
+
|
|
26
|
+
export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): void {
|
|
27
|
+
const ep = layout.endpointLabels;
|
|
28
|
+
if (!ep || ep.entries.length === 0) return;
|
|
29
|
+
|
|
30
|
+
const chartArea = layout.area;
|
|
31
|
+
const chartRightX = chartArea.x + chartArea.width;
|
|
32
|
+
|
|
33
|
+
const root = createSVGElement('g');
|
|
34
|
+
root.setAttribute('class', 'oc-endpoint-labels');
|
|
35
|
+
root.setAttribute('role', 'list');
|
|
36
|
+
root.setAttribute('aria-label', 'Endpoint labels');
|
|
37
|
+
|
|
38
|
+
const labelFontSize = ep.labelStyle.fontSize ?? 11;
|
|
39
|
+
const labelLineHeight = labelFontSize * (ep.labelStyle.lineHeight ?? 1.25);
|
|
40
|
+
const valueFontSize = ep.valueStyle.fontSize ?? 11;
|
|
41
|
+
|
|
42
|
+
// The column starts at ep.bounds.x; the chip sits flush-left in the column,
|
|
43
|
+
// the label/value text starts after the chip + gap.
|
|
44
|
+
const chipX = ep.bounds.x;
|
|
45
|
+
const chipWidth = ep.swatchSize;
|
|
46
|
+
const textX = chipX + chipWidth + ep.gap;
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < ep.entries.length; i++) {
|
|
49
|
+
const entry = ep.entries[i];
|
|
50
|
+
|
|
51
|
+
const entryG = createSVGElement('g');
|
|
52
|
+
entryG.setAttribute('class', 'oc-endpoint-label-entry');
|
|
53
|
+
entryG.setAttribute('role', 'listitem');
|
|
54
|
+
entryG.setAttribute('data-endpoint-index', String(i));
|
|
55
|
+
entryG.setAttribute('data-endpoint-key', entry.seriesKey);
|
|
56
|
+
entryG.setAttribute('aria-label', `${entry.seriesKey}: ${entry.value}`);
|
|
57
|
+
|
|
58
|
+
// Leader line: drawn first so swatch/text sit on top.
|
|
59
|
+
if (entry.showLeader) {
|
|
60
|
+
const leader = createSVGElement('line');
|
|
61
|
+
leader.setAttribute('class', 'oc-endpoint-leader');
|
|
62
|
+
setAttrs(leader, {
|
|
63
|
+
x1: chipX,
|
|
64
|
+
y1: entry.labelY + labelFontSize / 2,
|
|
65
|
+
x2: chartRightX,
|
|
66
|
+
y2: entry.dataY,
|
|
67
|
+
stroke: entry.color,
|
|
68
|
+
'stroke-width': LEADER_STROKE_WIDTH,
|
|
69
|
+
'stroke-opacity': LEADER_OPACITY,
|
|
70
|
+
});
|
|
71
|
+
entryG.appendChild(leader);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Swatch: rounded chip with a colored bar through its midline, matching
|
|
75
|
+
// the traditional legend so a chart never shows two swatch idioms.
|
|
76
|
+
const rowY = entry.labelY + labelFontSize / 2;
|
|
77
|
+
const chipHeight = Math.max(12, Math.round(ep.swatchSize * 0.85));
|
|
78
|
+
const chipY = rowY - chipHeight / 2;
|
|
79
|
+
const chip = createSVGElement('rect');
|
|
80
|
+
chip.setAttribute('class', 'oc-endpoint-swatch-chip');
|
|
81
|
+
setAttrs(chip, {
|
|
82
|
+
x: chipX,
|
|
83
|
+
y: chipY,
|
|
84
|
+
width: chipWidth,
|
|
85
|
+
height: chipHeight,
|
|
86
|
+
rx: 3,
|
|
87
|
+
ry: 3,
|
|
88
|
+
fill: ep.swatchChipFill,
|
|
89
|
+
});
|
|
90
|
+
entryG.appendChild(chip);
|
|
91
|
+
|
|
92
|
+
const barWidth = Math.max(8, chipWidth - 8);
|
|
93
|
+
const barHeight = 3;
|
|
94
|
+
const barX = chipX + (chipWidth - barWidth) / 2;
|
|
95
|
+
const barY = rowY - barHeight / 2;
|
|
96
|
+
const bar = createSVGElement('rect');
|
|
97
|
+
bar.setAttribute('class', 'oc-endpoint-swatch-bar');
|
|
98
|
+
setAttrs(bar, {
|
|
99
|
+
x: barX,
|
|
100
|
+
y: barY,
|
|
101
|
+
width: barWidth,
|
|
102
|
+
height: barHeight,
|
|
103
|
+
rx: barHeight / 2,
|
|
104
|
+
ry: barHeight / 2,
|
|
105
|
+
fill: entry.color,
|
|
106
|
+
});
|
|
107
|
+
entryG.appendChild(bar);
|
|
108
|
+
|
|
109
|
+
// Label text. Multi-line via tspans when wrapped.
|
|
110
|
+
const label = createSVGElement('text');
|
|
111
|
+
label.setAttribute('class', 'oc-endpoint-label');
|
|
112
|
+
setAttrs(label, { x: textX, y: entry.labelY + labelFontSize });
|
|
113
|
+
applyTextStyle(label, ep.labelStyle);
|
|
114
|
+
// Engine-resolved color always wins so theme overrides at the CSS layer
|
|
115
|
+
// don't fight per-series colors.
|
|
116
|
+
(label as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', entry.color);
|
|
117
|
+
|
|
118
|
+
if (entry.labelLines.length <= 1) {
|
|
119
|
+
label.textContent = entry.labelLines[0] ?? entry.seriesKey;
|
|
120
|
+
} else {
|
|
121
|
+
for (let li = 0; li < entry.labelLines.length; li++) {
|
|
122
|
+
const tspan = createSVGElement('tspan');
|
|
123
|
+
setAttrs(tspan, { x: textX, dy: li === 0 ? 0 : labelLineHeight });
|
|
124
|
+
tspan.textContent = entry.labelLines[li];
|
|
125
|
+
label.appendChild(tspan);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
entryG.appendChild(label);
|
|
129
|
+
|
|
130
|
+
// Value text directly underneath the last label line.
|
|
131
|
+
const lineCount = Math.max(entry.labelLines.length, 1);
|
|
132
|
+
const valueY =
|
|
133
|
+
entry.labelY +
|
|
134
|
+
labelFontSize +
|
|
135
|
+
(lineCount - 1) * labelLineHeight +
|
|
136
|
+
ep.valueGap +
|
|
137
|
+
valueFontSize;
|
|
138
|
+
const value = createSVGElement('text');
|
|
139
|
+
value.setAttribute('class', 'oc-endpoint-value');
|
|
140
|
+
setAttrs(value, { x: textX, y: valueY });
|
|
141
|
+
applyTextStyle(value, ep.valueStyle);
|
|
142
|
+
value.textContent = entry.value;
|
|
143
|
+
entryG.appendChild(value);
|
|
144
|
+
|
|
145
|
+
// Marker: open-ring circle at the chart's right edge on the line.
|
|
146
|
+
if (entry.marker) {
|
|
147
|
+
const marker = createSVGElement('circle');
|
|
148
|
+
marker.setAttribute('class', 'oc-endpoint-marker');
|
|
149
|
+
setAttrs(marker, {
|
|
150
|
+
cx: entry.marker.x,
|
|
151
|
+
cy: entry.marker.y,
|
|
152
|
+
r: entry.marker.radius,
|
|
153
|
+
fill: entry.marker.fill,
|
|
154
|
+
stroke: entry.marker.stroke,
|
|
155
|
+
'stroke-width': entry.marker.strokeWidth,
|
|
156
|
+
});
|
|
157
|
+
entryG.appendChild(marker);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
root.appendChild(entryG);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
parent.appendChild(root);
|
|
164
|
+
}
|
package/src/renderers/legend.ts
CHANGED
|
@@ -61,6 +61,11 @@ export function renderLegend(parent: SVGElement, legend: LegendLayout): void {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// Swatch
|
|
64
|
+
// The default ('square') and 'line' shapes render as a "chip": a small
|
|
65
|
+
// rounded rectangle in a subtle elevated surface tone with a colored
|
|
66
|
+
// rounded bar through the middle. Matches the editorial mock-2 legend
|
|
67
|
+
// and the endpoint-labels swatch so a chart never shows two swatch styles.
|
|
68
|
+
// 'circle' is preserved for point/scatter charts.
|
|
64
69
|
if (entry.shape === 'circle') {
|
|
65
70
|
const circle = createSVGElement('circle');
|
|
66
71
|
setAttrs(circle, {
|
|
@@ -70,38 +75,38 @@ export function renderLegend(parent: SVGElement, legend: LegendLayout): void {
|
|
|
70
75
|
fill: entry.color,
|
|
71
76
|
});
|
|
72
77
|
entryG.appendChild(circle);
|
|
73
|
-
} else if (entry.shape === 'line') {
|
|
74
|
-
// Line swatch: a short line segment with a dot in the middle
|
|
75
|
-
const line = createSVGElement('line');
|
|
76
|
-
setAttrs(line, {
|
|
77
|
-
x1: offsetX,
|
|
78
|
-
y1: offsetY + legend.swatchSize / 2,
|
|
79
|
-
x2: offsetX + legend.swatchSize,
|
|
80
|
-
y2: offsetY + legend.swatchSize / 2,
|
|
81
|
-
stroke: entry.color,
|
|
82
|
-
'stroke-width': 2,
|
|
83
|
-
});
|
|
84
|
-
entryG.appendChild(line);
|
|
85
|
-
// Small dot at center
|
|
86
|
-
const dot = createSVGElement('circle');
|
|
87
|
-
setAttrs(dot, {
|
|
88
|
-
cx: offsetX + legend.swatchSize / 2,
|
|
89
|
-
cy: offsetY + legend.swatchSize / 2,
|
|
90
|
-
r: 2.5,
|
|
91
|
-
fill: entry.color,
|
|
92
|
-
});
|
|
93
|
-
entryG.appendChild(dot);
|
|
94
78
|
} else {
|
|
95
|
-
const
|
|
96
|
-
|
|
79
|
+
const chipHeight = Math.max(12, Math.round(legend.swatchSize * 0.85));
|
|
80
|
+
const chipY = offsetY + legend.swatchSize / 2 - chipHeight / 2;
|
|
81
|
+
const chip = createSVGElement('rect');
|
|
82
|
+
chip.setAttribute('class', 'oc-legend-swatch-chip');
|
|
83
|
+
setAttrs(chip, {
|
|
97
84
|
x: offsetX,
|
|
98
|
-
y:
|
|
85
|
+
y: chipY,
|
|
99
86
|
width: legend.swatchSize,
|
|
100
|
-
height:
|
|
87
|
+
height: chipHeight,
|
|
88
|
+
rx: 3,
|
|
89
|
+
ry: 3,
|
|
90
|
+
fill: legend.swatchChipFill,
|
|
91
|
+
});
|
|
92
|
+
entryG.appendChild(chip);
|
|
93
|
+
|
|
94
|
+
const barWidth = Math.max(8, legend.swatchSize - 8);
|
|
95
|
+
const barHeight = 3;
|
|
96
|
+
const barX = offsetX + (legend.swatchSize - barWidth) / 2;
|
|
97
|
+
const barY = offsetY + legend.swatchSize / 2 - barHeight / 2;
|
|
98
|
+
const bar = createSVGElement('rect');
|
|
99
|
+
bar.setAttribute('class', 'oc-legend-swatch-bar');
|
|
100
|
+
setAttrs(bar, {
|
|
101
|
+
x: barX,
|
|
102
|
+
y: barY,
|
|
103
|
+
width: barWidth,
|
|
104
|
+
height: barHeight,
|
|
105
|
+
rx: barHeight / 2,
|
|
106
|
+
ry: barHeight / 2,
|
|
101
107
|
fill: entry.color,
|
|
102
|
-
rx: 2,
|
|
103
108
|
});
|
|
104
|
-
entryG.appendChild(
|
|
109
|
+
entryG.appendChild(bar);
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
// Label
|
package/src/renderers/marks.ts
CHANGED
|
@@ -179,6 +179,40 @@ function renderAreaMark(mark: AreaMark, index: number): SVGElement {
|
|
|
179
179
|
return g;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Build an SVG path describing a rectangle with selectively rounded corners.
|
|
184
|
+
* Used by stacked segments where only the leading edge (top of a vertical
|
|
185
|
+
* stack, right of a horizontal stack) should round so the seams between
|
|
186
|
+
* adjacent segments stay flush.
|
|
187
|
+
*/
|
|
188
|
+
function _rectPathWithCorners(
|
|
189
|
+
mark: RectMark,
|
|
190
|
+
sides: NonNullable<RectMark['cornerRadiusSides']>,
|
|
191
|
+
): string {
|
|
192
|
+
const { x, y, width: w, height: h } = mark;
|
|
193
|
+
// Clamp the radius so it never exceeds half of the shorter side, otherwise
|
|
194
|
+
// the arcs would overlap and the path would render as a degenerate shape.
|
|
195
|
+
const r = Math.max(0, Math.min(mark.cornerRadius ?? 0, w / 2, h / 2));
|
|
196
|
+
const tl = sides.tl ? r : 0;
|
|
197
|
+
const tr = sides.tr ? r : 0;
|
|
198
|
+
const br = sides.br ? r : 0;
|
|
199
|
+
const bl = sides.bl ? r : 0;
|
|
200
|
+
return [
|
|
201
|
+
`M${x + tl},${y}`,
|
|
202
|
+
`H${x + w - tr}`,
|
|
203
|
+
tr ? `A${tr},${tr} 0 0 1 ${x + w},${y + tr}` : '',
|
|
204
|
+
`V${y + h - br}`,
|
|
205
|
+
br ? `A${br},${br} 0 0 1 ${x + w - br},${y + h}` : '',
|
|
206
|
+
`H${x + bl}`,
|
|
207
|
+
bl ? `A${bl},${bl} 0 0 1 ${x},${y + h - bl}` : '',
|
|
208
|
+
`V${y + tl}`,
|
|
209
|
+
tl ? `A${tl},${tl} 0 0 1 ${x + tl},${y}` : '',
|
|
210
|
+
'Z',
|
|
211
|
+
]
|
|
212
|
+
.filter(Boolean)
|
|
213
|
+
.join(' ');
|
|
214
|
+
}
|
|
215
|
+
|
|
182
216
|
function renderRectMark(mark: RectMark, index: number): SVGElement {
|
|
183
217
|
const g = createSVGElement('g');
|
|
184
218
|
g.setAttribute('data-mark-id', `rect-${index}`);
|
|
@@ -189,24 +223,35 @@ function renderRectMark(mark: RectMark, index: number): SVGElement {
|
|
|
189
223
|
g.setAttribute('data-orient', 'horizontal');
|
|
190
224
|
}
|
|
191
225
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
226
|
+
// When `cornerRadiusSides` selects a subset of corners (e.g. top-only for
|
|
227
|
+
// the topmost segment of a stacked column), SVG's `rx`/`ry` won't do —
|
|
228
|
+
// it rounds all four corners or none. Emit a `<path>` with per-corner
|
|
229
|
+
// arcs in that case so the stacked segments stay flush at the seam.
|
|
230
|
+
const sides = mark.cornerRadiusSides;
|
|
231
|
+
const partialCorners =
|
|
232
|
+
!!sides && (!sides.tl || !sides.tr || !sides.br || !sides.bl) && !!mark.cornerRadius;
|
|
233
|
+
const shapeEl = partialCorners ? createSVGElement('path') : createSVGElement('rect');
|
|
234
|
+
if (partialCorners) {
|
|
235
|
+
shapeEl.setAttribute('d', _rectPathWithCorners(mark, sides));
|
|
236
|
+
} else {
|
|
237
|
+
setAttrs(shapeEl, {
|
|
238
|
+
x: mark.x,
|
|
239
|
+
y: mark.y,
|
|
240
|
+
width: mark.width,
|
|
241
|
+
height: mark.height,
|
|
242
|
+
});
|
|
243
|
+
if (mark.cornerRadius) {
|
|
244
|
+
setAttrs(shapeEl, { rx: mark.cornerRadius, ry: mark.cornerRadius });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
shapeEl.setAttribute('fill', String(resolveMarkFill(mark.fill, currentGradientMap)));
|
|
200
248
|
if (mark.stroke) {
|
|
201
|
-
|
|
249
|
+
shapeEl.setAttribute('stroke', mark.stroke);
|
|
202
250
|
}
|
|
203
251
|
if (mark.strokeWidth) {
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
if (mark.cornerRadius) {
|
|
207
|
-
setAttrs(rect, { rx: mark.cornerRadius, ry: mark.cornerRadius });
|
|
252
|
+
shapeEl.setAttribute('stroke-width', String(mark.strokeWidth));
|
|
208
253
|
}
|
|
209
|
-
g.appendChild(
|
|
254
|
+
g.appendChild(shapeEl);
|
|
210
255
|
|
|
211
256
|
// Render value label if present and visible
|
|
212
257
|
if (mark.label?.visible) {
|
|
@@ -376,7 +421,7 @@ function getMarkSeries(mark: Mark): string | undefined {
|
|
|
376
421
|
}
|
|
377
422
|
// For arc marks, the category name is the first part of the aria label (before ':')
|
|
378
423
|
if (mark.type === 'arc') {
|
|
379
|
-
return mark.aria.label
|
|
424
|
+
return mark.aria.label?.split(':')[0]?.trim();
|
|
380
425
|
}
|
|
381
426
|
// For rect/point, the aria label may be "category: value" or "category, group: value".
|
|
382
427
|
// The series name is the category part (before the colon).
|
|
@@ -397,8 +442,11 @@ export function renderMarks(parent: SVGElement, layout: ChartLayout): void {
|
|
|
397
442
|
if (!renderer) continue;
|
|
398
443
|
|
|
399
444
|
const el = renderer(mark, i);
|
|
400
|
-
//
|
|
401
|
-
|
|
445
|
+
// Decorative marks (e.g. sparkline endpoint dots) are hidden from
|
|
446
|
+
// assistive tech because they duplicate an existing data point.
|
|
447
|
+
if (mark.aria?.decorative) {
|
|
448
|
+
el.setAttribute('aria-hidden', 'true');
|
|
449
|
+
} else if (mark.aria?.label) {
|
|
402
450
|
el.setAttribute('aria-label', mark.aria.label);
|
|
403
451
|
}
|
|
404
452
|
// Add data-series attribute for legend toggle matching
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KPI metric bar rendering. Emits a `<g class="oc-metrics">` group containing
|
|
3
|
+
* a label/value pair per cell. Visual styling lives in `chrome.css`
|
|
4
|
+
* (`.oc-metric-*` classes); this renderer only sets positions and structure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChartLayout } from '@opendata-ai/openchart-core';
|
|
8
|
+
import { createSVGElement, setAttrs } from './svg-dom';
|
|
9
|
+
|
|
10
|
+
export function renderMetrics(parent: SVGElement, layout: ChartLayout): void {
|
|
11
|
+
const bar = layout.metrics;
|
|
12
|
+
if (!bar || bar.cells.length === 0) return;
|
|
13
|
+
|
|
14
|
+
const g = createSVGElement('g');
|
|
15
|
+
g.setAttribute('class', 'oc-metrics');
|
|
16
|
+
|
|
17
|
+
for (const cell of bar.cells) {
|
|
18
|
+
const label = createSVGElement('text');
|
|
19
|
+
label.setAttribute('class', 'oc-metric-label');
|
|
20
|
+
setAttrs(label, { x: cell.x, y: cell.labelY });
|
|
21
|
+
label.textContent = cell.metric.label.toUpperCase();
|
|
22
|
+
g.appendChild(label);
|
|
23
|
+
|
|
24
|
+
const value = createSVGElement('text');
|
|
25
|
+
value.setAttribute('class', 'oc-metric-value');
|
|
26
|
+
setAttrs(value, { x: cell.x, y: cell.valueY });
|
|
27
|
+
value.textContent = cell.metric.value;
|
|
28
|
+
|
|
29
|
+
if (cell.metric.delta) {
|
|
30
|
+
const delta = createSVGElement('tspan');
|
|
31
|
+
const tone = cell.metric.deltaTone ?? 'up';
|
|
32
|
+
delta.setAttribute('class', tone === 'down' ? 'oc-metric-delta-down' : 'oc-metric-delta-up');
|
|
33
|
+
delta.setAttribute('dx', '8');
|
|
34
|
+
delta.textContent = cell.metric.delta;
|
|
35
|
+
value.appendChild(delta);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (cell.metric.secondary) {
|
|
39
|
+
const secondary = createSVGElement('tspan');
|
|
40
|
+
secondary.setAttribute('class', 'oc-metric-secondary');
|
|
41
|
+
secondary.setAttribute('dx', '6');
|
|
42
|
+
secondary.textContent = cell.metric.secondary;
|
|
43
|
+
value.appendChild(secondary);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
g.appendChild(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
parent.appendChild(g);
|
|
50
|
+
}
|