@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.
Files changed (145) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/chunk-DCODCLWI.js +234 -0
  3. package/dist/chunk-DCODCLWI.js.map +1 -0
  4. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  5. package/dist/chunk-EKAUQAAX.js.map +1 -0
  6. package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
  7. package/dist/chunk-KNT5AZ2O.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +283 -24
  16. package/dist/index.js +2012 -763
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.js +7 -69
  21. package/dist/static.js.map +1 -1
  22. package/dist/story/index.d.ts +3 -2
  23. package/dist/story/index.js +5 -3
  24. package/dist/story/index.js.map +1 -1
  25. package/dist/styles.css +1 -1
  26. package/dist/styles.css.map +1 -1
  27. package/package.json +14 -6
  28. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  29. package/dist/chunk-ENXSUXC5.js.map +0 -1
  30. package/src/__test-fixtures__/dom.ts +0 -70
  31. package/src/__test-fixtures__/specs.ts +0 -214
  32. package/src/__tests__/animation.test.ts +0 -358
  33. package/src/__tests__/annotations-render.test.ts +0 -201
  34. package/src/__tests__/compound-labels.test.ts +0 -122
  35. package/src/__tests__/continuous-legend.test.ts +0 -91
  36. package/src/__tests__/crosshair.test.ts +0 -130
  37. package/src/__tests__/edit-events.test.ts +0 -902
  38. package/src/__tests__/events.test.ts +0 -387
  39. package/src/__tests__/export.test.ts +0 -241
  40. package/src/__tests__/font-race.test.ts +0 -199
  41. package/src/__tests__/gradient-ids.test.ts +0 -159
  42. package/src/__tests__/gradient-utils.test.ts +0 -125
  43. package/src/__tests__/known-bugs-render.test.ts +0 -183
  44. package/src/__tests__/map.test.ts +0 -293
  45. package/src/__tests__/mount.test.ts +0 -357
  46. package/src/__tests__/pattern-fills.test.ts +0 -105
  47. package/src/__tests__/resize-timing.test.ts +0 -218
  48. package/src/__tests__/sankey.test.ts +0 -133
  49. package/src/__tests__/selection-events.test.ts +0 -869
  50. package/src/__tests__/series-search.test.ts +0 -311
  51. package/src/__tests__/static.test.ts +0 -141
  52. package/src/__tests__/svg-renderer.test.ts +0 -895
  53. package/src/__tests__/table-keyboard.test.ts +0 -361
  54. package/src/__tests__/table-mount.test.ts +0 -639
  55. package/src/__tests__/tilemap.test.ts +0 -158
  56. package/src/__tests__/tooltip.test.ts +0 -337
  57. package/src/__tests__/transition.test.ts +0 -1777
  58. package/src/__tests__/you-draw-it.test.ts +0 -273
  59. package/src/animation-vars.ts +0 -42
  60. package/src/animation.ts +0 -76
  61. package/src/barlist-mount.ts +0 -383
  62. package/src/barlist-renderer.ts +0 -267
  63. package/src/dom-helpers.ts +0 -17
  64. package/src/export.ts +0 -482
  65. package/src/gradient-utils.ts +0 -138
  66. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  67. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  68. package/src/graph/__tests__/interaction.test.ts +0 -205
  69. package/src/graph/__tests__/keyboard.test.ts +0 -653
  70. package/src/graph/__tests__/search.test.ts +0 -88
  71. package/src/graph/__tests__/simulation.test.ts +0 -233
  72. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  73. package/src/graph/__tests__/zoom.test.ts +0 -203
  74. package/src/graph/canvas-renderer.ts +0 -747
  75. package/src/graph/interaction.ts +0 -367
  76. package/src/graph/keyboard.ts +0 -208
  77. package/src/graph/search.ts +0 -50
  78. package/src/graph/simulation-worker-url.ts +0 -12
  79. package/src/graph/simulation-worker.ts +0 -278
  80. package/src/graph/simulation.ts +0 -422
  81. package/src/graph/spatial-index.ts +0 -121
  82. package/src/graph/types.ts +0 -47
  83. package/src/graph/worker-protocol.ts +0 -70
  84. package/src/graph/zoom.ts +0 -134
  85. package/src/graph-mount.ts +0 -888
  86. package/src/index.ts +0 -76
  87. package/src/interactions/chart-events.ts +0 -139
  88. package/src/interactions/crosshair.ts +0 -233
  89. package/src/interactions/drag-handler.ts +0 -175
  90. package/src/interactions/editing-drags.ts +0 -515
  91. package/src/interactions/index.ts +0 -25
  92. package/src/interactions/keyboard-nav.ts +0 -111
  93. package/src/interactions/legend-interaction.ts +0 -46
  94. package/src/interactions/selection.ts +0 -280
  95. package/src/interactions/tooltip-events.ts +0 -72
  96. package/src/map-camera.ts +0 -88
  97. package/src/map-mount.ts +0 -701
  98. package/src/map-renderer.ts +0 -338
  99. package/src/map-transition.ts +0 -83
  100. package/src/measure-text.ts +0 -134
  101. package/src/mount.ts +0 -1382
  102. package/src/pattern-utils.ts +0 -110
  103. package/src/renderers/annotations.ts +0 -388
  104. package/src/renderers/axes.ts +0 -351
  105. package/src/renderers/brand.ts +0 -87
  106. package/src/renderers/chrome.ts +0 -196
  107. package/src/renderers/endpoint-labels.ts +0 -150
  108. package/src/renderers/legend.ts +0 -311
  109. package/src/renderers/marks.ts +0 -561
  110. package/src/renderers/metrics.ts +0 -63
  111. package/src/renderers/svg-dom.ts +0 -40
  112. package/src/renderers/table-cells.ts +0 -446
  113. package/src/resize-observer.ts +0 -46
  114. package/src/sankey-mount.ts +0 -622
  115. package/src/sankey-renderer.ts +0 -614
  116. package/src/series-search.ts +0 -392
  117. package/src/static.ts +0 -251
  118. package/src/story/__tests__/camera-math.test.ts +0 -145
  119. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  120. package/src/story/__tests__/progress-math.test.ts +0 -95
  121. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  122. package/src/story/__tests__/tween.test.ts +0 -159
  123. package/src/story/camera-math.ts +0 -125
  124. package/src/story/create-chart-story.ts +0 -353
  125. package/src/story/crossfade.ts +0 -70
  126. package/src/story/index.ts +0 -39
  127. package/src/story/progress-math.ts +0 -85
  128. package/src/story/resolve-camera-target.ts +0 -119
  129. package/src/story/scroll-driver.ts +0 -206
  130. package/src/story/story-camera.ts +0 -49
  131. package/src/story/tween.ts +0 -112
  132. package/src/story/types.ts +0 -76
  133. package/src/svg-ids.ts +0 -23
  134. package/src/svg-renderer.ts +0 -383
  135. package/src/table-keyboard.ts +0 -266
  136. package/src/table-mount.ts +0 -567
  137. package/src/table-renderer.ts +0 -414
  138. package/src/text-edit-overlay.ts +0 -255
  139. package/src/theme-tokens.ts +0 -51
  140. package/src/tilemap-mount.ts +0 -511
  141. package/src/tilemap-renderer.ts +0 -396
  142. package/src/tooltip.ts +0 -177
  143. package/src/transition.ts +0 -2816
  144. package/src/you-draw-it.ts +0 -480
  145. /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
@@ -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
- }
@@ -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
- }
@@ -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
- }