@opendata-ai/openchart-vanilla 2.3.5 → 2.5.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.5",
3
+ "version": "2.5.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",
@@ -25,13 +25,16 @@
25
25
  "types": "./dist/index.d.ts",
26
26
  "import": "./dist/index.js"
27
27
  },
28
+ "./styles.css": "./dist/styles.css",
28
29
  "./simulation-worker": "./dist/simulation-worker.js"
29
30
  },
30
31
  "files": [
31
32
  "dist",
32
33
  "src"
33
34
  ],
34
- "sideEffects": false,
35
+ "sideEffects": [
36
+ "./dist/styles.css"
37
+ ],
35
38
  "keywords": [
36
39
  "chart",
37
40
  "visualization",
@@ -46,8 +49,8 @@
46
49
  "typecheck": "tsc --noEmit"
47
50
  },
48
51
  "dependencies": {
49
- "@opendata-ai/openchart-core": "2.3.5",
50
- "@opendata-ai/openchart-engine": "2.3.5",
52
+ "@opendata-ai/openchart-core": "2.5.0",
53
+ "@opendata-ai/openchart-engine": "2.5.0",
51
54
  "d3-force": "^3.0.0",
52
55
  "d3-quadtree": "^3.0.1"
53
56
  },
@@ -613,44 +613,41 @@ describe('targeted mark snapshots', () => {
613
613
  // ---------------------------------------------------------------------------
614
614
 
615
615
  describe('brand watermark', () => {
616
- it('renders "Open" and "Data" text elements', () => {
616
+ it('renders "OpenData" as a single text element with two tspans', () => {
617
617
  const { svg } = renderSpec(lineSpec);
618
- const openLink = svg.querySelector('.viz-axis-ref');
619
- const dataLink = svg.querySelector('.viz-chrome-ref');
620
- expect(openLink).not.toBeNull();
621
- expect(dataLink).not.toBeNull();
622
- expect(openLink!.querySelector('text')!.textContent).toBe('Open');
623
- expect(dataLink!.querySelector('text')!.textContent).toBe('Data');
618
+ const brandLink = svg.querySelector('.viz-chrome-ref');
619
+ expect(brandLink).not.toBeNull();
620
+ const text = brandLink!.querySelector('text')!;
621
+ expect(text.textContent).toBe('OpenData');
622
+ const tspans = text.querySelectorAll('tspan');
623
+ expect(tspans.length).toBe(2);
624
+ expect(tspans[0].textContent).toBe('Open');
625
+ expect(tspans[1].textContent).toBe('Data');
624
626
  });
625
627
 
626
- it('both elements link to tryopendata.ai', () => {
628
+ it('links to tryopendata.ai', () => {
627
629
  const { svg } = renderSpec(lineSpec);
628
630
  const links = svg.querySelectorAll('a[href="https://tryopendata.ai"]');
629
- expect(links.length).toBe(2);
631
+ expect(links.length).toBe(1);
630
632
  });
631
633
 
632
- it('elements are direct children of SVG root (no shared group)', () => {
634
+ it('is a direct child of SVG root', () => {
633
635
  const { svg } = renderSpec(lineSpec);
634
- const openLink = svg.querySelector('.viz-axis-ref');
635
- const dataLink = svg.querySelector('.viz-chrome-ref');
636
- expect(openLink!.parentElement).toBe(svg);
637
- expect(dataLink!.parentElement).toBe(svg);
636
+ const brandLink = svg.querySelector('.viz-chrome-ref');
637
+ expect(brandLink!.parentElement).toBe(svg);
638
638
  });
639
639
 
640
- it('elements are interleaved with other chart layers', () => {
640
+ it('renders after chrome (in the footer row)', () => {
641
641
  const { svg } = renderSpec(lineSpec);
642
642
  const children = Array.from(svg.children);
643
- const openIdx = children.findIndex((el) => el.classList.contains('viz-axis-ref'));
644
643
  const chromeIdx = children.findIndex((el) => el.classList.contains('viz-chrome'));
645
- const dataIdx = children.findIndex((el) => el.classList.contains('viz-chrome-ref'));
646
- // "Open" should come before chrome, "Data" after chrome
647
- expect(openIdx).toBeLessThan(chromeIdx);
648
- expect(dataIdx).toBeGreaterThan(chromeIdx);
644
+ const brandIdx = children.findIndex((el) => el.classList.contains('viz-chrome-ref'));
645
+ expect(brandIdx).toBeGreaterThan(chromeIdx);
649
646
  });
650
647
 
651
648
  it('skips watermark on very small charts', () => {
652
649
  const { svg } = renderSpec(lineSpec, { width: 100, height: 80 });
653
- const openLink = svg.querySelector('.viz-axis-ref');
654
- expect(openLink).toBeNull();
650
+ const brandLink = svg.querySelector('.viz-chrome-ref');
651
+ expect(brandLink).toBeNull();
655
652
  });
656
653
  });
@@ -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 {
@@ -296,6 +352,9 @@ function renderLineMark(mark: LineMark, index: number): SVGElement {
296
352
  if (mark.strokeDasharray) {
297
353
  path.setAttribute('stroke-dasharray', mark.strokeDasharray);
298
354
  }
355
+ if (mark.opacity != null) {
356
+ path.setAttribute('opacity', String(mark.opacity));
357
+ }
299
358
  g.appendChild(path);
300
359
  }
301
360
 
@@ -845,89 +904,65 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
845
904
  // Brand rendering
846
905
  // ---------------------------------------------------------------------------
847
906
 
848
- const BRAND_FONT_SIZE = 20;
907
+ const BRAND_FONT_SIZE = 11;
849
908
  const BRAND_MIN_WIDTH = 120;
850
909
  const BRAND_URL = 'https://tryopendata.ai';
851
910
  const XLINK_NS = 'http://www.w3.org/1999/xlink';
852
911
 
853
- /** Compute shared brand positioning from layout dimensions and theme. */
854
- function brandPosition(layout: ChartLayout) {
912
+ /**
913
+ * Render the "OpenData" brand as a footer-row element, right-aligned on the
914
+ * same baseline as the first bottom chrome text (source/byline/footer).
915
+ * Uses the same font size as chrome source text so it blends in as a subtle
916
+ * footer item rather than occupying independent visual space.
917
+ */
918
+ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
919
+ if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
920
+
855
921
  const { width } = layout.dimensions;
856
922
  const padding = layout.theme.spacing.padding;
857
923
  const rightEdge = width - padding;
924
+ const fill = layout.theme.colors.axis;
858
925
 
859
- // Vertically align with the first bottom chrome element (source/byline/footer).
860
- // This uses the same Y computation as renderChrome so the watermark sits on the
861
- // same baseline row as the source attribution text.
926
+ // Vertically align with the first bottom chrome element.
862
927
  const { chrome } = layout;
863
- const xAxisExtent = layout.axes.x ? (layout.axes.x.label ? 48 : 26) : 0;
928
+ const xAxisExtent = computeXAxisExtent(layout);
864
929
  const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
865
930
  const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
866
- // Chrome text uses dominant-baseline:hanging (Y = top of text) while the
867
- // brand uses the default alphabetic baseline (Y = baseline). Shift Y down
868
- // by the brand font size so the visual tops align.
869
931
  const chromeY = firstBottom
870
932
  ? bottomOffset + firstBottom.y
871
933
  : bottomOffset + layout.theme.spacing.chartToFooter;
872
- const y = chromeY + BRAND_FONT_SIZE;
873
-
874
- const dataWidth = estimateTextWidth('Data', BRAND_FONT_SIZE, 600);
875
- // "Open" text-anchor:end sits at the same x where "Data" text-anchor:start begins
876
- const dataX = rightEdge - dataWidth;
877
- const openX = dataX;
878
- return { openX, dataX, y, fill: layout.theme.colors.axis };
879
- }
880
-
881
- function renderBrandOpen(parent: SVGElement, layout: ChartLayout): void {
882
- if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
883
- const { openX, y, fill } = brandPosition(layout);
884
934
 
885
935
  const a = createSVGElement('a');
886
936
  a.setAttribute('href', BRAND_URL);
887
937
  a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
888
938
  a.setAttribute('target', '_blank');
889
939
  a.setAttribute('rel', 'noopener');
890
- a.setAttribute('class', 'viz-axis-ref');
940
+ a.setAttribute('class', 'viz-chrome-ref');
891
941
 
942
+ // "Open" in normal weight, "Data" in semibold, rendered as a single
943
+ // right-aligned text element with two tspans.
892
944
  const text = createSVGElement('text');
893
945
  setAttrs(text, {
894
- x: openX,
895
- y,
946
+ x: rightEdge,
947
+ y: chromeY,
896
948
  'font-family': layout.theme.fonts.family,
897
949
  'font-size': BRAND_FONT_SIZE,
898
- 'font-weight': 500,
899
950
  'text-anchor': 'end',
951
+ 'dominant-baseline': 'hanging',
900
952
  'fill-opacity': 0.55,
901
953
  });
902
954
  (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
903
- text.textContent = 'Open';
904
- a.appendChild(text);
905
- parent.appendChild(a);
906
- }
907
955
 
908
- function renderBrandData(parent: SVGElement, layout: ChartLayout): void {
909
- if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
910
- const { dataX, y, fill } = brandPosition(layout);
956
+ const openSpan = createSVGElement('tspan');
957
+ openSpan.setAttribute('font-weight', '500');
958
+ openSpan.textContent = 'Open';
959
+ text.appendChild(openSpan);
911
960
 
912
- const a = createSVGElement('a');
913
- a.setAttribute('href', BRAND_URL);
914
- a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
915
- a.setAttribute('target', '_blank');
916
- a.setAttribute('rel', 'noopener');
917
- a.setAttribute('class', 'viz-chrome-ref');
961
+ const dataSpan = createSVGElement('tspan');
962
+ dataSpan.setAttribute('font-weight', '600');
963
+ dataSpan.textContent = 'Data';
964
+ text.appendChild(dataSpan);
918
965
 
919
- const text = createSVGElement('text');
920
- setAttrs(text, {
921
- x: dataX,
922
- y,
923
- 'font-family': layout.theme.fonts.family,
924
- 'font-size': BRAND_FONT_SIZE,
925
- 'font-weight': 600,
926
- 'text-anchor': 'start',
927
- 'fill-opacity': 0.55,
928
- });
929
- (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
930
- text.textContent = 'Data';
931
966
  a.appendChild(text);
932
967
  parent.appendChild(a);
933
968
  }
@@ -997,12 +1032,11 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
997
1032
  renderAnnotations(svg, layout);
998
1033
  renderLegend(svg, layout.legend);
999
1034
 
1000
- renderBrandOpen(svg, layout);
1001
-
1002
1035
  // Chrome renders on top so titles are never obscured by chart elements
1003
1036
  renderChrome(svg, layout);
1004
1037
 
1005
- renderBrandData(svg, layout);
1038
+ // Brand renders as a footer item, right-aligned on the source/footer row
1039
+ renderBrand(svg, layout);
1006
1040
 
1007
1041
  container.appendChild(svg);
1008
1042
  return svg;