@opendata-ai/openchart-vanilla 2.4.0 → 2.6.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.4.0",
3
+ "version": "2.6.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.4.0",
50
- "@opendata-ai/openchart-engine": "2.4.0",
52
+ "@opendata-ai/openchart-core": "2.6.0",
53
+ "@opendata-ai/openchart-engine": "2.6.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
  });
@@ -352,6 +352,9 @@ function renderLineMark(mark: LineMark, index: number): SVGElement {
352
352
  if (mark.strokeDasharray) {
353
353
  path.setAttribute('stroke-dasharray', mark.strokeDasharray);
354
354
  }
355
+ if (mark.opacity != null) {
356
+ path.setAttribute('opacity', String(mark.opacity));
357
+ }
355
358
  g.appendChild(path);
356
359
  }
357
360
 
@@ -906,84 +909,60 @@ const BRAND_MIN_WIDTH = 120;
906
909
  const BRAND_URL = 'https://tryopendata.ai';
907
910
  const XLINK_NS = 'http://www.w3.org/1999/xlink';
908
911
 
909
- /** Compute shared brand positioning from layout dimensions and theme. */
910
- 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
+
911
921
  const { width } = layout.dimensions;
912
922
  const padding = layout.theme.spacing.padding;
913
923
  const rightEdge = width - padding;
924
+ const fill = layout.theme.colors.axis;
914
925
 
915
- // Vertically align with the first bottom chrome element (source/byline/footer).
916
- // This uses the same Y computation as renderChrome so the watermark sits on the
917
- // same baseline row as the source attribution text.
926
+ // Vertically align with the first bottom chrome element.
918
927
  const { chrome } = layout;
919
928
  const xAxisExtent = computeXAxisExtent(layout);
920
929
  const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
921
930
  const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
922
- // Chrome text uses dominant-baseline:hanging (Y = top of text) while the
923
- // brand uses the default alphabetic baseline (Y = baseline). Shift Y down
924
- // by the brand font size so the visual tops align.
925
931
  const chromeY = firstBottom
926
932
  ? bottomOffset + firstBottom.y
927
933
  : bottomOffset + layout.theme.spacing.chartToFooter;
928
- const y = chromeY + BRAND_FONT_SIZE;
929
-
930
- const dataWidth = estimateTextWidth('Data', BRAND_FONT_SIZE, 600);
931
- // "Open" text-anchor:end sits at the same x where "Data" text-anchor:start begins
932
- const dataX = rightEdge - dataWidth;
933
- const openX = dataX;
934
- return { openX, dataX, y, fill: layout.theme.colors.axis };
935
- }
936
-
937
- function renderBrandOpen(parent: SVGElement, layout: ChartLayout): void {
938
- if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
939
- const { openX, y, fill } = brandPosition(layout);
940
934
 
941
935
  const a = createSVGElement('a');
942
936
  a.setAttribute('href', BRAND_URL);
943
937
  a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
944
938
  a.setAttribute('target', '_blank');
945
939
  a.setAttribute('rel', 'noopener');
946
- a.setAttribute('class', 'viz-axis-ref');
940
+ a.setAttribute('class', 'viz-chrome-ref');
947
941
 
942
+ // "Open" in normal weight, "Data" in semibold, rendered as a single
943
+ // right-aligned text element with two tspans.
948
944
  const text = createSVGElement('text');
949
945
  setAttrs(text, {
950
- x: openX,
951
- y,
946
+ x: rightEdge,
947
+ y: chromeY,
952
948
  'font-family': layout.theme.fonts.family,
953
949
  'font-size': BRAND_FONT_SIZE,
954
- 'font-weight': 500,
955
950
  'text-anchor': 'end',
951
+ 'dominant-baseline': 'hanging',
956
952
  'fill-opacity': 0.55,
957
953
  });
958
954
  (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
959
- text.textContent = 'Open';
960
- a.appendChild(text);
961
- parent.appendChild(a);
962
- }
963
955
 
964
- function renderBrandData(parent: SVGElement, layout: ChartLayout): void {
965
- if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
966
- 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);
967
960
 
968
- const a = createSVGElement('a');
969
- a.setAttribute('href', BRAND_URL);
970
- a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
971
- a.setAttribute('target', '_blank');
972
- a.setAttribute('rel', 'noopener');
973
- 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);
974
965
 
975
- const text = createSVGElement('text');
976
- setAttrs(text, {
977
- x: dataX,
978
- y,
979
- 'font-family': layout.theme.fonts.family,
980
- 'font-size': BRAND_FONT_SIZE,
981
- 'font-weight': 600,
982
- 'text-anchor': 'start',
983
- 'fill-opacity': 0.55,
984
- });
985
- (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
986
- text.textContent = 'Data';
987
966
  a.appendChild(text);
988
967
  parent.appendChild(a);
989
968
  }
@@ -1053,12 +1032,11 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
1053
1032
  renderAnnotations(svg, layout);
1054
1033
  renderLegend(svg, layout.legend);
1055
1034
 
1056
- renderBrandOpen(svg, layout);
1057
-
1058
1035
  // Chrome renders on top so titles are never obscured by chart elements
1059
1036
  renderChrome(svg, layout);
1060
1037
 
1061
- renderBrandData(svg, layout);
1038
+ // Brand renders as a footer item, right-aligned on the source/footer row
1039
+ renderBrand(svg, layout);
1062
1040
 
1063
1041
  container.appendChild(svg);
1064
1042
  return svg;