@opendata-ai/openchart-vanilla 2.4.0 → 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/dist/index.js +20 -42
- package/dist/index.js.map +1 -1
- package/dist/styles.css +757 -0
- package/package.json +7 -4
- package/src/__tests__/svg-renderer.test.ts +19 -22
- package/src/svg-renderer.ts +31 -53
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "2.
|
|
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":
|
|
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.
|
|
50
|
-
"@opendata-ai/openchart-engine": "2.
|
|
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 "
|
|
616
|
+
it('renders "OpenData" as a single text element with two tspans', () => {
|
|
617
617
|
const { svg } = renderSpec(lineSpec);
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
expect(
|
|
622
|
-
|
|
623
|
-
expect(
|
|
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('
|
|
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(
|
|
631
|
+
expect(links.length).toBe(1);
|
|
630
632
|
});
|
|
631
633
|
|
|
632
|
-
it('
|
|
634
|
+
it('is a direct child of SVG root', () => {
|
|
633
635
|
const { svg } = renderSpec(lineSpec);
|
|
634
|
-
const
|
|
635
|
-
|
|
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('
|
|
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
|
|
646
|
-
|
|
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
|
|
654
|
-
expect(
|
|
650
|
+
const brandLink = svg.querySelector('.viz-chrome-ref');
|
|
651
|
+
expect(brandLink).toBeNull();
|
|
655
652
|
});
|
|
656
653
|
});
|
package/src/svg-renderer.ts
CHANGED
|
@@ -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
|
|
|
@@ -901,89 +904,65 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
|
|
|
901
904
|
// Brand rendering
|
|
902
905
|
// ---------------------------------------------------------------------------
|
|
903
906
|
|
|
904
|
-
const BRAND_FONT_SIZE =
|
|
907
|
+
const BRAND_FONT_SIZE = 11;
|
|
905
908
|
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
|
-
/**
|
|
910
|
-
|
|
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
|
|
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-
|
|
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:
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
956
|
+
const openSpan = createSVGElement('tspan');
|
|
957
|
+
openSpan.setAttribute('font-weight', '500');
|
|
958
|
+
openSpan.textContent = 'Open';
|
|
959
|
+
text.appendChild(openSpan);
|
|
967
960
|
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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
|
-
|
|
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;
|