@opendata-ai/openchart-vanilla 7.6.1 → 7.8.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/chunk-KYRQV6KR.js +2020 -0
- package/dist/chunk-KYRQV6KR.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +320 -2138
- package/dist/index.js.map +1 -1
- package/dist/static.d.ts +29 -0
- package/dist/static.js +187 -0
- package/dist/static.js.map +1 -0
- package/package.json +15 -3
- package/src/__tests__/static.test.ts +141 -0
- package/src/__tests__/svg-renderer.test.ts +10 -4
- package/src/mount.ts +31 -1
- package/src/renderers/annotations.ts +43 -0
- package/src/renderers/chrome.ts +35 -4
- package/src/renderers/endpoint-labels.ts +44 -38
- package/src/static.ts +246 -0
- package/src/svg-ids.ts +5 -0
- package/src/svg-renderer.ts +157 -98
- package/src/theme-tokens.ts +51 -0
- package/src/tilemap-renderer.ts +9 -5
package/dist/static.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ThemeConfig, DarkMode, ChartSpec, LayerSpec, TileMapSpec } from '@opendata-ai/openchart-core';
|
|
2
|
+
|
|
3
|
+
interface StaticRenderOptions {
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
theme?: ThemeConfig;
|
|
7
|
+
/**
|
|
8
|
+
* Dark mode setting. In static rendering `'auto'` resolves to light mode
|
|
9
|
+
* since there is no `matchMedia` to query. Use `'force'` for dark output.
|
|
10
|
+
*/
|
|
11
|
+
darkMode?: DarkMode;
|
|
12
|
+
watermark?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Render a chart spec to a standalone SVG string without a browser DOM.
|
|
16
|
+
*
|
|
17
|
+
* Requires `happy-dom` as a peer dependency (`bun add happy-dom`).
|
|
18
|
+
*
|
|
19
|
+
* Not safe for concurrent invocation: the render pipeline relies on a global
|
|
20
|
+
* SVG ID counter and a temporary global `document` swap, both of which are
|
|
21
|
+
* single-threaded. In a server handling parallel requests, serialize calls
|
|
22
|
+
* through a queue or mutex.
|
|
23
|
+
*
|
|
24
|
+
* The entire render pipeline is synchronous; the global swap is safe as long
|
|
25
|
+
* as no code schedules microtasks that outlive the call.
|
|
26
|
+
*/
|
|
27
|
+
declare function renderStaticSVG(spec: ChartSpec | LayerSpec | TileMapSpec, options?: StaticRenderOptions): string;
|
|
28
|
+
|
|
29
|
+
export { type StaticRenderOptions, renderStaticSVG };
|
package/dist/static.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SVG_NS,
|
|
3
|
+
renderChartSVG,
|
|
4
|
+
renderTileMapSVG,
|
|
5
|
+
resetSvgIdCounter
|
|
6
|
+
} from "./chunk-KYRQV6KR.js";
|
|
7
|
+
|
|
8
|
+
// src/static.ts
|
|
9
|
+
import { createRequire } from "module";
|
|
10
|
+
import { adaptForLightLineStroke, isLayerSpec, isTileMapSpec } from "@opendata-ai/openchart-core";
|
|
11
|
+
import { compileChart, compileLayer, compileTileMap } from "@opendata-ai/openchart-engine";
|
|
12
|
+
var esmRequire = createRequire(import.meta.url);
|
|
13
|
+
var cachedWindow;
|
|
14
|
+
function getHappyDomWindow() {
|
|
15
|
+
if (cachedWindow) return cachedWindow;
|
|
16
|
+
try {
|
|
17
|
+
({ Window: cachedWindow } = esmRequire("happy-dom"));
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error(
|
|
20
|
+
"renderStaticSVG requires 'happy-dom' as a peer dependency. Install it with: npm add happy-dom"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return cachedWindow;
|
|
24
|
+
}
|
|
25
|
+
var rendering = false;
|
|
26
|
+
function resolveStaticDarkMode(mode) {
|
|
27
|
+
if (mode === "force") return true;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
function buildThemeStyleBlock(theme) {
|
|
31
|
+
const accent = theme.colors.categorical[0] ?? "#06b6d4";
|
|
32
|
+
const bg = theme.colors.background === "transparent" ? theme.isDark ? "#09090b" : "#ffffff" : theme.colors.background;
|
|
33
|
+
const props = [
|
|
34
|
+
`--oc-font-family: ${theme.fonts.family}`,
|
|
35
|
+
`--oc-font-mono: ${theme.fonts.mono}`,
|
|
36
|
+
`--oc-title-size: ${theme.chrome.title.fontSize}px`,
|
|
37
|
+
`--oc-title-weight: ${theme.chrome.title.fontWeight}`,
|
|
38
|
+
`--oc-title-tracking: -0.022em`,
|
|
39
|
+
// sync with tokens.css
|
|
40
|
+
`--oc-subtitle-size: ${theme.chrome.subtitle.fontSize}px`,
|
|
41
|
+
`--oc-subtitle-weight: ${theme.chrome.subtitle.fontWeight}`,
|
|
42
|
+
`--oc-source-size: ${theme.chrome.source.fontSize}px`,
|
|
43
|
+
`--oc-source-weight: ${theme.chrome.source.fontWeight}`,
|
|
44
|
+
`--oc-body-size: ${theme.fonts.sizes.body}px`,
|
|
45
|
+
`--oc-eyebrow-size: ${theme.chrome.eyebrow.fontSize}px`,
|
|
46
|
+
`--oc-eyebrow-weight: ${theme.chrome.eyebrow.fontWeight}`,
|
|
47
|
+
`--oc-eyebrow-tracking: 0.08em`,
|
|
48
|
+
// sync with tokens.css
|
|
49
|
+
`--oc-bg: ${bg}`,
|
|
50
|
+
`--oc-text: ${theme.colors.text}`,
|
|
51
|
+
`--oc-text-muted: ${theme.colors.axis}`,
|
|
52
|
+
`--oc-text-faint: ${theme.isDark ? "#52525b" : "#d4d4d8"}`,
|
|
53
|
+
`--oc-gridline: ${theme.colors.gridline}`,
|
|
54
|
+
`--oc-axis: ${theme.colors.axis}`,
|
|
55
|
+
`--oc-border-radius: ${theme.borderRadius}px`,
|
|
56
|
+
`--oc-accent: ${accent}`,
|
|
57
|
+
`--oc-accent-strong: ${adaptForLightLineStroke(accent)}`,
|
|
58
|
+
`--oc-positive: ${theme.colors.positive}`,
|
|
59
|
+
`--oc-negative: ${theme.colors.negative}`,
|
|
60
|
+
`--oc-legend-text: ${theme.isDark ? "#d0d6e0" : "#3f3f46"}`,
|
|
61
|
+
`--oc-space-2: ${theme.spacing.chromeGap * 2}px`,
|
|
62
|
+
`--oc-space-4: ${theme.spacing.padding}px`
|
|
63
|
+
];
|
|
64
|
+
const rules = [
|
|
65
|
+
`svg.oc-chart { ${props.join("; ")}; }`,
|
|
66
|
+
`.oc-chrome { font-family: var(--oc-font-family); }`,
|
|
67
|
+
`.oc-eyebrow { font-size: var(--oc-eyebrow-size); font-weight: var(--oc-eyebrow-weight); letter-spacing: var(--oc-eyebrow-tracking); text-transform: uppercase; fill: var(--oc-accent); }`,
|
|
68
|
+
`.oc-title { font-size: var(--oc-title-size); font-weight: var(--oc-title-weight); letter-spacing: var(--oc-title-tracking); fill: var(--oc-text); }`,
|
|
69
|
+
`.oc-subtitle { font-size: var(--oc-subtitle-size); font-weight: var(--oc-subtitle-weight); fill: var(--oc-text-muted); }`,
|
|
70
|
+
`.oc-source, .oc-byline, .oc-footer { font-size: var(--oc-source-size); font-weight: var(--oc-source-weight); fill: var(--oc-text-muted); }`,
|
|
71
|
+
`.oc-brand { font-size: 11px; font-weight: 510; letter-spacing: 0.02em; fill: var(--oc-text-faint); }`,
|
|
72
|
+
`.oc-brand-dot { fill: var(--oc-accent); }`,
|
|
73
|
+
`.oc-eyebrow-dot { fill: var(--oc-accent); }`,
|
|
74
|
+
`.oc-metrics { font-family: var(--oc-font-family); }`,
|
|
75
|
+
`.oc-metric-label { font-size: 10px; font-weight: 510; letter-spacing: 0.08em; text-transform: uppercase; fill: var(--oc-text-muted); }`,
|
|
76
|
+
`.oc-metric-value { font-size: 22px; font-weight: 510; letter-spacing: -0.01em; fill: var(--oc-text); font-variant-numeric: tabular-nums; }`,
|
|
77
|
+
`.oc-metric-delta-up { fill: var(--oc-positive); font-size: 12px; font-weight: 510; }`,
|
|
78
|
+
`.oc-metric-delta-down { fill: var(--oc-negative); font-size: 12px; font-weight: 510; }`,
|
|
79
|
+
`.oc-axis-tick-inline { font-size: 11px; font-weight: 400; fill: var(--oc-text-muted); }`,
|
|
80
|
+
`.oc-endpoint-labels { font-family: var(--oc-font-family); }`,
|
|
81
|
+
`.oc-endpoint-label { fill: var(--oc-endpoint-label-color, var(--oc-text)); }`,
|
|
82
|
+
`.oc-endpoint-value { fill: var(--oc-endpoint-value-color, var(--oc-text-muted)); }`,
|
|
83
|
+
`.oc-endpoint-leader { stroke: var(--oc-endpoint-leader-color, currentColor); }`,
|
|
84
|
+
`.oc-annotation-subtitle { fill: var(--oc-annotation-subtitle-color, var(--oc-text-muted)); }`,
|
|
85
|
+
`.oc-metric-secondary { fill: var(--oc-positive); font-size: 12px; font-weight: 400; }`,
|
|
86
|
+
`.oc-legend { font-family: var(--oc-font-family); font-size: var(--oc-body-size); }`,
|
|
87
|
+
`.oc-legend-entry { cursor: default; }`,
|
|
88
|
+
`.oc-legend text { fill: var(--oc-legend-text); }`
|
|
89
|
+
];
|
|
90
|
+
return rules.join("\n");
|
|
91
|
+
}
|
|
92
|
+
function stripInteractiveElements(svg) {
|
|
93
|
+
const selectors = ["[data-voronoi-overlay]", "[data-crosshair]", "[data-snap-dots]"];
|
|
94
|
+
for (const selector of selectors) {
|
|
95
|
+
const els = svg.querySelectorAll(selector);
|
|
96
|
+
for (const el of els) {
|
|
97
|
+
el.parentNode?.removeChild(el);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function renderStaticSVG(spec, options) {
|
|
102
|
+
if (rendering) {
|
|
103
|
+
throw new Error("renderStaticSVG is not reentrant \u2014 serialize calls through a queue or mutex");
|
|
104
|
+
}
|
|
105
|
+
rendering = true;
|
|
106
|
+
const Window = getHappyDomWindow();
|
|
107
|
+
const win = new Window({ url: "about:blank" });
|
|
108
|
+
const prevDocument = globalThis.document;
|
|
109
|
+
const prevWindow = globalThis.window;
|
|
110
|
+
try {
|
|
111
|
+
globalThis.document = win.document;
|
|
112
|
+
globalThis.window = win;
|
|
113
|
+
resetSvgIdCounter();
|
|
114
|
+
const width = options?.width ?? 640;
|
|
115
|
+
const height = options?.height ?? 420;
|
|
116
|
+
const darkMode = resolveStaticDarkMode(options?.darkMode);
|
|
117
|
+
const compileOpts = {
|
|
118
|
+
width,
|
|
119
|
+
height,
|
|
120
|
+
theme: options?.theme,
|
|
121
|
+
darkMode,
|
|
122
|
+
watermark: options?.watermark
|
|
123
|
+
};
|
|
124
|
+
let svg;
|
|
125
|
+
let themeForStyle;
|
|
126
|
+
if (isTileMapSpec(spec)) {
|
|
127
|
+
const tileMapLayout = compileTileMap(spec, compileOpts);
|
|
128
|
+
svg = renderTileMapSVG(tileMapLayout, { animate: false });
|
|
129
|
+
themeForStyle = tileMapLayout.theme;
|
|
130
|
+
} else {
|
|
131
|
+
let layout;
|
|
132
|
+
if (isLayerSpec(spec)) {
|
|
133
|
+
layout = compileLayer(spec, compileOpts);
|
|
134
|
+
} else {
|
|
135
|
+
layout = compileChart(spec, compileOpts);
|
|
136
|
+
}
|
|
137
|
+
const container = win.document.createElement("div");
|
|
138
|
+
Object.defineProperty(container, "getBoundingClientRect", {
|
|
139
|
+
value: () => ({
|
|
140
|
+
width,
|
|
141
|
+
height,
|
|
142
|
+
top: 0,
|
|
143
|
+
left: 0,
|
|
144
|
+
right: width,
|
|
145
|
+
bottom: height,
|
|
146
|
+
x: 0,
|
|
147
|
+
y: 0,
|
|
148
|
+
toJSON: () => ({})
|
|
149
|
+
})
|
|
150
|
+
});
|
|
151
|
+
svg = renderChartSVG(layout, container, {
|
|
152
|
+
animate: false,
|
|
153
|
+
crosshair: false
|
|
154
|
+
});
|
|
155
|
+
stripInteractiveElements(svg);
|
|
156
|
+
themeForStyle = layout.theme;
|
|
157
|
+
}
|
|
158
|
+
const doc = win.document;
|
|
159
|
+
let defs = svg.querySelector("defs");
|
|
160
|
+
if (!defs) {
|
|
161
|
+
defs = doc.createElementNS(SVG_NS, "defs");
|
|
162
|
+
svg.insertBefore(defs, svg.firstChild);
|
|
163
|
+
}
|
|
164
|
+
const styleEl = doc.createElementNS(SVG_NS, "style");
|
|
165
|
+
styleEl.textContent = buildThemeStyleBlock(themeForStyle);
|
|
166
|
+
defs.insertBefore(styleEl, defs.firstChild);
|
|
167
|
+
const serializer = new win.XMLSerializer();
|
|
168
|
+
return serializer.serializeToString(svg);
|
|
169
|
+
} finally {
|
|
170
|
+
rendering = false;
|
|
171
|
+
if (prevDocument !== void 0) {
|
|
172
|
+
globalThis.document = prevDocument;
|
|
173
|
+
} else {
|
|
174
|
+
delete globalThis.document;
|
|
175
|
+
}
|
|
176
|
+
if (prevWindow !== void 0) {
|
|
177
|
+
globalThis.window = prevWindow;
|
|
178
|
+
} else {
|
|
179
|
+
delete globalThis.window;
|
|
180
|
+
}
|
|
181
|
+
win.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export {
|
|
185
|
+
renderStaticSVG
|
|
186
|
+
};
|
|
187
|
+
//# sourceMappingURL=static.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/static.ts"],"sourcesContent":["// Node.js only — do not import from browser code.\nimport { createRequire } from 'node:module';\nimport type {\n ChartLayout,\n ChartSpec,\n CompileOptions,\n DarkMode,\n LayerSpec,\n ResolvedTheme,\n ThemeConfig,\n TileMapSpec,\n} from '@opendata-ai/openchart-core';\nimport { adaptForLightLineStroke, isLayerSpec, isTileMapSpec } from '@opendata-ai/openchart-core';\nimport { compileChart, compileLayer, compileTileMap } from '@opendata-ai/openchart-engine';\nimport { SVG_NS } from './renderers/svg-dom';\nimport { resetSvgIdCounter } from './svg-ids';\nimport { renderChartSVG } from './svg-renderer';\nimport { renderTileMapSVG } from './tilemap-renderer';\n\nconst esmRequire = createRequire(import.meta.url);\n\nlet cachedWindow: typeof import('happy-dom').Window | undefined;\nfunction getHappyDomWindow(): typeof import('happy-dom').Window {\n if (cachedWindow) return cachedWindow;\n try {\n ({ Window: cachedWindow } = esmRequire('happy-dom') as typeof import('happy-dom'));\n } catch {\n throw new Error(\n \"renderStaticSVG requires 'happy-dom' as a peer dependency. Install it with: npm add happy-dom\",\n );\n }\n return cachedWindow;\n}\n\nlet rendering = false;\n\nexport interface StaticRenderOptions {\n width?: number;\n height?: number;\n theme?: ThemeConfig;\n /**\n * Dark mode setting. In static rendering `'auto'` resolves to light mode\n * since there is no `matchMedia` to query. Use `'force'` for dark output.\n */\n darkMode?: DarkMode;\n watermark?: boolean;\n}\n\nfunction resolveStaticDarkMode(mode?: DarkMode): boolean {\n if (mode === 'force') return true;\n return false;\n}\n\nfunction buildThemeStyleBlock(theme: ResolvedTheme): string {\n const accent = theme.colors.categorical[0] ?? '#06b6d4';\n const bg =\n theme.colors.background === 'transparent'\n ? theme.isDark\n ? '#09090b'\n : '#ffffff'\n : theme.colors.background;\n\n const props = [\n `--oc-font-family: ${theme.fonts.family}`,\n `--oc-font-mono: ${theme.fonts.mono}`,\n `--oc-title-size: ${theme.chrome.title.fontSize}px`,\n `--oc-title-weight: ${theme.chrome.title.fontWeight}`,\n `--oc-title-tracking: -0.022em`, // sync with tokens.css\n `--oc-subtitle-size: ${theme.chrome.subtitle.fontSize}px`,\n `--oc-subtitle-weight: ${theme.chrome.subtitle.fontWeight}`,\n `--oc-source-size: ${theme.chrome.source.fontSize}px`,\n `--oc-source-weight: ${theme.chrome.source.fontWeight}`,\n `--oc-body-size: ${theme.fonts.sizes.body}px`,\n `--oc-eyebrow-size: ${theme.chrome.eyebrow.fontSize}px`,\n `--oc-eyebrow-weight: ${theme.chrome.eyebrow.fontWeight}`,\n `--oc-eyebrow-tracking: 0.08em`, // sync with tokens.css\n `--oc-bg: ${bg}`,\n `--oc-text: ${theme.colors.text}`,\n `--oc-text-muted: ${theme.colors.axis}`,\n `--oc-text-faint: ${theme.isDark ? '#52525b' : '#d4d4d8'}`,\n `--oc-gridline: ${theme.colors.gridline}`,\n `--oc-axis: ${theme.colors.axis}`,\n `--oc-border-radius: ${theme.borderRadius}px`,\n `--oc-accent: ${accent}`,\n `--oc-accent-strong: ${adaptForLightLineStroke(accent)}`,\n `--oc-positive: ${theme.colors.positive}`,\n `--oc-negative: ${theme.colors.negative}`,\n `--oc-legend-text: ${theme.isDark ? '#d0d6e0' : '#3f3f46'}`,\n `--oc-space-2: ${theme.spacing.chromeGap * 2}px`,\n `--oc-space-4: ${theme.spacing.padding}px`,\n ];\n\n const rules = [\n `svg.oc-chart { ${props.join('; ')}; }`,\n `.oc-chrome { font-family: var(--oc-font-family); }`,\n `.oc-eyebrow { font-size: var(--oc-eyebrow-size); font-weight: var(--oc-eyebrow-weight); letter-spacing: var(--oc-eyebrow-tracking); text-transform: uppercase; fill: var(--oc-accent); }`,\n `.oc-title { font-size: var(--oc-title-size); font-weight: var(--oc-title-weight); letter-spacing: var(--oc-title-tracking); fill: var(--oc-text); }`,\n `.oc-subtitle { font-size: var(--oc-subtitle-size); font-weight: var(--oc-subtitle-weight); fill: var(--oc-text-muted); }`,\n `.oc-source, .oc-byline, .oc-footer { font-size: var(--oc-source-size); font-weight: var(--oc-source-weight); fill: var(--oc-text-muted); }`,\n `.oc-brand { font-size: 11px; font-weight: 510; letter-spacing: 0.02em; fill: var(--oc-text-faint); }`,\n `.oc-brand-dot { fill: var(--oc-accent); }`,\n `.oc-eyebrow-dot { fill: var(--oc-accent); }`,\n `.oc-metrics { font-family: var(--oc-font-family); }`,\n `.oc-metric-label { font-size: 10px; font-weight: 510; letter-spacing: 0.08em; text-transform: uppercase; fill: var(--oc-text-muted); }`,\n `.oc-metric-value { font-size: 22px; font-weight: 510; letter-spacing: -0.01em; fill: var(--oc-text); font-variant-numeric: tabular-nums; }`,\n `.oc-metric-delta-up { fill: var(--oc-positive); font-size: 12px; font-weight: 510; }`,\n `.oc-metric-delta-down { fill: var(--oc-negative); font-size: 12px; font-weight: 510; }`,\n `.oc-axis-tick-inline { font-size: 11px; font-weight: 400; fill: var(--oc-text-muted); }`,\n `.oc-endpoint-labels { font-family: var(--oc-font-family); }`,\n `.oc-endpoint-label { fill: var(--oc-endpoint-label-color, var(--oc-text)); }`,\n `.oc-endpoint-value { fill: var(--oc-endpoint-value-color, var(--oc-text-muted)); }`,\n `.oc-endpoint-leader { stroke: var(--oc-endpoint-leader-color, currentColor); }`,\n `.oc-annotation-subtitle { fill: var(--oc-annotation-subtitle-color, var(--oc-text-muted)); }`,\n `.oc-metric-secondary { fill: var(--oc-positive); font-size: 12px; font-weight: 400; }`,\n `.oc-legend { font-family: var(--oc-font-family); font-size: var(--oc-body-size); }`,\n `.oc-legend-entry { cursor: default; }`,\n `.oc-legend text { fill: var(--oc-legend-text); }`,\n ];\n\n return rules.join('\\n');\n}\n\nfunction stripInteractiveElements(svg: Element): void {\n const selectors = ['[data-voronoi-overlay]', '[data-crosshair]', '[data-snap-dots]'];\n for (const selector of selectors) {\n const els = svg.querySelectorAll(selector);\n for (const el of els) {\n el.parentNode?.removeChild(el);\n }\n }\n}\n\n/**\n * Render a chart spec to a standalone SVG string without a browser DOM.\n *\n * Requires `happy-dom` as a peer dependency (`bun add happy-dom`).\n *\n * Not safe for concurrent invocation: the render pipeline relies on a global\n * SVG ID counter and a temporary global `document` swap, both of which are\n * single-threaded. In a server handling parallel requests, serialize calls\n * through a queue or mutex.\n *\n * The entire render pipeline is synchronous; the global swap is safe as long\n * as no code schedules microtasks that outlive the call.\n */\nexport function renderStaticSVG(\n spec: ChartSpec | LayerSpec | TileMapSpec,\n options?: StaticRenderOptions,\n): string {\n if (rendering) {\n throw new Error('renderStaticSVG is not reentrant — serialize calls through a queue or mutex');\n }\n rendering = true;\n\n const Window = getHappyDomWindow();\n const win = new Window({ url: 'about:blank' });\n\n const prevDocument = globalThis.document;\n const prevWindow = (globalThis as Record<string, unknown>).window;\n\n try {\n (globalThis as Record<string, unknown>).document = win.document;\n (globalThis as Record<string, unknown>).window = win;\n\n resetSvgIdCounter();\n\n const width = options?.width ?? 640;\n const height = options?.height ?? 420;\n const darkMode = resolveStaticDarkMode(options?.darkMode);\n\n const compileOpts: CompileOptions = {\n width,\n height,\n theme: options?.theme,\n darkMode,\n watermark: options?.watermark,\n };\n\n let svg: SVGElement;\n let themeForStyle: ResolvedTheme;\n\n if (isTileMapSpec(spec)) {\n const tileMapLayout = compileTileMap(spec, compileOpts);\n svg = renderTileMapSVG(tileMapLayout, { animate: false });\n themeForStyle = tileMapLayout.theme;\n } else {\n let layout: ChartLayout;\n if (isLayerSpec(spec)) {\n layout = compileLayer(spec, compileOpts);\n } else {\n layout = compileChart(spec, compileOpts);\n }\n\n const container = win.document.createElement('div');\n Object.defineProperty(container, 'getBoundingClientRect', {\n value: () => ({\n width,\n height,\n top: 0,\n left: 0,\n right: width,\n bottom: height,\n x: 0,\n y: 0,\n toJSON: () => ({}),\n }),\n });\n\n svg = renderChartSVG(layout, container as unknown as HTMLElement, {\n animate: false,\n crosshair: false,\n });\n\n stripInteractiveElements(svg);\n themeForStyle = layout.theme;\n }\n\n const doc = win.document as unknown as Document;\n let defs = svg.querySelector('defs');\n if (!defs) {\n defs = doc.createElementNS(SVG_NS, 'defs');\n svg.insertBefore(defs as unknown as Node, svg.firstChild);\n }\n const styleEl = doc.createElementNS(SVG_NS, 'style');\n styleEl.textContent = buildThemeStyleBlock(themeForStyle);\n defs.insertBefore(styleEl as unknown as Node, defs.firstChild);\n\n const serializer = new (\n win as unknown as { XMLSerializer: typeof XMLSerializer }\n ).XMLSerializer();\n return serializer.serializeToString(svg as unknown as Node);\n } finally {\n rendering = false;\n if (prevDocument !== undefined) {\n (globalThis as Record<string, unknown>).document = prevDocument;\n } else {\n delete (globalThis as Record<string, unknown>).document;\n }\n if (prevWindow !== undefined) {\n (globalThis as Record<string, unknown>).window = prevWindow;\n } else {\n delete (globalThis as Record<string, unknown>).window;\n }\n win.close();\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.8.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,6 +25,10 @@
|
|
|
25
25
|
"types": "./dist/index.d.ts",
|
|
26
26
|
"import": "./dist/index.js"
|
|
27
27
|
},
|
|
28
|
+
"./static": {
|
|
29
|
+
"types": "./dist/static.d.ts",
|
|
30
|
+
"import": "./dist/static.js"
|
|
31
|
+
},
|
|
28
32
|
"./styles.css": "./dist/styles.css",
|
|
29
33
|
"./simulation-worker": "./dist/simulation-worker.js"
|
|
30
34
|
},
|
|
@@ -50,11 +54,19 @@
|
|
|
50
54
|
},
|
|
51
55
|
"dependencies": {
|
|
52
56
|
"@floating-ui/dom": "^1.7.6",
|
|
53
|
-
"@opendata-ai/openchart-core": "7.
|
|
54
|
-
"@opendata-ai/openchart-engine": "7.
|
|
57
|
+
"@opendata-ai/openchart-core": "7.8.0",
|
|
58
|
+
"@opendata-ai/openchart-engine": "7.8.0",
|
|
55
59
|
"d3-force": "^3.0.0",
|
|
56
60
|
"d3-quadtree": "^3.0.1"
|
|
57
61
|
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"happy-dom": ">=14.0.0"
|
|
64
|
+
},
|
|
65
|
+
"peerDependenciesMeta": {
|
|
66
|
+
"happy-dom": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
69
|
+
},
|
|
58
70
|
"devDependencies": {
|
|
59
71
|
"@types/d3-force": "^3.0.10",
|
|
60
72
|
"@types/d3-quadtree": "^3.0.6"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { LayerSpec } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { barSpec, lineSpec, pieSpec } from '../__test-fixtures__/specs';
|
|
4
|
+
import { renderStaticSVG } from '../static';
|
|
5
|
+
|
|
6
|
+
describe('renderStaticSVG', () => {
|
|
7
|
+
it('returns a valid SVG string', () => {
|
|
8
|
+
const svg = renderStaticSVG(lineSpec);
|
|
9
|
+
expect(svg).toContain('<svg');
|
|
10
|
+
expect(svg).toContain('</svg>');
|
|
11
|
+
expect(svg).toContain('viewBox');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('produces byte-identical output for the same spec', () => {
|
|
15
|
+
const first = renderStaticSVG(lineSpec, { width: 640, height: 420 });
|
|
16
|
+
const second = renderStaticSVG(lineSpec, { width: 640, height: 420 });
|
|
17
|
+
expect(first).toBe(second);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('includes a11y attributes', () => {
|
|
21
|
+
const svg = renderStaticSVG(lineSpec);
|
|
22
|
+
expect(svg).toContain('role=');
|
|
23
|
+
expect(svg).toContain('aria-label=');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('does not contain interactive elements', () => {
|
|
27
|
+
const svg = renderStaticSVG(lineSpec);
|
|
28
|
+
expect(svg).not.toContain('data-voronoi-overlay');
|
|
29
|
+
expect(svg).not.toContain('data-crosshair');
|
|
30
|
+
expect(svg).not.toContain('data-snap-dots');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('does not contain animation classes', () => {
|
|
34
|
+
const svg = renderStaticSVG(lineSpec);
|
|
35
|
+
expect(svg).not.toContain('oc-animate');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('includes the inlined style block with theme values', () => {
|
|
39
|
+
const svg = renderStaticSVG(lineSpec);
|
|
40
|
+
expect(svg).toContain('<style');
|
|
41
|
+
expect(svg).toContain('.oc-brand-dot');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('scopes custom properties to svg.oc-chart, not :root', () => {
|
|
45
|
+
const svg = renderStaticSVG(lineSpec);
|
|
46
|
+
expect(svg).toContain('svg.oc-chart');
|
|
47
|
+
expect(svg).not.toContain(':root');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('uses var() references in class rules for theme overridability', () => {
|
|
51
|
+
const svg = renderStaticSVG(lineSpec);
|
|
52
|
+
expect(svg).toContain('fill: var(--oc-accent)');
|
|
53
|
+
expect(svg).toContain('fill: var(--oc-text)');
|
|
54
|
+
expect(svg).toContain('fill: var(--oc-text-muted)');
|
|
55
|
+
expect(svg).toContain('font-family: var(--oc-font-family)');
|
|
56
|
+
expect(svg).not.toMatch(/\.oc-brand-dot \{ fill: #[0-9a-f]{6}/i);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('renders bar charts', () => {
|
|
60
|
+
const svg = renderStaticSVG(barSpec);
|
|
61
|
+
expect(svg).toContain('oc-mark-rect');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('renders pie charts', () => {
|
|
65
|
+
const svg = renderStaticSVG(pieSpec);
|
|
66
|
+
expect(svg).toContain('oc-mark-arc');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('renders layer specs', () => {
|
|
70
|
+
const layerSpec: LayerSpec = {
|
|
71
|
+
layer: [
|
|
72
|
+
{
|
|
73
|
+
mark: 'line',
|
|
74
|
+
data: [
|
|
75
|
+
{ x: '2020', y: 10 },
|
|
76
|
+
{ x: '2021', y: 20 },
|
|
77
|
+
],
|
|
78
|
+
encoding: {
|
|
79
|
+
x: { field: 'x', type: 'temporal' },
|
|
80
|
+
y: { field: 'y', type: 'quantitative' },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
mark: 'point',
|
|
85
|
+
data: [
|
|
86
|
+
{ x: '2020', y: 10 },
|
|
87
|
+
{ x: '2021', y: 20 },
|
|
88
|
+
],
|
|
89
|
+
encoding: {
|
|
90
|
+
x: { field: 'x', type: 'temporal' },
|
|
91
|
+
y: { field: 'y', type: 'quantitative' },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
const svg = renderStaticSVG(layerSpec);
|
|
97
|
+
expect(svg).toContain('<svg');
|
|
98
|
+
expect(svg).toContain('oc-mark-line');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('respects explicit width and height', () => {
|
|
102
|
+
const svg = renderStaticSVG(lineSpec, { width: 800, height: 500 });
|
|
103
|
+
expect(svg).toContain('viewBox="0 0 800 500"');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('renders dark mode when darkMode is "force"', () => {
|
|
107
|
+
const light = renderStaticSVG(lineSpec, { darkMode: 'off' });
|
|
108
|
+
const dark = renderStaticSVG(lineSpec, { darkMode: 'force' });
|
|
109
|
+
expect(light).not.toBe(dark);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('renders chrome elements', () => {
|
|
113
|
+
const svg = renderStaticSVG(lineSpec);
|
|
114
|
+
expect(svg).toContain('GDP Growth');
|
|
115
|
+
expect(svg).toContain('US vs UK over time');
|
|
116
|
+
expect(svg).toContain('World Bank');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('produces deterministic IDs across calls', () => {
|
|
120
|
+
const first = renderStaticSVG(lineSpec, { width: 640, height: 420 });
|
|
121
|
+
const second = renderStaticSVG(lineSpec, { width: 640, height: 420 });
|
|
122
|
+
const idPattern = /id="oc-[a-z]+-\d+"/g;
|
|
123
|
+
const firstIds = first.match(idPattern) ?? [];
|
|
124
|
+
const secondIds = second.match(idPattern) ?? [];
|
|
125
|
+
expect(firstIds).toEqual(secondIds);
|
|
126
|
+
expect(firstIds.length).toBeGreaterThan(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('does not pollute global document or window after rendering', () => {
|
|
130
|
+
const docBefore = globalThis.document;
|
|
131
|
+
const winBefore = globalThis.window;
|
|
132
|
+
renderStaticSVG(lineSpec);
|
|
133
|
+
expect(globalThis.document).toBe(docBefore);
|
|
134
|
+
expect(globalThis.window).toBe(winBefore);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('suppresses watermark when watermark is false', () => {
|
|
138
|
+
const svg = renderStaticSVG(lineSpec, { watermark: false });
|
|
139
|
+
expect(svg).not.toContain('oc-brand-text');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -525,16 +525,22 @@ describe('axis rendering', () => {
|
|
|
525
525
|
expect(labels.length).toBeGreaterThan(0);
|
|
526
526
|
});
|
|
527
527
|
|
|
528
|
-
it('x-axis has a baseline line element', () => {
|
|
529
|
-
const { svg } = renderSpec(
|
|
528
|
+
it('x-axis has a baseline line element for grounded marks', () => {
|
|
529
|
+
const { svg } = renderSpec(barSpec);
|
|
530
530
|
const xAxis = svg.querySelector('.oc-axis-x');
|
|
531
531
|
const line = xAxis!.querySelector('line');
|
|
532
|
-
// The renderer draws an axis line for x-axis
|
|
533
532
|
expect(line).not.toBeNull();
|
|
534
533
|
});
|
|
535
534
|
|
|
535
|
+
it('x-axis hides baseline for non-grounded marks', () => {
|
|
536
|
+
const { svg } = renderSpec(lineSpec);
|
|
537
|
+
const xAxis = svg.querySelector('.oc-axis-x');
|
|
538
|
+
const line = xAxis!.querySelector('line');
|
|
539
|
+
expect(line).toBeNull();
|
|
540
|
+
});
|
|
541
|
+
|
|
536
542
|
it('x-tick labels hang below the axis line by the label padding (no hugging)', () => {
|
|
537
|
-
const { svg, layout } = renderSpec(
|
|
543
|
+
const { svg, layout } = renderSpec(barSpec);
|
|
538
544
|
const xAxis = svg.querySelector('.oc-axis-x')!;
|
|
539
545
|
const axisLineY = Number(xAxis.querySelector('line')!.getAttribute('y2'));
|
|
540
546
|
const labels = Array.from(xAxis.querySelectorAll('text.oc-axis-tick'));
|
package/src/mount.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
LayerSpec,
|
|
21
21
|
ThemeConfig,
|
|
22
22
|
} from '@opendata-ai/openchart-core';
|
|
23
|
-
import { isLayerSpec } from '@opendata-ai/openchart-core';
|
|
23
|
+
import { isGraphSpec, isLayerSpec } from '@opendata-ai/openchart-core';
|
|
24
24
|
import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
|
|
25
25
|
import { cancelAnimations, setupAnimationCleanup } from './animation';
|
|
26
26
|
import {
|
|
@@ -57,6 +57,7 @@ import { createMeasureText, resolveFontFamily, scheduleFontReload } from './meas
|
|
|
57
57
|
import { observeResize } from './resize-observer';
|
|
58
58
|
import { renderChartSVG } from './svg-renderer';
|
|
59
59
|
import { createTextEditOverlay } from './text-edit-overlay';
|
|
60
|
+
import { stampThemeProperties } from './theme-tokens';
|
|
60
61
|
import { createTooltipManager, type TooltipManager } from './tooltip';
|
|
61
62
|
|
|
62
63
|
// ---------------------------------------------------------------------------
|
|
@@ -114,6 +115,8 @@ export interface ChartInstance {
|
|
|
114
115
|
deselect(): void;
|
|
115
116
|
/** Whether inline text editing is active. */
|
|
116
117
|
readonly isEditing: boolean;
|
|
118
|
+
/** Set highlight values on the color encoding and re-render. Pass null to clear. */
|
|
119
|
+
setHighlight(values: string[] | null): void;
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
// ---------------------------------------------------------------------------
|
|
@@ -789,6 +792,10 @@ export function createChart<TData extends DataRow = DataRow>(
|
|
|
789
792
|
container.classList.remove('oc-dark');
|
|
790
793
|
}
|
|
791
794
|
|
|
795
|
+
// Stamp resolved theme as CSS custom properties on the container
|
|
796
|
+
// so CSS consumers read from the same source of truth as the JS engine.
|
|
797
|
+
stampThemeProperties(container, currentLayout.theme);
|
|
798
|
+
|
|
792
799
|
// Set up animation cleanup on first render only
|
|
793
800
|
if (shouldAnimate && svgElement) {
|
|
794
801
|
cleanupAnimations = setupAnimationCleanup(svgElement, () => {
|
|
@@ -849,6 +856,28 @@ export function createChart<TData extends DataRow = DataRow>(
|
|
|
849
856
|
render();
|
|
850
857
|
}
|
|
851
858
|
|
|
859
|
+
function setHighlight(values: string[] | null): void {
|
|
860
|
+
if (destroyed) return;
|
|
861
|
+
if (isLayerSpec(currentSpec) || isGraphSpec(currentSpec as unknown as Record<string, unknown>))
|
|
862
|
+
return;
|
|
863
|
+
const spec = currentSpec as ChartSpec;
|
|
864
|
+
const colorEnc = spec.encoding?.color;
|
|
865
|
+
if (!colorEnc || typeof colorEnc !== 'object' || !('field' in colorEnc)) return;
|
|
866
|
+
const current = (colorEnc as { highlight?: string | string[] }).highlight;
|
|
867
|
+
if (!values?.length && !current) return;
|
|
868
|
+
const updatedColor = { ...colorEnc };
|
|
869
|
+
if (values && values.length > 0) {
|
|
870
|
+
updatedColor.highlight = values;
|
|
871
|
+
} else {
|
|
872
|
+
delete updatedColor.highlight;
|
|
873
|
+
}
|
|
874
|
+
currentSpec = {
|
|
875
|
+
...spec,
|
|
876
|
+
encoding: { ...spec.encoding, color: updatedColor },
|
|
877
|
+
} as ChartSpec;
|
|
878
|
+
render();
|
|
879
|
+
}
|
|
880
|
+
|
|
852
881
|
function doExport(format: 'svg'): string;
|
|
853
882
|
function doExport(format: 'svg-with-fonts', exportOptions?: SVGExportOptions): Promise<string>;
|
|
854
883
|
function doExport(format: 'png', exportOptions?: ExportOptions): Promise<Blob>;
|
|
@@ -985,5 +1014,6 @@ export function createChart<TData extends DataRow = DataRow>(
|
|
|
985
1014
|
get isEditing(): boolean {
|
|
986
1015
|
return isTextEditingActive;
|
|
987
1016
|
},
|
|
1017
|
+
setHighlight,
|
|
988
1018
|
};
|
|
989
1019
|
}
|
|
@@ -121,6 +121,49 @@ function renderAnnotation(
|
|
|
121
121
|
g.appendChild(line);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// Footnote marker: annotation was demoted by auto-thinning. Render a numbered
|
|
125
|
+
// dot at the data point instead of the full label text.
|
|
126
|
+
if (annotation.footnoteIndex != null && annotation.label) {
|
|
127
|
+
const cx =
|
|
128
|
+
annotation.label.connector?.endpoint?.x ??
|
|
129
|
+
annotation.label.connector?.to.x ??
|
|
130
|
+
annotation.label.x;
|
|
131
|
+
const cy =
|
|
132
|
+
annotation.label.connector?.endpoint?.y ??
|
|
133
|
+
annotation.label.connector?.to.y ??
|
|
134
|
+
annotation.label.y;
|
|
135
|
+
const r = 8;
|
|
136
|
+
const circle = createSVGElement('circle');
|
|
137
|
+
circle.setAttribute('class', 'oc-annotation-footnote-marker');
|
|
138
|
+
setAttrs(circle, {
|
|
139
|
+
cx,
|
|
140
|
+
cy,
|
|
141
|
+
r,
|
|
142
|
+
fill: bgColor ?? '#ffffff',
|
|
143
|
+
stroke: annotation.label.style.fill ?? '#666',
|
|
144
|
+
'stroke-width': 1.5,
|
|
145
|
+
});
|
|
146
|
+
g.appendChild(circle);
|
|
147
|
+
|
|
148
|
+
const num = createSVGElement('text');
|
|
149
|
+
num.setAttribute('class', 'oc-annotation-footnote-number');
|
|
150
|
+
setAttrs(num, {
|
|
151
|
+
x: cx,
|
|
152
|
+
y: cy,
|
|
153
|
+
'dominant-baseline': 'central',
|
|
154
|
+
'text-anchor': 'middle',
|
|
155
|
+
});
|
|
156
|
+
applyTextStyle(num, {
|
|
157
|
+
...annotation.label.style,
|
|
158
|
+
fontSize: 9,
|
|
159
|
+
fontWeight: 600,
|
|
160
|
+
});
|
|
161
|
+
num.textContent = String(annotation.footnoteIndex);
|
|
162
|
+
g.appendChild(num);
|
|
163
|
+
parent.appendChild(g);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
124
167
|
// Label with optional connector line
|
|
125
168
|
if (annotation.label?.visible) {
|
|
126
169
|
// Render connector first (behind the label text)
|
package/src/renderers/chrome.ts
CHANGED
|
@@ -91,10 +91,41 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
const bottomOffset = layout.chrome.bottomAnchorY ?? layout.area.y + layout.area.height;
|
|
94
|
+
|
|
95
|
+
// Footnotes from auto-thinned annotations, rendered above source/byline.
|
|
96
|
+
// Each footnote gets its own line to avoid horizontal overflow.
|
|
97
|
+
let footnoteBandHeight = 0;
|
|
98
|
+
if (chrome.footnotes && chrome.footnotes.length > 0) {
|
|
99
|
+
const fontSize = layout.theme.fonts.sizes.small;
|
|
100
|
+
const pad = layout.theme.spacing.padding;
|
|
101
|
+
const lineHeight = fontSize * 1.3;
|
|
102
|
+
const style = {
|
|
103
|
+
fontFamily: layout.theme.fonts.family,
|
|
104
|
+
fontSize,
|
|
105
|
+
fontWeight: layout.theme.fonts.weights.normal,
|
|
106
|
+
fill: layout.theme.chrome.source.color,
|
|
107
|
+
lineHeight: 1.3,
|
|
108
|
+
textAnchor: 'start' as const,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < chrome.footnotes.length; i++) {
|
|
112
|
+
const f = chrome.footnotes[i];
|
|
113
|
+
const y =
|
|
114
|
+
bottomOffset + layout.theme.spacing.chartToFooter + textAscent(fontSize) + i * lineHeight;
|
|
115
|
+
const el = createSVGElement('text');
|
|
116
|
+
el.setAttribute('class', 'oc-footnotes');
|
|
117
|
+
setAttrs(el, { x: pad, y });
|
|
118
|
+
applyTextStyle(el, style);
|
|
119
|
+
el.textContent = `${f.index}. ${f.text}`;
|
|
120
|
+
g.appendChild(el);
|
|
121
|
+
}
|
|
122
|
+
footnoteBandHeight = chrome.footnotes.length * lineHeight + 4;
|
|
123
|
+
}
|
|
124
|
+
|
|
94
125
|
if (chrome.source) {
|
|
95
126
|
renderChromeElement(
|
|
96
127
|
g,
|
|
97
|
-
{ ...chrome.source, y: bottomOffset + chrome.source.y },
|
|
128
|
+
{ ...chrome.source, y: bottomOffset + chrome.source.y + footnoteBandHeight },
|
|
98
129
|
'oc-source',
|
|
99
130
|
'source',
|
|
100
131
|
measureText,
|
|
@@ -103,7 +134,7 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
|
103
134
|
if (chrome.byline) {
|
|
104
135
|
renderChromeElement(
|
|
105
136
|
g,
|
|
106
|
-
{ ...chrome.byline, y: bottomOffset + chrome.byline.y },
|
|
137
|
+
{ ...chrome.byline, y: bottomOffset + chrome.byline.y + footnoteBandHeight },
|
|
107
138
|
'oc-byline',
|
|
108
139
|
'byline',
|
|
109
140
|
measureText,
|
|
@@ -112,14 +143,14 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
|
112
143
|
if (chrome.footer) {
|
|
113
144
|
renderChromeElement(
|
|
114
145
|
g,
|
|
115
|
-
{ ...chrome.footer, y: bottomOffset + chrome.footer.y },
|
|
146
|
+
{ ...chrome.footer, y: bottomOffset + chrome.footer.y + footnoteBandHeight },
|
|
116
147
|
'oc-footer',
|
|
117
148
|
'footer',
|
|
118
149
|
measureText,
|
|
119
150
|
);
|
|
120
151
|
}
|
|
121
152
|
if (chrome.brand) {
|
|
122
|
-
const brandY = bottomOffset + chrome.brand.y;
|
|
153
|
+
const brandY = bottomOffset + chrome.brand.y + footnoteBandHeight;
|
|
123
154
|
renderChromeElement(g, { ...chrome.brand, y: brandY }, 'oc-brand', 'brand', measureText);
|
|
124
155
|
// Accent dot to the left of the brand text. text-anchor=end means
|
|
125
156
|
// brand.x is the right edge, so the dot sits 12px left of the measured
|