@opendata-ai/openchart-vanilla 2.3.4 → 2.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-vanilla",
3
- "version": "2.3.4",
3
+ "version": "2.4.0",
4
4
  "description": "Vanilla JS renderer for openchart: SVG charts, HTML tables, force-directed graphs",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -46,8 +46,8 @@
46
46
  "typecheck": "tsc --noEmit"
47
47
  },
48
48
  "dependencies": {
49
- "@opendata-ai/openchart-core": "2.3.4",
50
- "@opendata-ai/openchart-engine": "2.3.4",
49
+ "@opendata-ai/openchart-core": "2.4.0",
50
+ "@opendata-ai/openchart-engine": "2.4.0",
51
51
  "d3-force": "^3.0.0",
52
52
  "d3-quadtree": "^3.0.1"
53
53
  },
@@ -28,6 +28,31 @@ import { estimateTextWidth } from '@opendata-ai/openchart-core';
28
28
 
29
29
  const SVG_NS = 'http://www.w3.org/2000/svg';
30
30
 
31
+ /**
32
+ * Compute the vertical extent of x-axis labels below the chart area.
33
+ * Accounts for rotated tick labels which need more vertical space.
34
+ */
35
+ function computeXAxisExtent(layout: ChartLayout): number {
36
+ const xAxis = layout.axes.x;
37
+ if (!xAxis) return 0;
38
+
39
+ if (xAxis.tickAngle && Math.abs(xAxis.tickAngle) > 10) {
40
+ // Rotated labels: estimate height from the longest tick label.
41
+ const fontSize = xAxis.tickLabelStyle.fontSize;
42
+ const fontWeight = xAxis.tickLabelStyle.fontWeight;
43
+ const angleRad = Math.abs(xAxis.tickAngle) * (Math.PI / 180);
44
+ let maxLabelWidth = 40;
45
+ for (const tick of xAxis.ticks) {
46
+ const w = estimateTextWidth(tick.label, fontSize, fontWeight);
47
+ if (w > maxLabelWidth) maxLabelWidth = w;
48
+ }
49
+ const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
50
+ return xAxis.label ? rotatedHeight + 20 : rotatedHeight;
51
+ }
52
+
53
+ return xAxis.label ? 48 : 26;
54
+ }
55
+
31
56
  // ---------------------------------------------------------------------------
32
57
  // Helpers
33
58
  // ---------------------------------------------------------------------------
@@ -96,9 +121,8 @@ function renderChrome(parent: SVGElement, layout: ChartLayout): void {
96
121
  }
97
122
 
98
123
  // Bottom chrome starts below x-axis labels/title, not at chart area bottom.
99
- // X-axis tick labels render at +14, axis title at +35. Account for that
100
- // so source/byline/footer don't overlap axis content.
101
- const xAxisExtent = layout.axes.x ? (layout.axes.x.label ? 48 : 26) : 0;
124
+ // Accounts for rotated tick labels which need more vertical space.
125
+ const xAxisExtent = computeXAxisExtent(layout);
102
126
  const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
