@opendata-ai/openchart-vanilla 6.27.0 → 6.28.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.
- package/dist/index.d.ts +36 -2
- package/dist/index.js +901 -486
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +3 -3
- package/src/barlist-mount.ts +314 -0
- package/src/barlist-renderer.ts +264 -0
- package/src/index.ts +3 -0
- package/src/renderers/axes.ts +2 -2
- package/src/svg-renderer.ts +8 -2
- package/src/tilemap-renderer.ts +13 -4
|
@@ -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';
|
package/src/renderers/axes.ts
CHANGED
|
@@ -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, {
|
package/src/svg-renderer.ts
CHANGED
|
@@ -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);
|
package/src/tilemap-renderer.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
328
|
-
rx:
|
|
336
|
+
height: barHeight,
|
|
337
|
+
rx: barHeight / 2,
|
|
329
338
|
fill: `url(#${gradientId})`,
|
|
330
339
|
});
|
|
331
340
|
g.appendChild(bar);
|