@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/dist/index.js +72 -50
- 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 +97 -63
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
|
@@ -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
|
-
//
|
|
100
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
/**
|
|
854
|
-
|
|
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
|
|
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 =
|
|
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-
|
|
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:
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
956
|
+
const openSpan = createSVGElement('tspan');
|
|
957
|
+
openSpan.setAttribute('font-weight', '500');
|
|
958
|
+
openSpan.textContent = 'Open';
|
|
959
|
+
text.appendChild(openSpan);
|
|
911
960
|
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
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;
|