103
127
  if (chrome.source) {
104
128
  renderChromeElement(
@@ -168,11 +192,26 @@ function renderAxis(
168
192
  // Label (no tick marks -- gridlines provide sufficient reference)
169
193
  const label = createSVGElement('text');
170
194
  label.setAttribute('class', 'viz-axis-tick');
171
- setAttrs(label, {
172
- x: tick.position,
173
- y: area.y + area.height + 14,
174
- 'text-anchor': 'middle',
175
- });
195
+
196
+ if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
197
+ // Rotated labels: anchor at the rotation pivot point
198
+ const labelX = tick.position;
199
+ const labelY = area.y + area.height + 6;
200
+ setAttrs(label, {
201
+ x: labelX,
202
+ y: labelY,
203
+ 'text-anchor': axis.tickAngle < 0 ? 'end' : 'start',
204
+ 'dominant-baseline': 'central',
205
+ transform: `rotate(${axis.tickAngle}, ${labelX}, ${labelY})`,
206
+ });
207
+ } else {
208
+ setAttrs(label, {
209
+ x: tick.position,
210
+ y: area.y + area.height + 14,
211
+ 'text-anchor': 'middle',
212
+ });
213
+ }
214
+
176
215
  applyTextStyle(label, axis.tickLabelStyle);
177
216
  label.textContent = tick.label;
178
217
  g.appendChild(label);
@@ -228,9 +267,26 @@ function renderAxis(
228
267
  axisLabel.textContent = axis.label;
229
268
 
230
269
  if (orientation === 'x') {
270
+ // Position axis title below tick labels. For rotated labels, compute
271
+ // the vertical extent of the rotated ticks and place the title below.
272
+ let titleY = area.y + area.height + 35;
273
+ if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
274
+ const angleRad = Math.abs(axis.tickAngle) * (Math.PI / 180);
275
+ let maxLabelWidth = 40;
276
+ for (const tick of axis.ticks) {
277
+ const w = estimateTextWidth(
278
+ tick.label,
279
+ axis.tickLabelStyle.fontSize,
280
+ axis.tickLabelStyle.fontWeight,
281
+ );
282
+ if (w > maxLabelWidth) maxLabelWidth = w;
283
+ }
284
+ const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
285
+ titleY = area.y + area.height + rotatedHeight + 14;
286
+ }
231
287
  setAttrs(axisLabel, {
232
288
  x: area.x + area.width / 2,
233
- y: area.y + area.height + 35,
289
+ y: titleY,
234
290
  'text-anchor': 'middle',
235
291
  });
236
292
  } else {
@@ -860,7 +916,7 @@ function brandPosition(layout: ChartLayout) {
860
916
  // This uses the same Y computation as renderChrome so the watermark sits on the
861
917
  // same baseline row as the source attribution text.
862
918
  const { chrome } = layout;
863
- const xAxisExtent = layout.axes.x ? (layout.axes.x.label ? 48 : 26) : 0;
919
+ const xAxisExtent = computeXAxisExtent(layout);
864
920
  const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
865
921
  const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
866
922
  // Chrome text uses dominant-baseline:hanging (Y = top of text) while the
@@ -9,6 +9,13 @@
9
9
  import type { ResolvedColumn, TableLayout, TableRow } from '@opendata-ai/openchart-core';
10
10
  import { renderCell } from './renderers/table-cells';
11
11
 
12
+ // ---------------------------------------------------------------------------
13
+ // Constants
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const BRAND_URL = 'https://tryopendata.ai';
17
+ const BRAND_FONT_SIZE = 20;
18
+
12
19
  // ---------------------------------------------------------------------------
13
20
  // Chrome rendering
14
21
  // ---------------------------------------------------------------------------
@@ -351,10 +358,10 @@ export function renderTable(layout: TableLayout, container: HTMLElement): HTMLEl
351
358
  brand.className = 'viz-table-ref';
352
359
  brand.style.cssText = 'text-align: right; padding: 4px 8px;';
353
360
  const brandLink = document.createElement('a');
354
- brandLink.href = 'https://tryopendata.ai';
361
+ brandLink.href = BRAND_URL;
355
362
  brandLink.target = '_blank';
356
363
  brandLink.rel = 'noopener';
357
- brandLink.style.cssText = `font-size: 20px; font-weight: 600; color: ${brandColor}; opacity: 0.55; text-decoration: none; font-family: ${theme ? theme.fonts.family : 'sans-serif'};`;
364
+ brandLink.style.cssText = `font-size: ${BRAND_FONT_SIZE}px; font-weight: 600; color: ${brandColor}; opacity: 0.55; text-decoration: none; font-family: ${theme ? theme.fonts.family : 'sans-serif'};`;
358
365
  brandLink.textContent = 'OpenData';
359
366
  brand.appendChild(brandLink);
360
367
  wrapper.appendChild(brand);