@opendata-ai/openchart-vanilla 2.0.0 → 2.1.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/README.md +102 -0
- package/dist/index.js +101 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/svg-renderer.test.ts +47 -0
- package/src/graph/canvas-renderer.ts +29 -0
- package/src/svg-renderer.ts +95 -0
- package/src/table-renderer.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.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.
|
|
50
|
-
"@opendata-ai/openchart-engine": "2.
|
|
49
|
+
"@opendata-ai/openchart-core": "2.1.0",
|
|
50
|
+
"@opendata-ai/openchart-engine": "2.1.0",
|
|
51
51
|
"d3-force": "^3.0.0",
|
|
52
52
|
"d3-quadtree": "^3.0.1"
|
|
53
53
|
},
|
|
@@ -607,3 +607,50 @@ describe('targeted mark snapshots', () => {
|
|
|
607
607
|
expect(path!.getAttribute('d')).not.toBeNull();
|
|
608
608
|
});
|
|
609
609
|
});
|
|
610
|
+
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
// Brand watermark
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
describe('brand watermark', () => {
|
|
616
|
+
it('renders "Open" and "Data" text elements', () => {
|
|
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');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('both elements link to tryopendata.ai', () => {
|
|
627
|
+
const { svg } = renderSpec(lineSpec);
|
|
628
|
+
const links = svg.querySelectorAll('a[href="https://tryopendata.ai"]');
|
|
629
|
+
expect(links.length).toBe(2);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('elements are direct children of SVG root (no shared group)', () => {
|
|
633
|
+
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);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('elements are interleaved with other chart layers', () => {
|
|
641
|
+
const { svg } = renderSpec(lineSpec);
|
|
642
|
+
const children = Array.from(svg.children);
|
|
643
|
+
const openIdx = children.findIndex((el) => el.classList.contains('viz-axis-ref'));
|
|
644
|
+
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);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('skips watermark on very small charts', () => {
|
|
652
|
+
const { svg } = renderSpec(lineSpec, { width: 100, height: 80 });
|
|
653
|
+
const openLink = svg.querySelector('.viz-axis-ref');
|
|
654
|
+
expect(openLink).toBeNull();
|
|
655
|
+
});
|
|
656
|
+
});
|
|
@@ -222,6 +222,35 @@ export class GraphCanvasRenderer {
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
ctx.restore();
|
|
225
|
+
|
|
226
|
+
// Brand watermark in screen coordinates (unaffected by pan/zoom)
|
|
227
|
+
this.drawBrand(ctx, cssWidth, cssHeight, theme);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// -------------------------------------------------------------------------
|
|
231
|
+
// Brand rendering
|
|
232
|
+
// -------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
private drawBrand(
|
|
235
|
+
ctx: CanvasRenderingContext2D,
|
|
236
|
+
w: number,
|
|
237
|
+
h: number,
|
|
238
|
+
theme: GraphRenderState['theme'],
|
|
239
|
+
): void {
|
|
240
|
+
if (w < 120) return;
|
|
241
|
+
const { dpr } = this;
|
|
242
|
+
ctx.save();
|
|
243
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
244
|
+
const padding = theme.spacing.padding;
|
|
245
|
+
const x = w - padding;
|
|
246
|
+
const y = h - 4;
|
|
247
|
+
ctx.font = `600 20px ${theme.fonts.family}`;
|
|
248
|
+
ctx.fillStyle = theme.colors.axis;
|
|
249
|
+
ctx.globalAlpha = 0.5;
|
|
250
|
+
ctx.textAlign = 'right';
|
|
251
|
+
ctx.textBaseline = 'alphabetic';
|
|
252
|
+
ctx.fillText('OpenData', x, y);
|
|
253
|
+
ctx.restore();
|
|
225
254
|
}
|
|
226
255
|
|
|
227
256
|
// -------------------------------------------------------------------------
|
package/src/svg-renderer.ts
CHANGED
|
@@ -841,6 +841,97 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
|
|
|
841
841
|
parent.appendChild(g);
|
|
842
842
|
}
|
|
843
843
|
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
// Brand rendering
|
|
846
|
+
// ---------------------------------------------------------------------------
|
|
847
|
+
|
|
848
|
+
const BRAND_FONT_SIZE = 20;
|
|
849
|
+
const BRAND_MIN_WIDTH = 120;
|
|
850
|
+
const BRAND_URL = 'https://tryopendata.ai';
|
|
851
|
+
const XLINK_NS = 'http://www.w3.org/1999/xlink';
|
|
852
|
+
|
|
853
|
+
/** Compute shared brand positioning from layout dimensions and theme. */
|
|
854
|
+
function brandPosition(layout: ChartLayout) {
|
|
855
|
+
const { width } = layout.dimensions;
|
|
856
|
+
const padding = layout.theme.spacing.padding;
|
|
857
|
+
const rightEdge = width - padding;
|
|
858
|
+
|
|
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.
|
|
862
|
+
const { chrome } = layout;
|
|
863
|
+
const xAxisExtent = layout.axes.x ? (layout.axes.x.label ? 48 : 26) : 0;
|
|
864
|
+
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
|
|
865
|
+
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
|
+
const chromeY = firstBottom
|
|
870
|
+
? bottomOffset + firstBottom.y
|
|
871
|
+
: 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
|
+
|
|
885
|
+
const a = createSVGElement('a');
|
|
886
|
+
a.setAttribute('href', BRAND_URL);
|
|
887
|
+
a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
|
|
888
|
+
a.setAttribute('target', '_blank');
|
|
889
|
+
a.setAttribute('rel', 'noopener');
|
|
890
|
+
a.setAttribute('class', 'viz-axis-ref');
|
|
891
|
+
|
|
892
|
+
const text = createSVGElement('text');
|
|
893
|
+
setAttrs(text, {
|
|
894
|
+
x: openX,
|
|
895
|
+
y,
|
|
896
|
+
'font-family': layout.theme.fonts.family,
|
|
897
|
+
'font-size': BRAND_FONT_SIZE,
|
|
898
|
+
'font-weight': 500,
|
|
899
|
+
'text-anchor': 'end',
|
|
900
|
+
'fill-opacity': 0.55,
|
|
901
|
+
});
|
|
902
|
+
(text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
|
|
903
|
+
text.textContent = 'Open';
|
|
904
|
+
a.appendChild(text);
|
|
905
|
+
parent.appendChild(a);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function renderBrandData(parent: SVGElement, layout: ChartLayout): void {
|
|
909
|
+
if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
|
|
910
|
+
const { dataX, y, fill } = brandPosition(layout);
|
|
911
|
+
|
|
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');
|
|
918
|
+
|
|
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
|
+
a.appendChild(text);
|
|
932
|
+
parent.appendChild(a);
|
|
933
|
+
}
|
|
934
|
+
|
|
844
935
|
// ---------------------------------------------------------------------------
|
|
845
936
|
// Main render function
|
|
846
937
|
// ---------------------------------------------------------------------------
|
|
@@ -906,9 +997,13 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
|
|
|
906
997
|
renderAnnotations(svg, layout);
|
|
907
998
|
renderLegend(svg, layout.legend);
|
|
908
999
|
|
|
1000
|
+
renderBrandOpen(svg, layout);
|
|
1001
|
+
|
|
909
1002
|
// Chrome renders on top so titles are never obscured by chart elements
|
|
910
1003
|
renderChrome(svg, layout);
|
|
911
1004
|
|
|
1005
|
+
renderBrandData(svg, layout);
|
|
1006
|
+
|
|
912
1007
|
container.appendChild(svg);
|
|
913
1008
|
return svg;
|
|
914
1009
|
}
|
package/src/table-renderer.ts
CHANGED
|
@@ -345,6 +345,20 @@ export function renderTable(layout: TableLayout, container: HTMLElement): HTMLEl
|
|
|
345
345
|
liveRegion.setAttribute('role', 'status');
|
|
346
346
|
wrapper.appendChild(liveRegion);
|
|
347
347
|
|
|
348
|
+
// Brand watermark
|
|
349
|
+
const brandColor = theme ? theme.colors.axis : '#999999';
|
|
350
|
+
const brand = document.createElement('div');
|
|
351
|
+
brand.className = 'viz-table-ref';
|
|
352
|
+
brand.style.cssText = 'text-align: right; padding: 4px 8px;';
|
|
353
|
+
const brandLink = document.createElement('a');
|
|
354
|
+
brandLink.href = 'https://tryopendata.ai';
|
|
355
|
+
brandLink.target = '_blank';
|
|
356
|
+
brandLink.rel = 'noopener';
|
|
357
|
+
brandLink.style.cssText = `font-size: 20px; color: ${brandColor}; opacity: 0.55; text-decoration: none; font-family: ${theme ? theme.fonts.family : 'sans-serif'};`;
|
|
358
|
+
brandLink.textContent = 'OpenData';
|
|
359
|
+
brand.appendChild(brandLink);
|
|
360
|
+
wrapper.appendChild(brand);
|
|
361
|
+
|
|
348
362
|
container.appendChild(wrapper);
|
|
349
363
|
return wrapper;
|
|
350
364
|
}
|