@opendata-ai/openchart-vanilla 6.27.2 → 6.28.4

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,264 @@
1
+ /**
2
+ * BarList SVG renderer: converts a BarListLayout into SVG DOM elements.
3
+ *
4
+ * Creates an <svg> with rows of label + track + bar + value. Animation is
5
+ * pure CSS, driven by data attributes and CSS custom properties.
6
+ */
7
+
8
+ import type { BarListLayout, BarListRowMark, ResolvedAnimation } from '@opendata-ai/openchart-core';
9
+
10
+ const SVG_NS = 'http://www.w3.org/2000/svg';
11
+ const XLINK_NS = 'http://www.w3.org/1999/xlink';
12
+ const BRAND_URL = 'https://tryopendata.ai';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function createSVGElement(tag: string): SVGElement {
19
+ return document.createElementNS(SVG_NS, tag);
20
+ }
21
+
22
+ function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
23
+ for (const [key, value] of Object.entries(attrs)) {
24
+ el.setAttribute(key, String(value));
25
+ }
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Chrome rendering
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function renderChrome(parent: SVGElement, layout: BarListLayout): void {
33
+ const g = createSVGElement('g');
34
+ g.setAttribute('class', 'oc-chrome');
35
+
36
+ const { chrome } = layout;
37
+ const bottomOffset = layout.area.y + layout.area.height;
38
+
39
+ for (const key of ['title', 'subtitle', 'source', 'byline', 'footer'] as const) {
40
+ const el = chrome[key];
41
+ if (!el) continue;
42
+
43
+ const isBottom = key === 'source' || key === 'byline' || key === 'footer';
44
+ const text = createSVGElement('text');
45
+ setAttrs(text, {
46
+ x: el.x,
47
+ y: isBottom ? bottomOffset + el.y : el.y,
48
+ });
49
+ text.setAttribute('class', `oc-${key}`);
50
+ text.setAttribute('font-family', el.style.fontFamily);
51
+ text.setAttribute('font-size', String(el.style.fontSize));
52
+ text.setAttribute('font-weight', String(el.style.fontWeight));
53
+ (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', el.style.fill);
54
+ text.textContent = el.text;
55
+ g.appendChild(text);
56
+ }
57
+
58
+ parent.appendChild(g);
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Watermark rendering
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function renderWatermark(parent: SVGElement, layout: BarListLayout): void {
66
+ if (layout.width < 480) return;
67
+
68
+ const { width, height, theme } = layout;
69
+ const padding = theme.spacing.padding;
70
+ const rightEdge = width - padding;
71
+ const bottomEdge = height - padding;
72
+ const fill = theme.colors.axis;
73
+
74
+ const a = createSVGElement('a');
75
+ a.setAttribute('href', BRAND_URL);
76
+ a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
77
+ a.setAttribute('target', '_blank');
78
+ a.setAttribute('rel', 'noopener');
79
+ a.setAttribute('class', 'oc-chrome-ref');
80
+
81
+ const text = createSVGElement('text');
82
+ setAttrs(text, {
83
+ x: rightEdge,
84
+ y: bottomEdge,
85
+ 'dominant-baseline': 'alphabetic',
86
+ 'text-anchor': 'end',
87
+ 'font-family': theme.fonts.family,
88
+ 'font-size': 12,
89
+ 'fill-opacity': 0.55,
90
+ });
91
+ (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
92
+
93
+ const trySpan = createSVGElement('tspan');
94
+ setAttrs(trySpan, { 'font-weight': 500 });
95
+ trySpan.textContent = 'try';
96
+
97
+ const openDataSpan = createSVGElement('tspan');
98
+ setAttrs(openDataSpan, { 'font-weight': 600, 'font-size': 16 });
99
+ openDataSpan.textContent = 'OpenData';
100
+
101
+ const aiSpan = createSVGElement('tspan');
102
+ setAttrs(aiSpan, { 'font-weight': 500 });
103
+ aiSpan.textContent = '.ai';
104
+
105
+ text.appendChild(trySpan);
106
+ text.appendChild(openDataSpan);
107
+ text.appendChild(aiSpan);
108
+ a.appendChild(text);
109
+ parent.appendChild(a);
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Row rendering
114
+ // ---------------------------------------------------------------------------
115
+
116
+ function renderRows(
117
+ parent: SVGElement,
118
+ rows: BarListRowMark[],
119
+ animation?: ResolvedAnimation,
120
+ ): void {
121
+ const g = createSVGElement('g');
122
+ g.setAttribute('class', 'oc-barlist-rows');
123
+ g.setAttribute('role', 'list');
124
+
125
+ for (const row of rows) {
126
+ const rowGroup = createSVGElement('g');
127
+ rowGroup.setAttribute('class', 'oc-barlist-row');
128
+ rowGroup.setAttribute('data-row-index', String(row.index));
129
+ rowGroup.setAttribute('role', 'listitem');
130
+ if (row.aria?.label) {
131
+ rowGroup.setAttribute('aria-label', row.aria.label);
132
+ }
133
+
134
+ if (animation?.enabled) {
135
+ rowGroup.setAttribute('data-animation-index', String(row.animationIndex));
136
+ const style = (rowGroup as SVGElement & ElementCSSInlineStyle).style;
137
+ style.setProperty('--oc-mark-index', String(row.animationIndex));
138
+ style.setProperty('--oc-row-delay', `${row.animationIndex * 40}ms`);
139
+ }
140
+
141
+ // Label text
142
+ const labelEl = createSVGElement('text');
143
+ setAttrs(labelEl, {
144
+ x: row.label.x,
145
+ y: row.label.y,
146
+ 'dominant-baseline': 'central',
147
+ 'text-anchor': 'start',
148
+ 'font-family': row.label.style.fontFamily,
149
+ 'font-size': row.label.style.fontSize,
150
+ 'font-weight': row.label.style.fontWeight,
151
+ });
152
+ (labelEl as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', row.label.style.fill);
153
+ labelEl.textContent = row.label.text;
154
+ rowGroup.appendChild(labelEl);
155
+
156
+ // Subtitle text (if present)
157
+ if (row.subtitle?.visible) {
158
+ const subEl = createSVGElement('text');
159
+ setAttrs(subEl, {
160
+ x: row.subtitle.x,
161
+ y: row.subtitle.y,
162
+ 'dominant-baseline': 'central',
163
+ 'text-anchor': 'start',
164
+ 'font-family': row.subtitle.style.fontFamily,
165
+ 'font-size': row.subtitle.style.fontSize,
166
+ 'font-weight': row.subtitle.style.fontWeight,
167
+ });
168
+ (subEl as SVGElement & ElementCSSInlineStyle).style.setProperty(
169
+ 'fill',
170
+ row.subtitle.style.fill,
171
+ );
172
+ subEl.textContent = row.subtitle.text;
173
+ rowGroup.appendChild(subEl);
174
+ }
175
+
176
+ // Track (muted background bar)
177
+ const trackRect = createSVGElement('rect');
178
+ setAttrs(trackRect, {
179
+ x: row.track.x,
180
+ y: row.track.y,
181
+ width: row.track.width,
182
+ height: row.track.height,
183
+ rx: row.track.cornerRadius,
184
+ fill: 'currentColor',
185
+ 'fill-opacity': 0.06,
186
+ });
187
+ trackRect.setAttribute('class', 'oc-barlist-track');
188
+ rowGroup.appendChild(trackRect);
189
+
190
+ // Fill bar
191
+ const barRect = createSVGElement('rect');
192
+ setAttrs(barRect, {
193
+ x: row.bar.x,
194
+ y: row.bar.y,
195
+ width: row.bar.width,
196
+ height: row.bar.height,
197
+ rx: row.bar.cornerRadius,
198
+ fill: row.bar.fill,
199
+ });
200
+ barRect.setAttribute('class', 'oc-barlist-bar');
201
+ rowGroup.appendChild(barRect);
202
+
203
+ // Value label (right-aligned)
204
+ const valueEl = createSVGElement('text');
205
+ setAttrs(valueEl, {
206
+ x: row.valueLabel.x,
207
+ y: row.valueLabel.y,
208
+ 'dominant-baseline': 'central',
209
+ 'text-anchor': 'end',
210
+ 'font-family': row.valueLabel.style.fontFamily,
211
+ 'font-size': row.valueLabel.style.fontSize,
212
+ 'font-weight': row.valueLabel.style.fontWeight,
213
+ });
214
+ (valueEl as SVGElement & ElementCSSInlineStyle).style.setProperty(
215
+ 'fill',
216
+ row.valueLabel.style.fill,
217
+ );
218
+ valueEl.textContent = row.valueLabel.text;
219
+ rowGroup.appendChild(valueEl);
220
+
221
+ g.appendChild(rowGroup);
222
+ }
223
+
224
+ parent.appendChild(g);
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Public API
229
+ // ---------------------------------------------------------------------------
230
+
231
+ export function renderBarListSVG(
232
+ layout: BarListLayout,
233
+ opts?: { animate?: boolean },
234
+ ): SVGSVGElement {
235
+ const { width, height, rows, a11y, watermark, animation } = layout;
236
+ const animate = opts?.animate && animation?.enabled;
237
+
238
+ const svg = createSVGElement('svg') as SVGSVGElement;
239
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
240
+ svg.setAttribute('role', 'list');
241
+ // No explicit width attribute — CSS width:100% on .oc-barlist-root handles it.
242
+ // Explicit pixel height via inline style avoids iOS Safari's height:100% quirk.
243
+ svg.style.height = `${height}px`;
244
+ if (a11y.altText) {
245
+ svg.setAttribute('aria-label', a11y.altText);
246
+ }
247
+
248
+ const classes = animate ? 'oc-barlist oc-animate' : 'oc-barlist';
249
+ svg.setAttribute('class', classes);
250
+
251
+ if (animate && animation) {
252
+ svg.style.setProperty('--oc-animation-duration', `${animation.duration}ms`);
253
+ svg.style.setProperty('--oc-animation-stagger', '40ms');
254
+ }
255
+
256
+ renderChrome(svg, layout);
257
+ renderRows(svg, rows, animate ? animation : undefined);
258
+
259
+ if (watermark) {
260
+ renderWatermark(svg, layout);
261
+ }
262
+
263
+ return svg;
264
+ }
package/src/index.ts CHANGED
@@ -17,6 +17,9 @@ export type {
17
17
  TableSpec,
18
18
  VizSpec,
19
19
  } from '@opendata-ai/openchart-engine';
20
+ export type { BarListInstance, BarListMountOptions } from './barlist-mount';
21
+ // BarList mount API
22
+ export { createBarList } from './barlist-mount';
20
23
  export type { JPGExportOptions, PNGExportOptions, SVGExportOptions } from './export';
21
24
  // Export utilities
22
25
  export { exportCSV, exportJPG, exportPNG, exportSVG, exportSVGWithFonts } from './export';
@@ -41,9 +41,9 @@ function renderAxis(
41
41
 
42
42
  const { area } = layout;
43
43
 
44
- // Only draw axis line for x-axis (bottom baseline).
44
+ // Only draw axis line for x-axis (bottom baseline), unless explicitly disabled.
45
45
  // Horizontal gridlines already guide y-values, so the vertical y-axis line is redundant.
46
- if (orientation === 'x') {
46
+ if (orientation === 'x' && axis.domainLine !== false) {
47
47
  const line = createSVGElement('line');
48
48
  line.setAttribute('class', 'oc-axis-line');
49
49
  setAttrs(line, {
@@ -122,12 +122,18 @@ export function renderChartSVG(
122
122
  const defs = createSVGElement('defs');
123
123
  const clipPath = createSVGElement('clipPath');
124
124
  clipPath.setAttribute('id', clipId);
125
+ const maxPointR = layout.marks.reduce(
126
+ (max, m) =>
127
+ m.type === 'point' && (m as { r?: number }).r ? Math.max(max, (m as { r?: number }).r!) : max,
128
+ 0,
129
+ );
130
+ const clipPad = Math.max(maxPointR, 2);
125
131
  const clipRect = createSVGElement('rect');
126
132
  setAttrs(clipRect, {
127
133
  x: 0,
128
- y: layout.area.y,
134
+ y: layout.area.y - clipPad,
129
135
  width,
130
- height: layout.area.height + 2,
136
+ height: layout.area.height + clipPad * 2,
131
137
  });
132
138
  clipPath.appendChild(clipRect);
133
139
  defs.appendChild(clipPath);
@@ -235,6 +235,7 @@ function renderTiles(
235
235
  height: tile.size,
236
236
  rx: tile.cornerRadius,
237
237
  fill: tile.fill,
238
+ 'fill-opacity': tile.fillOpacity ?? 1,
238
239
  stroke: tile.stroke,
239
240
  'stroke-width': tile.strokeWidth,
240
241
  });
@@ -312,20 +313,28 @@ function renderGradientLegend(parent: SVGElement, layout: TileMapLayout): void {
312
313
 
313
314
  for (const stop of gradientLegend.colorStops) {
314
315
  const s = createSVGElement('stop');
315
- setAttrs(s, { offset: `${stop.offset * 100}%`, 'stop-color': stop.color });
316
+ const attrs: Record<string, string | number> = {
317
+ offset: `${stop.offset * 100}%`,
318
+ 'stop-color': stop.color,
319
+ };
320
+ if (stop.opacity !== undefined) {
321
+ attrs['stop-opacity'] = stop.opacity;
322
+ }
323
+ setAttrs(s, attrs);
316
324
  grad.appendChild(s);
317
325
  }
318
326
 
319
327
  (defs as SVGElement).appendChild(grad);
320
328
 
321
- // Gradient bar
329
+ // Gradient bar (pill-shaped)
330
+ const barHeight = gradientLegend.bounds.height;
322
331
  const bar = createSVGElement('rect');
323
332
  setAttrs(bar, {
324
333
  x: gradientLegend.bounds.x,
325
334
  y: gradientLegend.bounds.y,
326
335
  width: gradientLegend.bounds.width,
327
- height: gradientLegend.bounds.height,
328
- rx: 3,
336
+ height: barHeight,
337
+ rx: barHeight / 2,
329
338
  fill: `url(#${gradientId})`,
330
339
  });
331
340
  g.appendChild(bar);