@opendata-ai/openchart-vanilla 2.0.0 → 2.2.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.d.ts +24 -3
- package/dist/index.js +299 -11
- package/dist/index.js.map +1 -1
- package/dist/simulation-worker.js +9 -1
- package/package.json +3 -3
- package/src/__tests__/export.test.ts +29 -3
- package/src/__tests__/svg-renderer.test.ts +47 -0
- package/src/export.ts +70 -0
- package/src/graph/canvas-renderer.ts +54 -0
- package/src/graph/interaction.ts +8 -0
- package/src/graph/simulation-worker.ts +19 -8
- package/src/graph/simulation.ts +16 -8
- package/src/graph/types.ts +1 -0
- package/src/graph/worker-protocol.ts +3 -0
- package/src/graph-mount.ts +161 -1
- package/src/index.ts +2 -2
- package/src/mount.ts +21 -7
- package/src/svg-renderer.ts +95 -0
- package/src/table-renderer.ts +14 -0
package/src/index.ts
CHANGED
|
@@ -17,9 +17,9 @@ export type {
|
|
|
17
17
|
TableSpec,
|
|
18
18
|
VizSpec,
|
|
19
19
|
} from '@opendata-ai/openchart-engine';
|
|
20
|
-
export type { PNGExportOptions } from './export';
|
|
20
|
+
export type { JPGExportOptions, PNGExportOptions } from './export';
|
|
21
21
|
// Export utilities
|
|
22
|
-
export { exportCSV, exportPNG, exportSVG } from './export';
|
|
22
|
+
export { exportCSV, exportJPG, exportPNG, exportSVG } from './export';
|
|
23
23
|
// Graph simulation worker
|
|
24
24
|
export { createSimulationWorker } from './graph/simulation-worker-url';
|
|
25
25
|
export type { GraphInstance, GraphMountOptions } from './graph-mount';
|
package/src/mount.ts
CHANGED
|
@@ -26,7 +26,7 @@ import type {
|
|
|
26
26
|
TooltipContent,
|
|
27
27
|
} from '@opendata-ai/openchart-core';
|
|
28
28
|
import { compileChart } from '@opendata-ai/openchart-engine';
|
|
29
|
-
import { exportCSV, exportPNG, exportSVG, type
|
|
29
|
+
import { exportCSV, exportJPG, exportPNG, exportSVG, type JPGExportOptions } from './export';
|
|
30
30
|
import { observeResize } from './resize-observer';
|
|
31
31
|
import { renderChartSVG } from './svg-renderer';
|
|
32
32
|
import { createTooltipManager, type TooltipManager } from './tooltip';
|
|
@@ -46,8 +46,8 @@ export interface MountOptions extends ChartEventHandlers {
|
|
|
46
46
|
responsive?: boolean;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export interface ExportOptions extends
|
|
50
|
-
// Extensible for future formats
|
|
49
|
+
export interface ExportOptions extends JPGExportOptions {
|
|
50
|
+
// Extensible for future formats (extends JPGExportOptions which extends PNGExportOptions)
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export interface ChartInstance {
|
|
@@ -58,8 +58,9 @@ export interface ChartInstance {
|
|
|
58
58
|
/** Export the chart. */
|
|
59
59
|
export(format: 'svg'): string;
|
|
60
60
|
export(format: 'png', options?: ExportOptions): Promise<Blob>;
|
|
61
|
+
export(format: 'jpg', options?: ExportOptions): Promise<Blob>;
|
|
61
62
|
export(format: 'csv'): string;
|
|
62
|
-
export(format: 'svg' | 'png' | 'csv', options?: ExportOptions): string | Promise<Blob>;
|
|
63
|
+
export(format: 'svg' | 'png' | 'jpg' | 'csv', options?: ExportOptions): string | Promise<Blob>;
|
|
63
64
|
/** Remove all DOM elements and disconnect observers. */
|
|
64
65
|
destroy(): void;
|
|
65
66
|
/** The current compiled layout (for hooks / debugging). */
|
|
@@ -1096,13 +1097,16 @@ function wireSeriesLabelDrag(
|
|
|
1096
1097
|
|
|
1097
1098
|
/**
|
|
1098
1099
|
* Wire click handlers on legend entries to toggle series visibility.
|
|
1099
|
-
*
|
|
1100
|
+
* Fires onEdit with { type: 'legend-toggle', series, hidden } for each toggle,
|
|
1101
|
+
* and optionally calls the legacy onLegendToggle callback.
|
|
1102
|
+
* Legend entries for hidden series stay visible but dimmed (opacity 0.3).
|
|
1100
1103
|
* Returns a cleanup function.
|
|
1101
1104
|
*/
|
|
1102
1105
|
function wireLegendInteraction(
|
|
1103
1106
|
svg: SVGElement,
|
|
1104
1107
|
_layout: ChartLayout,
|
|
1105
1108
|
onLegendToggle?: (series: string, visible: boolean) => void,
|
|
1109
|
+
onEdit?: (edit: ElementEdit) => void,
|
|
1106
1110
|
): () => void {
|
|
1107
1111
|
const legendEntries = svg.querySelectorAll('[data-legend-index]');
|
|
1108
1112
|
const cleanups: Array<() => void> = [];
|
|
@@ -1120,11 +1124,13 @@ function wireLegendInteraction(
|
|
|
1120
1124
|
entry.setAttribute('opacity', '1');
|
|
1121
1125
|
entry.setAttribute('aria-label', `${label}: visible`);
|
|
1122
1126
|
onLegendToggle?.(label, true);
|
|
1127
|
+
onEdit?.({ type: 'legend-toggle', series: label, hidden: false });
|
|
1123
1128
|
} else {
|
|
1124
1129
|
hiddenSeries.add(label);
|
|
1125
1130
|
entry.setAttribute('opacity', '0.3');
|
|
1126
1131
|
entry.setAttribute('aria-label', `${label}: hidden`);
|
|
1127
1132
|
onLegendToggle?.(label, false);
|
|
1133
|
+
onEdit?.({ type: 'legend-toggle', series: label, hidden: true });
|
|
1128
1134
|
}
|
|
1129
1135
|
|
|
1130
1136
|
// Toggle visibility of marks with matching series.
|
|
@@ -1448,7 +1454,12 @@ export function createChart(
|
|
|
1448
1454
|
);
|
|
1449
1455
|
|
|
1450
1456
|
// Wire legend interactivity
|
|
1451
|
-
cleanupLegend = wireLegendInteraction(
|
|
1457
|
+
cleanupLegend = wireLegendInteraction(
|
|
1458
|
+
svgElement,
|
|
1459
|
+
currentLayout,
|
|
1460
|
+
options?.onLegendToggle,
|
|
1461
|
+
options?.onEdit,
|
|
1462
|
+
);
|
|
1452
1463
|
|
|
1453
1464
|
// Wire chart event handlers (mark click/hover/leave, annotation click)
|
|
1454
1465
|
if (
|
|
@@ -1546,9 +1557,10 @@ export function createChart(
|
|
|
1546
1557
|
|
|
1547
1558
|
function doExport(format: 'svg'): string;
|
|
1548
1559
|
function doExport(format: 'png', exportOptions?: ExportOptions): Promise<Blob>;
|
|
1560
|
+
function doExport(format: 'jpg', exportOptions?: ExportOptions): Promise<Blob>;
|
|
1549
1561
|
function doExport(format: 'csv'): string;
|
|
1550
1562
|
function doExport(
|
|
1551
|
-
format: 'svg' | 'png' | 'csv',
|
|
1563
|
+
format: 'svg' | 'png' | 'jpg' | 'csv',
|
|
1552
1564
|
exportOptions?: ExportOptions,
|
|
1553
1565
|
): string | Promise<Blob> {
|
|
1554
1566
|
if (!svgElement) {
|
|
@@ -1560,6 +1572,8 @@ export function createChart(
|
|
|
1560
1572
|
return exportSVG(svgElement);
|
|
1561
1573
|
case 'png':
|
|
1562
1574
|
return exportPNG(svgElement, exportOptions);
|
|
1575
|
+
case 'jpg':
|
|
1576
|
+
return exportJPG(svgElement, exportOptions);
|
|
1563
1577
|
case 'csv':
|
|
1564
1578
|
return exportCSV(
|
|
1565
1579
|
'data' in currentSpec && Array.isArray(currentSpec.data) ? currentSpec.data : [],
|
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
|
}
|