@opendata-ai/openchart-vanilla 6.28.6 → 7.0.2

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.
@@ -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 uses center alignment for a cleaner look
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
- text.setAttribute('text-anchor', 'middle');
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);
@@ -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
- g.setAttribute('class', `oc-axis oc-axis-${isRight ? 'y2' : orientation}`);
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);
@@ -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);
@@ -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
- element.text,
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 = element.text;
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
+ }
@@ -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 rect = createSVGElement('rect');
96
- setAttrs(rect, {
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: offsetY,
85
+ y: chipY,
99
86
  width: legend.swatchSize,
100
- height: legend.swatchSize,
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(rect);
109
+ entryG.appendChild(bar);
105
110
  }
106
111
 
107
112
  // Label
@@ -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
- const rect = createSVGElement('rect');
193
- setAttrs(rect, {
194
- x: mark.x,
195
- y: mark.y,
196
- width: mark.width,
197
- height: mark.height,
198
- fill: resolveMarkFill(mark.fill, currentGradientMap),
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
- rect.setAttribute('stroke', mark.stroke);
249
+ shapeEl.setAttribute('stroke', mark.stroke);
202
250
  }
203
251
  if (mark.strokeWidth) {
204
- rect.setAttribute('stroke-width', String(mark.strokeWidth));
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(rect);
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.split(':')[0]?.trim();
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
- // Add ARIA label if present
401
- if (mark.aria?.label) {
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
+ }