@opendata-ai/openchart-vanilla 8.0.0-rc.9 → 8.0.0

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