@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21
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/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
- package/dist/chunk-DCODCLWI.js +234 -0
- package/dist/chunk-DCODCLWI.js.map +1 -0
- package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
- package/dist/chunk-EKAUQAAX.js.map +1 -0
- package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
- package/dist/chunk-KNT5AZ2O.js.map +1 -0
- package/dist/chunk-RM5XS6NR.js +113 -0
- package/dist/chunk-RM5XS6NR.js.map +1 -0
- package/dist/chunk-T32UHMZA.js +282 -0
- package/dist/chunk-T32UHMZA.js.map +1 -0
- package/dist/export-gif.d.ts +113 -0
- package/dist/export-gif.js +21 -0
- package/dist/export-gif.js.map +1 -0
- package/dist/index.d.ts +283 -24
- package/dist/index.js +2012 -763
- package/dist/index.js.map +1 -1
- package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
- package/dist/simulation-worker.js +83 -2
- package/dist/static.js +7 -69
- package/dist/static.js.map +1 -1
- package/dist/story/index.d.ts +3 -2
- package/dist/story/index.js +5 -3
- package/dist/story/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +14 -6
- package/dist/chunk-5EOV5JQZ.js.map +0 -1
- package/dist/chunk-ENXSUXC5.js.map +0 -1
- package/src/__test-fixtures__/dom.ts +0 -70
- package/src/__test-fixtures__/specs.ts +0 -214
- package/src/__tests__/animation.test.ts +0 -358
- package/src/__tests__/annotations-render.test.ts +0 -201
- package/src/__tests__/compound-labels.test.ts +0 -122
- package/src/__tests__/continuous-legend.test.ts +0 -91
- package/src/__tests__/crosshair.test.ts +0 -130
- package/src/__tests__/edit-events.test.ts +0 -902
- package/src/__tests__/events.test.ts +0 -387
- package/src/__tests__/export.test.ts +0 -241
- package/src/__tests__/font-race.test.ts +0 -199
- package/src/__tests__/gradient-ids.test.ts +0 -159
- package/src/__tests__/gradient-utils.test.ts +0 -125
- package/src/__tests__/known-bugs-render.test.ts +0 -183
- package/src/__tests__/map.test.ts +0 -293
- package/src/__tests__/mount.test.ts +0 -357
- package/src/__tests__/pattern-fills.test.ts +0 -105
- package/src/__tests__/resize-timing.test.ts +0 -218
- package/src/__tests__/sankey.test.ts +0 -133
- package/src/__tests__/selection-events.test.ts +0 -869
- package/src/__tests__/series-search.test.ts +0 -311
- package/src/__tests__/static.test.ts +0 -141
- package/src/__tests__/svg-renderer.test.ts +0 -895
- package/src/__tests__/table-keyboard.test.ts +0 -361
- package/src/__tests__/table-mount.test.ts +0 -639
- package/src/__tests__/tilemap.test.ts +0 -158
- package/src/__tests__/tooltip.test.ts +0 -337
- package/src/__tests__/transition.test.ts +0 -1777
- package/src/__tests__/you-draw-it.test.ts +0 -273
- package/src/animation-vars.ts +0 -42
- package/src/animation.ts +0 -76
- package/src/barlist-mount.ts +0 -383
- package/src/barlist-renderer.ts +0 -267
- package/src/dom-helpers.ts +0 -17
- package/src/export.ts +0 -482
- package/src/gradient-utils.ts +0 -138
- package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
- package/src/graph/__tests__/graph-mount.test.ts +0 -213
- package/src/graph/__tests__/interaction.test.ts +0 -205
- package/src/graph/__tests__/keyboard.test.ts +0 -653
- package/src/graph/__tests__/search.test.ts +0 -88
- package/src/graph/__tests__/simulation.test.ts +0 -233
- package/src/graph/__tests__/spatial-index.test.ts +0 -142
- package/src/graph/__tests__/zoom.test.ts +0 -203
- package/src/graph/canvas-renderer.ts +0 -747
- package/src/graph/interaction.ts +0 -367
- package/src/graph/keyboard.ts +0 -208
- package/src/graph/search.ts +0 -50
- package/src/graph/simulation-worker-url.ts +0 -12
- package/src/graph/simulation-worker.ts +0 -278
- package/src/graph/simulation.ts +0 -422
- package/src/graph/spatial-index.ts +0 -121
- package/src/graph/types.ts +0 -47
- package/src/graph/worker-protocol.ts +0 -70
- package/src/graph/zoom.ts +0 -134
- package/src/graph-mount.ts +0 -888
- package/src/index.ts +0 -76
- package/src/interactions/chart-events.ts +0 -139
- package/src/interactions/crosshair.ts +0 -233
- package/src/interactions/drag-handler.ts +0 -175
- package/src/interactions/editing-drags.ts +0 -515
- package/src/interactions/index.ts +0 -25
- package/src/interactions/keyboard-nav.ts +0 -111
- package/src/interactions/legend-interaction.ts +0 -46
- package/src/interactions/selection.ts +0 -280
- package/src/interactions/tooltip-events.ts +0 -72
- package/src/map-camera.ts +0 -88
- package/src/map-mount.ts +0 -701
- package/src/map-renderer.ts +0 -338
- package/src/map-transition.ts +0 -83
- package/src/measure-text.ts +0 -134
- package/src/mount.ts +0 -1382
- package/src/pattern-utils.ts +0 -110
- package/src/renderers/annotations.ts +0 -388
- package/src/renderers/axes.ts +0 -351
- package/src/renderers/brand.ts +0 -87
- package/src/renderers/chrome.ts +0 -196
- package/src/renderers/endpoint-labels.ts +0 -150
- package/src/renderers/legend.ts +0 -311
- package/src/renderers/marks.ts +0 -561
- package/src/renderers/metrics.ts +0 -63
- package/src/renderers/svg-dom.ts +0 -40
- package/src/renderers/table-cells.ts +0 -446
- package/src/resize-observer.ts +0 -46
- package/src/sankey-mount.ts +0 -622
- package/src/sankey-renderer.ts +0 -614
- package/src/series-search.ts +0 -392
- package/src/static.ts +0 -251
- package/src/story/__tests__/camera-math.test.ts +0 -145
- package/src/story/__tests__/create-chart-story.test.ts +0 -317
- package/src/story/__tests__/progress-math.test.ts +0 -95
- package/src/story/__tests__/scroll-driver.test.ts +0 -130
- package/src/story/__tests__/tween.test.ts +0 -159
- package/src/story/camera-math.ts +0 -125
- package/src/story/create-chart-story.ts +0 -353
- package/src/story/crossfade.ts +0 -70
- package/src/story/index.ts +0 -39
- package/src/story/progress-math.ts +0 -85
- package/src/story/resolve-camera-target.ts +0 -119
- package/src/story/scroll-driver.ts +0 -206
- package/src/story/story-camera.ts +0 -49
- package/src/story/tween.ts +0 -112
- package/src/story/types.ts +0 -76
- package/src/svg-ids.ts +0 -23
- package/src/svg-renderer.ts +0 -383
- package/src/table-keyboard.ts +0 -266
- package/src/table-mount.ts +0 -567
- package/src/table-renderer.ts +0 -414
- package/src/text-edit-overlay.ts +0 -255
- package/src/theme-tokens.ts +0 -51
- package/src/tilemap-mount.ts +0 -511
- package/src/tilemap-renderer.ts +0 -396
- package/src/tooltip.ts +0 -177
- package/src/transition.ts +0 -2816
- package/src/you-draw-it.ts +0 -480
- /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
package/src/renderers/axes.ts
DELETED
|
@@ -1,351 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Axis rendering: axis line, ticks, tick labels, gridlines, axis title.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { AxisLayout, ChartLayout } from '@opendata-ai/openchart-core';
|
|
6
|
-
import {
|
|
7
|
-
axisTitleOffset,
|
|
8
|
-
estimateTextWidth,
|
|
9
|
-
getAxisTitleOffset,
|
|
10
|
-
TICK_LABEL_OFFSET,
|
|
11
|
-
textAscent,
|
|
12
|
-
} from '@opendata-ai/openchart-core';
|
|
13
|
-
import { serializeKeyValue } from '@opendata-ai/openchart-engine';
|
|
14
|
-
import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
|
|
15
|
-
|
|
16
|
-
function appendCompoundLabel(
|
|
17
|
-
parent: SVGElement,
|
|
18
|
-
primaryText: string,
|
|
19
|
-
subtitle: string,
|
|
20
|
-
fontWeight: number,
|
|
21
|
-
): void {
|
|
22
|
-
const primarySpan = createSVGElement('tspan');
|
|
23
|
-
primarySpan.setAttribute('font-weight', String(fontWeight));
|
|
24
|
-
primarySpan.textContent = primaryText;
|
|
25
|
-
parent.appendChild(primarySpan);
|
|
26
|
-
|
|
27
|
-
const subtitleSpan = createSVGElement('tspan');
|
|
28
|
-
subtitleSpan.setAttribute('dx', '0.5em');
|
|
29
|
-
subtitleSpan.textContent = subtitle;
|
|
30
|
-
subtitleSpan.setAttribute('font-weight', '400');
|
|
31
|
-
subtitleSpan.setAttribute('fill-opacity', '0.6');
|
|
32
|
-
parent.appendChild(subtitleSpan);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function renderAxis(
|
|
36
|
-
parent: SVGElement,
|
|
37
|
-
axis: AxisLayout,
|
|
38
|
-
orientation: 'x' | 'y',
|
|
39
|
-
layout: ChartLayout,
|
|
40
|
-
): void {
|
|
41
|
-
const g = createSVGElement('g');
|
|
42
|
-
const isRight = orientation === 'y' && axis.orient === 'right';
|
|
43
|
-
const isInlineY = orientation === 'y' && axis.tickPosition === 'inline' && !isRight;
|
|
44
|
-
g.setAttribute(
|
|
45
|
-
'class',
|
|
46
|
-
`oc-axis oc-axis-${isRight ? 'y2' : orientation}${isInlineY ? ' oc-axis-inline' : ''}`,
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
const { area } = layout;
|
|
50
|
-
|
|
51
|
-
// Only draw axis line for x-axis (bottom baseline), unless explicitly disabled.
|
|
52
|
-
// Horizontal gridlines already guide y-values, so the vertical y-axis line is redundant.
|
|
53
|
-
if (orientation === 'x' && axis.domainLine !== false) {
|
|
54
|
-
const line = createSVGElement('line');
|
|
55
|
-
line.setAttribute('class', 'oc-axis-line');
|
|
56
|
-
setAttrs(line, {
|
|
57
|
-
x1: axis.start.x,
|
|
58
|
-
y1: axis.start.y,
|
|
59
|
-
x2: axis.end.x,
|
|
60
|
-
y2: axis.end.y,
|
|
61
|
-
stroke: layout.theme.colors.axis,
|
|
62
|
-
'stroke-width': 1,
|
|
63
|
-
});
|
|
64
|
-
g.appendChild(line);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Ticks and labels
|
|
68
|
-
// Tick positions are absolute pixel coordinates from D3 scales whose range
|
|
69
|
-
// was set to [chartArea.x, chartArea.x + chartArea.width] (and similarly for y).
|
|
70
|
-
// Don't add area.x/area.y again or you'll double-offset everything.
|
|
71
|
-
for (const tick of axis.ticks) {
|
|
72
|
-
// Stable key for data-update transitions
|
|
73
|
-
const tickKey = serializeKeyValue(tick.value);
|
|
74
|
-
|
|
75
|
-
if (orientation === 'x') {
|
|
76
|
-
// Label (no tick marks -- gridlines provide sufficient reference)
|
|
77
|
-
const label = createSVGElement('text');
|
|
78
|
-
label.setAttribute('class', 'oc-axis-tick');
|
|
79
|
-
label.setAttribute('data-tick-key', tickKey);
|
|
80
|
-
|
|
81
|
-
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
|
|
82
|
-
// Rotated labels: anchor at the rotation pivot point
|
|
83
|
-
const labelX = tick.position;
|
|
84
|
-
const xLabelPad = axis.labelPadding ?? layout.theme.spacing.xAxisLabelPadding;
|
|
85
|
-
const labelY = area.y + area.height + xLabelPad;
|
|
86
|
-
setAttrs(label, {
|
|
87
|
-
x: labelX,
|
|
88
|
-
y: labelY,
|
|
89
|
-
'text-anchor': axis.tickAngle < 0 ? 'end' : 'start',
|
|
90
|
-
'dominant-baseline': 'central',
|
|
91
|
-
transform: `rotate(${axis.tickAngle}, ${labelX}, ${labelY})`,
|
|
92
|
-
});
|
|
93
|
-
} else {
|
|
94
|
-
const xLabelPad = axis.labelPadding ?? layout.theme.spacing.xAxisLabelPadding;
|
|
95
|
-
// xLabelPad is the literal gap between the axis line and the TOP of
|
|
96
|
-
// the label regardless of font size, so shift down by the ascent to
|
|
97
|
-
// land on the alphabetic baseline. (dominant-baseline:hanging would
|
|
98
|
-
// express this directly, but WebKit positions hanging from different
|
|
99
|
-
// font metrics than Blink, drifting labels on iOS Safari.)
|
|
100
|
-
setAttrs(label, {
|
|
101
|
-
x: tick.position,
|
|
102
|
-
y: area.y + area.height + xLabelPad + textAscent(axis.tickLabelStyle.fontSize),
|
|
103
|
-
'text-anchor': 'middle',
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
applyTextStyle(label, axis.tickLabelStyle);
|
|
108
|
-
label.textContent = tick.label;
|
|
109
|
-
// The engine ellipsizes rotated labels that would overflow the reserved
|
|
110
|
-
// band and stashes the original in `fullLabel`. Expose it as a <title> so
|
|
111
|
-
// the complete category is still reachable on hover and to a screen
|
|
112
|
-
// reader — truncation is a visual affordance, not data loss.
|
|
113
|
-
if (tick.fullLabel) {
|
|
114
|
-
const title = createSVGElement('title');
|
|
115
|
-
title.textContent = tick.fullLabel;
|
|
116
|
-
label.appendChild(title);
|
|
117
|
-
}
|
|
118
|
-
g.appendChild(label);
|
|
119
|
-
} else if (isInlineY) {
|
|
120
|
-
// Inline y-tick: label sits above its gridline at the chart-area left
|
|
121
|
-
// edge, no gutter reserved. The gridline itself is the visual axis.
|
|
122
|
-
const label = createSVGElement('text');
|
|
123
|
-
label.setAttribute('class', 'oc-axis-tick oc-axis-tick-inline');
|
|
124
|
-
label.setAttribute('data-tick-key', tickKey);
|
|
125
|
-
setAttrs(label, {
|
|
126
|
-
x: area.x,
|
|
127
|
-
y: tick.position - 6,
|
|
128
|
-
'text-anchor': 'start',
|
|
129
|
-
});
|
|
130
|
-
applyTextStyle(label, axis.tickLabelStyle);
|
|
131
|
-
label.textContent = tick.label;
|
|
132
|
-
g.appendChild(label);
|
|
133
|
-
} else {
|
|
134
|
-
const label = createSVGElement('text');
|
|
135
|
-
label.setAttribute('class', 'oc-axis-tick');
|
|
136
|
-
label.setAttribute('data-tick-key', tickKey);
|
|
137
|
-
setAttrs(label, {
|
|
138
|
-
x: isRight ? area.x + area.width + TICK_LABEL_OFFSET : area.x - TICK_LABEL_OFFSET,
|
|
139
|
-
y: tick.position,
|
|
140
|
-
'text-anchor': isRight ? 'start' : 'end',
|
|
141
|
-
'dominant-baseline': 'central',
|
|
142
|
-
});
|
|
143
|
-
applyTextStyle(label, axis.tickLabelStyle);
|
|
144
|
-
if (!isRight) {
|
|
145
|
-
// Truncate categorical left y-axis labels that exceed available space
|
|
146
|
-
const availableWidth = area.x - TICK_LABEL_OFFSET;
|
|
147
|
-
const fontSize = axis.tickLabelStyle.fontSize;
|
|
148
|
-
const fontWeight = axis.tickLabelStyle.fontWeight;
|
|
149
|
-
|
|
150
|
-
if (tick.subtitle) {
|
|
151
|
-
// Compound label: primary + gap + subtitle via tspan elements
|
|
152
|
-
const gapWidth = fontSize * 0.5;
|
|
153
|
-
const subtitleWidth = estimateTextWidth(tick.subtitle, fontSize, fontWeight);
|
|
154
|
-
const primaryWidth = estimateTextWidth(tick.label, fontSize, fontWeight);
|
|
155
|
-
const totalWidth = primaryWidth + gapWidth + subtitleWidth;
|
|
156
|
-
|
|
157
|
-
if (totalWidth > availableWidth && availableWidth > 20) {
|
|
158
|
-
const ellipsis = '…';
|
|
159
|
-
const ellipsisWidth = estimateTextWidth(ellipsis, fontSize, fontWeight);
|
|
160
|
-
const budgetForPrimary = availableWidth - gapWidth - subtitleWidth - ellipsisWidth;
|
|
161
|
-
|
|
162
|
-
let primaryText = tick.label;
|
|
163
|
-
if (budgetForPrimary > 0) {
|
|
164
|
-
let lo = 0;
|
|
165
|
-
let hi = tick.label.length;
|
|
166
|
-
while (lo < hi) {
|
|
167
|
-
const mid = (lo + hi + 1) >>> 1;
|
|
168
|
-
const candidate = tick.label.slice(0, mid);
|
|
169
|
-
if (estimateTextWidth(candidate, fontSize, fontWeight) <= budgetForPrimary) {
|
|
170
|
-
lo = mid;
|
|
171
|
-
} else {
|
|
172
|
-
hi = mid - 1;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
primaryText = lo > 0 ? tick.label.slice(0, lo).trimEnd() + ellipsis : ellipsis;
|
|
176
|
-
} else {
|
|
177
|
-
primaryText = ellipsis;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
appendCompoundLabel(label, primaryText, tick.subtitle, fontWeight);
|
|
181
|
-
|
|
182
|
-
const titleEl = createSVGElement('title');
|
|
183
|
-
titleEl.textContent = `${tick.label} ${tick.subtitle}`;
|
|
184
|
-
label.appendChild(titleEl);
|
|
185
|
-
} else {
|
|
186
|
-
appendCompoundLabel(label, tick.label, tick.subtitle, fontWeight);
|
|
187
|
-
}
|
|
188
|
-
} else {
|
|
189
|
-
// Plain label (no subtitle)
|
|
190
|
-
const fullWidth = estimateTextWidth(tick.label, fontSize, fontWeight);
|
|
191
|
-
if (fullWidth > availableWidth && availableWidth > 20) {
|
|
192
|
-
const ellipsis = '…';
|
|
193
|
-
const ellipsisWidth = estimateTextWidth(ellipsis, fontSize, fontWeight);
|
|
194
|
-
let lo = 0;
|
|
195
|
-
let hi = tick.label.length;
|
|
196
|
-
while (lo < hi) {
|
|
197
|
-
const mid = (lo + hi + 1) >>> 1;
|
|
198
|
-
const candidate = tick.label.slice(0, mid);
|
|
199
|
-
if (
|
|
200
|
-
estimateTextWidth(candidate, fontSize, fontWeight) + ellipsisWidth <=
|
|
201
|
-
availableWidth
|
|
202
|
-
) {
|
|
203
|
-
lo = mid;
|
|
204
|
-
} else {
|
|
205
|
-
hi = mid - 1;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
label.textContent = lo > 0 ? tick.label.slice(0, lo).trimEnd() + ellipsis : ellipsis;
|
|
209
|
-
const titleEl = createSVGElement('title');
|
|
210
|
-
titleEl.textContent = tick.label;
|
|
211
|
-
label.appendChild(titleEl);
|
|
212
|
-
} else {
|
|
213
|
-
label.textContent = tick.label;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
} else {
|
|
217
|
-
label.textContent = tick.label;
|
|
218
|
-
}
|
|
219
|
-
g.appendChild(label);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Gridlines (positions are also absolute from the scales)
|
|
224
|
-
// Skip gridlines for right-side y-axis (left y-axis gridlines are sufficient)
|
|
225
|
-
if (!isRight) {
|
|
226
|
-
// Build position -> tick value map for keying gridlines
|
|
227
|
-
const posToTickKey = new Map<number, string>();
|
|
228
|
-
for (const tick of axis.ticks) {
|
|
229
|
-
posToTickKey.set(tick.position, serializeKeyValue(tick.value));
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
for (const gridline of axis.gridlines) {
|
|
233
|
-
const gl = createSVGElement('line');
|
|
234
|
-
gl.setAttribute('class', 'oc-gridline');
|
|
235
|
-
// Stamp data-tick-key for data-update transitions
|
|
236
|
-
const glKey = posToTickKey.get(gridline.position);
|
|
237
|
-
if (glKey) {
|
|
238
|
-
gl.setAttribute('data-tick-key', glKey);
|
|
239
|
-
}
|
|
240
|
-
if (orientation === 'y') {
|
|
241
|
-
setAttrs(gl, {
|
|
242
|
-
x1: area.x,
|
|
243
|
-
y1: gridline.position,
|
|
244
|
-
x2: area.x + area.width,
|
|
245
|
-
y2: gridline.position,
|
|
246
|
-
stroke: layout.theme.colors.gridline,
|
|
247
|
-
'stroke-width': 1,
|
|
248
|
-
'stroke-opacity': 0.6,
|
|
249
|
-
});
|
|
250
|
-
} else {
|
|
251
|
-
setAttrs(gl, {
|
|
252
|
-
x1: gridline.position,
|
|
253
|
-
y1: area.y,
|
|
254
|
-
x2: gridline.position,
|
|
255
|
-
y2: area.y + area.height,
|
|
256
|
-
stroke: layout.theme.colors.gridline,
|
|
257
|
-
'stroke-width': 1,
|
|
258
|
-
'stroke-opacity': 0.6,
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
g.appendChild(gl);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Axis label
|
|
266
|
-
if (axis.label && axis.labelStyle) {
|
|
267
|
-
const axisLabel = createSVGElement('text');
|
|
268
|
-
axisLabel.setAttribute('class', 'oc-axis-title');
|
|
269
|
-
applyTextStyle(axisLabel, axis.labelStyle);
|
|
270
|
-
axisLabel.textContent = axis.label;
|
|
271
|
-
|
|
272
|
-
const tp = axis.titlePosition;
|
|
273
|
-
if (tp) {
|
|
274
|
-
const attrs: Record<string, string | number> = {
|
|
275
|
-
x: tp.x,
|
|
276
|
-
y: tp.y,
|
|
277
|
-
'text-anchor': 'middle',
|
|
278
|
-
};
|
|
279
|
-
if (tp.angle) {
|
|
280
|
-
attrs.transform = `rotate(${tp.angle}, ${tp.x}, ${tp.y})`;
|
|
281
|
-
}
|
|
282
|
-
setAttrs(axisLabel, attrs);
|
|
283
|
-
} else if (orientation === 'x') {
|
|
284
|
-
const tickFontSize = axis.tickLabelStyle.fontSize;
|
|
285
|
-
const tickBand = Math.max(26, 4 + tickFontSize + Math.max(11, Math.ceil(tickFontSize * 0.7)));
|
|
286
|
-
let titleY = area.y + area.height + tickBand + 9;
|
|
287
|
-
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
|
|
288
|
-
const angleRad = Math.abs(axis.tickAngle) * (Math.PI / 180);
|
|
289
|
-
let maxLabelWidth = 40;
|
|
290
|
-
for (const tick of axis.ticks) {
|
|
291
|
-
const w = estimateTextWidth(tick.label, tickFontSize, axis.tickLabelStyle.fontWeight);
|
|
292
|
-
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
293
|
-
}
|
|
294
|
-
const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
|
|
295
|
-
titleY = area.y + area.height + rotatedHeight + 14;
|
|
296
|
-
}
|
|
297
|
-
setAttrs(axisLabel, {
|
|
298
|
-
x: area.x + area.width / 2,
|
|
299
|
-
y: titleY,
|
|
300
|
-
'text-anchor': 'middle',
|
|
301
|
-
});
|
|
302
|
-
} else if (isRight) {
|
|
303
|
-
const titleOffset = getAxisTitleOffset(layout.dimensions.width);
|
|
304
|
-
const titleX = area.x + area.width + titleOffset;
|
|
305
|
-
setAttrs(axisLabel, {
|
|
306
|
-
x: titleX,
|
|
307
|
-
y: area.y + area.height / 2,
|
|
308
|
-
'text-anchor': 'middle',
|
|
309
|
-
transform: `rotate(90, ${titleX}, ${area.y + area.height / 2})`,
|
|
310
|
-
});
|
|
311
|
-
} else {
|
|
312
|
-
const maxTickLabelWidth = axis.ticks.reduce((max, t) => {
|
|
313
|
-
const w = estimateTextWidth(
|
|
314
|
-
t.label,
|
|
315
|
-
axis.tickLabelStyle.fontSize,
|
|
316
|
-
axis.tickLabelStyle.fontWeight ?? 400,
|
|
317
|
-
);
|
|
318
|
-
return Math.max(max, w);
|
|
319
|
-
}, 0);
|
|
320
|
-
// Inline y-ticks live inside the plot, so the title clears only the chart
|
|
321
|
-
// edge — mirror the engine's reservation (see axisTitleOffset).
|
|
322
|
-
const titleOffset = axisTitleOffset(
|
|
323
|
-
maxTickLabelWidth,
|
|
324
|
-
axis.labelStyle.fontSize,
|
|
325
|
-
layout.dimensions.width,
|
|
326
|
-
isInlineY,
|
|
327
|
-
);
|
|
328
|
-
setAttrs(axisLabel, {
|
|
329
|
-
x: area.x - titleOffset,
|
|
330
|
-
y: area.y + area.height / 2,
|
|
331
|
-
'text-anchor': 'middle',
|
|
332
|
-
transform: `rotate(-90, ${area.x - titleOffset}, ${area.y + area.height / 2})`,
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
g.appendChild(axisLabel);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
parent.appendChild(g);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
export function renderAxes(parent: SVGElement, layout: ChartLayout): void {
|
|
342
|
-
if (layout.axes.x) {
|
|
343
|
-
renderAxis(parent, layout.axes.x, 'x', layout);
|
|
344
|
-
}
|
|
345
|
-
if (layout.axes.y) {
|
|
346
|
-
renderAxis(parent, layout.axes.y, 'y', layout);
|
|
347
|
-
}
|
|
348
|
-
if (layout.axes.y2) {
|
|
349
|
-
renderAxis(parent, layout.axes.y2, 'y', layout);
|
|
350
|
-
}
|
|
351
|
-
}
|
package/src/renderers/brand.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Brand rendering: the "tryOpenData.ai" watermark footer.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { ChartLayout } from '@opendata-ai/openchart-core';
|
|
6
|
-
import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH, textAscent } from '@opendata-ai/openchart-core';
|
|
7
|
-
import { footnoteBandHeight } from './chrome';
|
|
8
|
-
import { createSVGElement, setAttrs, XLINK_NS } from './svg-dom';
|
|
9
|
-
|
|
10
|
-
const BRAND_URL = 'https://tryopendata.ai';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Render the "OpenData" brand as a footer-row element, right-aligned on the
|
|
14
|
-
* same baseline as the first bottom chrome text (source/byline/footer).
|
|
15
|
-
* Uses the same font size as chrome source text so it blends in as a subtle
|
|
16
|
-
* footer item rather than occupying independent visual space.
|
|
17
|
-
*/
|
|
18
|
-
export function renderBrand(parent: SVGElement, layout: ChartLayout): void {
|
|
19
|
-
if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
|
|
20
|
-
|
|
21
|
-
const { width } = layout.dimensions;
|
|
22
|
-
const padding = layout.theme.spacing.padding;
|
|
23
|
-
const rightEdge = width - padding;
|
|
24
|
-
const fill = layout.theme.colors.axis;
|
|
25
|
-
|
|
26
|
-
// Vertically align with the first bottom chrome element.
|
|
27
|
-
const { chrome } = layout;
|
|
28
|
-
const bottomOffset = chrome.bottomAnchorY ?? layout.area.y + layout.area.height;
|
|
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;
|
|
36
|
-
// renderChrome pushes the whole footer row down past the footnote list; the
|
|
37
|
-
// brand shares that row, so it takes the same shift or the footnotes land on
|
|
38
|
-
// top of it.
|
|
39
|
-
const bandHeight = footnoteBandHeight(layout);
|
|
40
|
-
const chromeY = firstBottom
|
|
41
|
-
? bottomOffset + firstBottom.y + bandHeight
|
|
42
|
-
: bottomOffset + layout.theme.spacing.chartToFooter + bottomLegendOffset + bandHeight;
|
|
43
|
-
|
|
44
|
-
const a = createSVGElement('a');
|
|
45
|
-
a.setAttribute('href', BRAND_URL);
|
|
46
|
-
a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
|
|
47
|
-
a.setAttribute('target', '_blank');
|
|
48
|
-
a.setAttribute('rel', 'noopener');
|
|
49
|
-
a.setAttribute('class', 'oc-chrome-ref');
|
|
50
|
-
|
|
51
|
-
// "try" in normal weight, "OpenData" in semibold, ".ai" in normal weight,
|
|
52
|
-
// rendered as a single right-aligned text element with three tspans.
|
|
53
|
-
// chromeY is the top edge shared with source/byline chrome text; anchor the
|
|
54
|
-
// shared alphabetic baseline from the largest tspan's ascent. (Hanging
|
|
55
|
-
// baseline is avoided: WebKit positions it differently and never inherits
|
|
56
|
-
// it into tspans, which scattered the three spans on iOS Safari.)
|
|
57
|
-
const BRAND_LARGE = 16;
|
|
58
|
-
const text = createSVGElement('text');
|
|
59
|
-
setAttrs(text, {
|
|
60
|
-
x: rightEdge,
|
|
61
|
-
y: chromeY + textAscent(BRAND_LARGE),
|
|
62
|
-
'font-family': layout.theme.fonts.family,
|
|
63
|
-
'font-size': BRAND_FONT_SIZE,
|
|
64
|
-
'text-anchor': 'end',
|
|
65
|
-
'fill-opacity': 0.55,
|
|
66
|
-
});
|
|
67
|
-
(text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
|
|
68
|
-
|
|
69
|
-
const trySpan = createSVGElement('tspan');
|
|
70
|
-
trySpan.setAttribute('font-weight', '500');
|
|
71
|
-
trySpan.textContent = 'try';
|
|
72
|
-
text.appendChild(trySpan);
|
|
73
|
-
|
|
74
|
-
const openDataSpan = createSVGElement('tspan');
|
|
75
|
-
openDataSpan.setAttribute('font-weight', '600');
|
|
76
|
-
openDataSpan.setAttribute('font-size', String(BRAND_LARGE));
|
|
77
|
-
openDataSpan.textContent = 'OpenData';
|
|
78
|
-
text.appendChild(openDataSpan);
|
|
79
|
-
|
|
80
|
-
const aiSpan = createSVGElement('tspan');
|
|
81
|
-
aiSpan.setAttribute('font-weight', '500');
|
|
82
|
-
aiSpan.textContent = '.ai';
|
|
83
|
-
text.appendChild(aiSpan);
|
|
84
|
-
|
|
85
|
-
a.appendChild(text);
|
|
86
|
-
parent.appendChild(a);
|
|
87
|
-
}
|
package/src/renderers/chrome.ts
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chrome rendering: title, subtitle, source, byline, footer.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type {
|
|
6
|
-
ChartLayout,
|
|
7
|
-
MeasureTextFn,
|
|
8
|
-
ResolvedChromeElement,
|
|
9
|
-
} from '@opendata-ai/openchart-core';
|
|
10
|
-
import {
|
|
11
|
-
estimateTextWidth,
|
|
12
|
-
FOOTNOTE_LINE_HEIGHT,
|
|
13
|
-
footnoteBandHeight as footnoteBandHeightFor,
|
|
14
|
-
textAscent,
|
|
15
|
-
wrapText,
|
|
16
|
-
} from '@opendata-ai/openchart-core';
|
|
17
|
-
import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Vertical space the auto-thinning footnote list occupies below the plot.
|
|
21
|
-
* Everything on the footer row (source, byline, footer, brand) shifts down by
|
|
22
|
-
* this much so the footnotes don't land on top of it. Zero when there are no
|
|
23
|
-
* footnotes. The brand watermark renders from a separate path
|
|
24
|
-
* (`renderBrand`), so it has to read the same number from here rather than
|
|
25
|
-
* recompute it.
|
|
26
|
-
*
|
|
27
|
-
* A `ChartLayout`-shaped adapter over the core helper -- deliberately not a
|
|
28
|
-
* second implementation. The engine reserves this band in the bottom margin
|
|
29
|
-
* from the same function, so a local copy of the arithmetic is a standing
|
|
30
|
-
* invitation for the two to drift and drop the footnotes onto the watermark.
|
|
31
|
-
*/
|
|
32
|
-
export function footnoteBandHeight(layout: ChartLayout): number {
|
|
33
|
-
return footnoteBandHeightFor(layout.chrome.footnotes?.length ?? 0, layout.theme);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function renderChromeElement(
|
|
37
|
-
parent: SVGElement,
|
|
38
|
-
element: ResolvedChromeElement,
|
|
39
|
-
className: string,
|
|
40
|
-
chromeKey: string,
|
|
41
|
-
measureText?: MeasureTextFn,
|
|
42
|
-
uppercase = false,
|
|
43
|
-
): void {
|
|
44
|
-
const text = createSVGElement('text');
|
|
45
|
-
// element.y is the TOP of the text box; convert to the alphabetic baseline
|
|
46
|
-
// here instead of relying on dominant-baseline:hanging, which WebKit
|
|
47
|
-
// positions from different font metrics and never inherits into tspans.
|
|
48
|
-
setAttrs(text, { x: element.x, y: element.y + textAscent(element.style.fontSize) });
|
|
49
|
-
applyTextStyle(text, element.style);
|
|
50
|
-
text.setAttribute('class', className);
|
|
51
|
-
text.setAttribute('data-chrome-key', chromeKey);
|
|
52
|
-
|
|
53
|
-
// happy-dom doesn't apply CSS text-transform inside SVG measurement, so
|
|
54
|
-
// pre-uppercase the rendered text for elements that style as uppercase.
|
|
55
|
-
// Browsers honor the CSS rule too, so this is double-applied harmlessly.
|
|
56
|
-
const renderedText = uppercase ? element.text.toUpperCase() : element.text;
|
|
57
|
-
|
|
58
|
-
const lines = wrapText(
|
|
59
|
-
renderedText,
|
|
60
|
-
element.style.fontSize,
|
|
61
|
-
element.style.fontWeight,
|
|
62
|
-
element.maxWidth,
|
|
63
|
-
measureText,
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
if (lines.length === 1) {
|
|
67
|
-
text.textContent = renderedText;
|
|
68
|
-
} else {
|
|
69
|
-
const lineHeight = element.style.fontSize * (element.style.lineHeight ?? 1.3);
|
|
70
|
-
for (let i = 0; i < lines.length; i++) {
|
|
71
|
-
const tspan = createSVGElement('tspan');
|
|
72
|
-
setAttrs(tspan, { x: element.x, dy: i === 0 ? 0 : lineHeight });
|
|
73
|
-
tspan.textContent = lines[i];
|
|
74
|
-
text.appendChild(tspan);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
parent.appendChild(text);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
82
|
-
const g = createSVGElement('g');
|
|
83
|
-
g.setAttribute('class', 'oc-chrome');
|
|
84
|
-
|
|
85
|
-
const { chrome, measureText } = layout;
|
|
86
|
-
|
|
87
|
-
// Top chrome: render at their stored y positions (already absolute)
|
|
88
|
-
if (chrome.eyebrow) {
|
|
89
|
-
// Leading accent dot — matches the editorial design system mock.
|
|
90
|
-
// eyebrow.y is the top of the text box. Visual center is roughly
|
|
91
|
-
// y + fontSize * 0.55 (cap height).
|
|
92
|
-
const eyebrow = chrome.eyebrow;
|
|
93
|
-
const dotR = 3;
|
|
94
|
-
const dotGap = 8;
|
|
95
|
-
const dotX = eyebrow.x + dotR;
|
|
96
|
-
const dotY = eyebrow.y + eyebrow.style.fontSize * 0.42;
|
|
97
|
-
const dot = createSVGElement('circle');
|
|
98
|
-
dot.setAttribute('class', 'oc-eyebrow-dot');
|
|
99
|
-
setAttrs(dot, { cx: dotX, cy: dotY, r: dotR });
|
|
100
|
-
dot.setAttribute('fill', eyebrow.style.fill ?? 'currentColor');
|
|
101
|
-
g.appendChild(dot);
|
|
102
|
-
|
|
103
|
-
const shifted: ResolvedChromeElement = {
|
|
104
|
-
...eyebrow,
|
|
105
|
-
x: eyebrow.x + dotR * 2 + dotGap,
|
|
106
|
-
};
|
|
107
|
-
renderChromeElement(g, shifted, 'oc-eyebrow', 'eyebrow', measureText, true);
|
|
108
|
-
}
|
|
109
|
-
if (chrome.title) {
|
|
110
|
-
renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);
|
|
111
|
-
}
|
|
112
|
-
if (chrome.subtitle) {
|
|
113
|
-
renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const bottomOffset = layout.chrome.bottomAnchorY ?? layout.area.y + layout.area.height;
|
|
117
|
-
|
|
118
|
-
// Footnotes from auto-thinned annotations, rendered above source/byline.
|
|
119
|
-
// Each footnote gets its own line to avoid horizontal overflow.
|
|
120
|
-
const bandHeight = footnoteBandHeight(layout);
|
|
121
|
-
if (chrome.footnotes && chrome.footnotes.length > 0) {
|
|
122
|
-
const fontSize = layout.theme.fonts.sizes.small;
|
|
123
|
-
const pad = layout.theme.spacing.padding;
|
|
124
|
-
const lineHeight = fontSize * FOOTNOTE_LINE_HEIGHT;
|
|
125
|
-
const style = {
|
|
126
|
-
fontFamily: layout.theme.fonts.family,
|
|
127
|
-
fontSize,
|
|
128
|
-
fontWeight: layout.theme.fonts.weights.normal,
|
|
129
|
-
fill: layout.theme.chrome.source.color,
|
|
130
|
-
lineHeight: FOOTNOTE_LINE_HEIGHT,
|
|
131
|
-
textAnchor: 'start' as const,
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
for (let i = 0; i < chrome.footnotes.length; i++) {
|
|
135
|
-
const f = chrome.footnotes[i];
|
|
136
|
-
const y =
|
|
137
|
-
bottomOffset + layout.theme.spacing.chartToFooter + textAscent(fontSize) + i * lineHeight;
|
|
138
|
-
const el = createSVGElement('text');
|
|
139
|
-
el.setAttribute('class', 'oc-footnotes');
|
|
140
|
-
setAttrs(el, { x: pad, y });
|
|
141
|
-
applyTextStyle(el, style);
|
|
142
|
-
el.textContent = `${f.index}. ${f.text}`;
|
|
143
|
-
g.appendChild(el);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (chrome.source) {
|
|
148
|
-
renderChromeElement(
|
|
149
|
-
g,
|
|
150
|
-
{ ...chrome.source, y: bottomOffset + chrome.source.y + bandHeight },
|
|
151
|
-
'oc-source',
|
|
152
|
-
'source',
|
|
153
|
-
measureText,
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
if (chrome.byline) {
|
|
157
|
-
renderChromeElement(
|
|
158
|
-
g,
|
|
159
|
-
{ ...chrome.byline, y: bottomOffset + chrome.byline.y + bandHeight },
|
|
160
|
-
'oc-byline',
|
|
161
|
-
'byline',
|
|
162
|
-
measureText,
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
if (chrome.footer) {
|
|
166
|
-
renderChromeElement(
|
|
167
|
-
g,
|
|
168
|
-
{ ...chrome.footer, y: bottomOffset + chrome.footer.y + bandHeight },
|
|
169
|
-
'oc-footer',
|
|
170
|
-
'footer',
|
|
171
|
-
measureText,
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
if (chrome.brand) {
|
|
175
|
-
const brandY = bottomOffset + chrome.brand.y + bandHeight;
|
|
176
|
-
renderChromeElement(g, { ...chrome.brand, y: brandY }, 'oc-brand', 'brand', measureText);
|
|
177
|
-
// Accent dot to the left of the brand text. text-anchor=end means
|
|
178
|
-
// brand.x is the right edge, so the dot sits 12px left of the measured
|
|
179
|
-
// text's leftmost glyph. Use estimateTextWidth (the same path the
|
|
180
|
-
// engine uses for label sizing) instead of a `length * 0.55em` fudge
|
|
181
|
-
// so wide glyphs (W, M) and narrow ones (i, l) land correctly.
|
|
182
|
-
const textWidth = estimateTextWidth(
|
|
183
|
-
chrome.brand.text,
|
|
184
|
-
chrome.brand.style.fontSize,
|
|
185
|
-
chrome.brand.style.fontWeight,
|
|
186
|
-
);
|
|
187
|
-
const dotX = chrome.brand.x - textWidth - 12;
|
|
188
|
-
const dotY = brandY + chrome.brand.style.fontSize / 2;
|
|
189
|
-
const dot = createSVGElement('circle');
|
|
190
|
-
dot.setAttribute('class', 'oc-brand-dot');
|
|
191
|
-
setAttrs(dot, { cx: dotX, cy: dotY, r: 3 });
|
|
192
|
-
g.appendChild(dot);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
parent.appendChild(g);
|
|
196
|
-
}
|