@opendata-ai/openchart-vanilla 6.19.3 → 6.21.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.d.ts +18 -7
- package/dist/index.js +718 -778
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/gradient-ids.test.ts +159 -0
- package/src/__tests__/resize-timing.test.ts +184 -0
- package/src/gradient-utils.ts +6 -8
- package/src/mount.ts +4 -11
- package/src/renderers/annotations.ts +212 -0
- package/src/renderers/axes.ts +164 -0
- package/src/renderers/brand.ts +75 -0
- package/src/renderers/chrome.ts +96 -0
- package/src/renderers/legend.ts +131 -0
- package/src/renderers/marks.ts +427 -0
- package/src/renderers/svg-dom.ts +66 -0
- package/src/sankey-renderer.ts +6 -43
- package/src/svg-ids.ts +18 -0
- package/src/svg-renderer.ts +64 -1269
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Axis rendering: axis line, ticks, tick labels, gridlines, axis title.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AxisLayout, ChartLayout } from '@opendata-ai/openchart-core';
|
|
6
|
+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
7
|
+
import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
|
|
8
|
+
|
|
9
|
+
function renderAxis(
|
|
10
|
+
parent: SVGElement,
|
|
11
|
+
axis: AxisLayout,
|
|
12
|
+
orientation: 'x' | 'y',
|
|
13
|
+
layout: ChartLayout,
|
|
14
|
+
): void {
|
|
15
|
+
const g = createSVGElement('g');
|
|
16
|
+
g.setAttribute('class', `oc-axis oc-axis-${orientation}`);
|
|
17
|
+
|
|
18
|
+
const { area } = layout;
|
|
19
|
+
|
|
20
|
+
// Only draw axis line for x-axis (bottom baseline).
|
|
21
|
+
// Horizontal gridlines already guide y-values, so the vertical y-axis line is redundant.
|
|
22
|
+
if (orientation === 'x') {
|
|
23
|
+
const line = createSVGElement('line');
|
|
24
|
+
line.setAttribute('class', 'oc-axis-line');
|
|
25
|
+
setAttrs(line, {
|
|
26
|
+
x1: axis.start.x,
|
|
27
|
+
y1: axis.start.y,
|
|
28
|
+
x2: axis.end.x,
|
|
29
|
+
y2: axis.end.y,
|
|
30
|
+
stroke: layout.theme.colors.axis,
|
|
31
|
+
'stroke-width': 1,
|
|
32
|
+
});
|
|
33
|
+
g.appendChild(line);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Ticks and labels
|
|
37
|
+
// Tick positions are absolute pixel coordinates from D3 scales whose range
|
|
38
|
+
// was set to [chartArea.x, chartArea.x + chartArea.width] (and similarly for y).
|
|
39
|
+
// Don't add area.x/area.y again or you'll double-offset everything.
|
|
40
|
+
for (const tick of axis.ticks) {
|
|
41
|
+
if (orientation === 'x') {
|
|
42
|
+
// Label (no tick marks -- gridlines provide sufficient reference)
|
|
43
|
+
const label = createSVGElement('text');
|
|
44
|
+
label.setAttribute('class', 'oc-axis-tick');
|
|
45
|
+
|
|
46
|
+
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
|
|
47
|
+
// Rotated labels: anchor at the rotation pivot point
|
|
48
|
+
const labelX = tick.position;
|
|
49
|
+
const labelY = area.y + area.height + 6;
|
|
50
|
+
setAttrs(label, {
|
|
51
|
+
x: labelX,
|
|
52
|
+
y: labelY,
|
|
53
|
+
'text-anchor': axis.tickAngle < 0 ? 'end' : 'start',
|
|
54
|
+
'dominant-baseline': 'central',
|
|
55
|
+
transform: `rotate(${axis.tickAngle}, ${labelX}, ${labelY})`,
|
|
56
|
+
});
|
|
57
|
+
} else {
|
|
58
|
+
setAttrs(label, {
|
|
59
|
+
x: tick.position,
|
|
60
|
+
y: area.y + area.height + 14,
|
|
61
|
+
'text-anchor': 'middle',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
applyTextStyle(label, axis.tickLabelStyle);
|
|
66
|
+
label.textContent = tick.label;
|
|
67
|
+
g.appendChild(label);
|
|
68
|
+
} else {
|
|
69
|
+
// Label (no tick marks -- gridlines provide sufficient reference)
|
|
70
|
+
const label = createSVGElement('text');
|
|
71
|
+
label.setAttribute('class', 'oc-axis-tick');
|
|
72
|
+
setAttrs(label, {
|
|
73
|
+
x: area.x - 6,
|
|
74
|
+
y: tick.position,
|
|
75
|
+
'text-anchor': 'end',
|
|
76
|
+
'dominant-baseline': 'central',
|
|
77
|
+
});
|
|
78
|
+
applyTextStyle(label, axis.tickLabelStyle);
|
|
79
|
+
label.textContent = tick.label;
|
|
80
|
+
g.appendChild(label);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Gridlines (positions are also absolute from the scales)
|
|
85
|
+
for (const gridline of axis.gridlines) {
|
|
86
|
+
const gl = createSVGElement('line');
|
|
87
|
+
gl.setAttribute('class', 'oc-gridline');
|
|
88
|
+
if (orientation === 'y') {
|
|
89
|
+
setAttrs(gl, {
|
|
90
|
+
x1: area.x,
|
|
91
|
+
y1: gridline.position,
|
|
92
|
+
x2: area.x + area.width,
|
|
93
|
+
y2: gridline.position,
|
|
94
|
+
stroke: layout.theme.colors.gridline,
|
|
95
|
+
'stroke-width': 1,
|
|
96
|
+
'stroke-opacity': 0.6,
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
setAttrs(gl, {
|
|
100
|
+
x1: gridline.position,
|
|
101
|
+
y1: area.y,
|
|
102
|
+
x2: gridline.position,
|
|
103
|
+
y2: area.y + area.height,
|
|
104
|
+
stroke: layout.theme.colors.gridline,
|
|
105
|
+
'stroke-width': 1,
|
|
106
|
+
'stroke-opacity': 0.6,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
g.appendChild(gl);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Axis label
|
|
113
|
+
if (axis.label && axis.labelStyle) {
|
|
114
|
+
const axisLabel = createSVGElement('text');
|
|
115
|
+
axisLabel.setAttribute('class', 'oc-axis-title');
|
|
116
|
+
applyTextStyle(axisLabel, axis.labelStyle);
|
|
117
|
+
axisLabel.textContent = axis.label;
|
|
118
|
+
|
|
119
|
+
if (orientation === 'x') {
|
|
120
|
+
// Position axis title below tick labels. For rotated labels, compute
|
|
121
|
+
// the vertical extent of the rotated ticks and place the title below.
|
|
122
|
+
let titleY = area.y + area.height + 35;
|
|
123
|
+
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
|
|
124
|
+
const angleRad = Math.abs(axis.tickAngle) * (Math.PI / 180);
|
|
125
|
+
let maxLabelWidth = 40;
|
|
126
|
+
for (const tick of axis.ticks) {
|
|
127
|
+
const w = estimateTextWidth(
|
|
128
|
+
tick.label,
|
|
129
|
+
axis.tickLabelStyle.fontSize,
|
|
130
|
+
axis.tickLabelStyle.fontWeight,
|
|
131
|
+
);
|
|
132
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
133
|
+
}
|
|
134
|
+
const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
|
|
135
|
+
titleY = area.y + area.height + rotatedHeight + 14;
|
|
136
|
+
}
|
|
137
|
+
setAttrs(axisLabel, {
|
|
138
|
+
x: area.x + area.width / 2,
|
|
139
|
+
y: titleY,
|
|
140
|
+
'text-anchor': 'middle',
|
|
141
|
+
});
|
|
142
|
+
} else {
|
|
143
|
+
// Rotated y-axis label
|
|
144
|
+
setAttrs(axisLabel, {
|
|
145
|
+
x: area.x - 45,
|
|
146
|
+
y: area.y + area.height / 2,
|
|
147
|
+
'text-anchor': 'middle',
|
|
148
|
+
transform: `rotate(-90, ${area.x - 45}, ${area.y + area.height / 2})`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
g.appendChild(axisLabel);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
parent.appendChild(g);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function renderAxes(parent: SVGElement, layout: ChartLayout): void {
|
|
158
|
+
if (layout.axes.x) {
|
|
159
|
+
renderAxis(parent, layout.axes.x, 'x', layout);
|
|
160
|
+
}
|
|
161
|
+
if (layout.axes.y) {
|
|
162
|
+
renderAxis(parent, layout.axes.y, 'y', layout);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand rendering: the "tryOpenData.ai" watermark footer.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ChartLayout } from '@opendata-ai/openchart-core';
|
|
6
|
+
import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH } from '@opendata-ai/openchart-core';
|
|
7
|
+
import { computeXAxisExtent, createSVGElement, setAttrs, XLINK_NS } from './svg-dom';
|
|
8
|
+
|
|
9
|
+
const BRAND_URL = 'https://tryopendata.ai';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render the "OpenData" brand as a footer-row element, right-aligned on the
|
|
13
|
+
* same baseline as the first bottom chrome text (source/byline/footer).
|
|
14
|
+
* Uses the same font size as chrome source text so it blends in as a subtle
|
|
15
|
+
* footer item rather than occupying independent visual space.
|
|
16
|
+
*/
|
|
17
|
+
export function renderBrand(parent: SVGElement, layout: ChartLayout): void {
|
|
18
|
+
if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
|
|
19
|
+
|
|
20
|
+
const { width } = layout.dimensions;
|
|
21
|
+
const padding = layout.theme.spacing.padding;
|
|
22
|
+
const rightEdge = width - padding;
|
|
23
|
+
const fill = layout.theme.colors.axis;
|
|
24
|
+
|
|
25
|
+
// Vertically align with the first bottom chrome element.
|
|
26
|
+
const { chrome } = layout;
|
|
27
|
+
const xAxisExtent = computeXAxisExtent(layout);
|
|
28
|
+
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
|
|
29
|
+
const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
|
|
30
|
+
const chromeY = firstBottom
|
|
31
|
+
? bottomOffset + firstBottom.y
|
|
32
|
+
: bottomOffset + layout.theme.spacing.chartToFooter;
|
|
33
|
+
|
|
34
|
+
const a = createSVGElement('a');
|
|
35
|
+
a.setAttribute('href', BRAND_URL);
|
|
36
|
+
a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
|
|
37
|
+
a.setAttribute('target', '_blank');
|
|
38
|
+
a.setAttribute('rel', 'noopener');
|
|
39
|
+
a.setAttribute('class', 'oc-chrome-ref');
|
|
40
|
+
|
|
41
|
+
// "try" in normal weight, "OpenData" in semibold, ".ai" in normal weight,
|
|
42
|
+
// rendered as a single right-aligned text element with three tspans.
|
|
43
|
+
// Use alphabetic baseline so mixed-size tspans share a common bottom line.
|
|
44
|
+
const BRAND_LARGE = 16;
|
|
45
|
+
const text = createSVGElement('text');
|
|
46
|
+
setAttrs(text, {
|
|
47
|
+
x: rightEdge,
|
|
48
|
+
y: chromeY + BRAND_LARGE,
|
|
49
|
+
'dominant-baseline': 'alphabetic',
|
|
50
|
+
'font-family': layout.theme.fonts.family,
|
|
51
|
+
'font-size': BRAND_FONT_SIZE,
|
|
52
|
+
'text-anchor': 'end',
|
|
53
|
+
'fill-opacity': 0.55,
|
|
54
|
+
});
|
|
55
|
+
(text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
|
|
56
|
+
|
|
57
|
+
const trySpan = createSVGElement('tspan');
|
|
58
|
+
trySpan.setAttribute('font-weight', '500');
|
|
59
|
+
trySpan.textContent = 'try';
|
|
60
|
+
text.appendChild(trySpan);
|
|
61
|
+
|
|
62
|
+
const openDataSpan = createSVGElement('tspan');
|
|
63
|
+
openDataSpan.setAttribute('font-weight', '600');
|
|
64
|
+
openDataSpan.setAttribute('font-size', String(BRAND_LARGE));
|
|
65
|
+
openDataSpan.textContent = 'OpenData';
|
|
66
|
+
text.appendChild(openDataSpan);
|
|
67
|
+
|
|
68
|
+
const aiSpan = createSVGElement('tspan');
|
|
69
|
+
aiSpan.setAttribute('font-weight', '500');
|
|
70
|
+
aiSpan.textContent = '.ai';
|
|
71
|
+
text.appendChild(aiSpan);
|
|
72
|
+
|
|
73
|
+
a.appendChild(text);
|
|
74
|
+
parent.appendChild(a);
|
|
75
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome rendering: title, subtitle, source, byline, footer.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ChartLayout,
|
|
7
|
+
MeasureTextFn,
|
|
8
|
+
ResolvedChromeElement,
|
|
9
|
+
} from '@opendata-ai/openchart-core';
|
|
10
|
+
import { wrapText } from '@opendata-ai/openchart-core';
|
|
11
|
+
import { applyTextStyle, computeXAxisExtent, createSVGElement, setAttrs } from './svg-dom';
|
|
12
|
+
|
|
13
|
+
function renderChromeElement(
|
|
14
|
+
parent: SVGElement,
|
|
15
|
+
element: ResolvedChromeElement,
|
|
16
|
+
className: string,
|
|
17
|
+
chromeKey: string,
|
|
18
|
+
measureText?: MeasureTextFn,
|
|
19
|
+
): void {
|
|
20
|
+
const text = createSVGElement('text');
|
|
21
|
+
setAttrs(text, { x: element.x, y: element.y });
|
|
22
|
+
applyTextStyle(text, element.style);
|
|
23
|
+
text.setAttribute('class', className);
|
|
24
|
+
text.setAttribute('data-chrome-key', chromeKey);
|
|
25
|
+
|
|
26
|
+
const lines = wrapText(
|
|
27
|
+
element.text,
|
|
28
|
+
element.style.fontSize,
|
|
29
|
+
element.style.fontWeight,
|
|
30
|
+
element.maxWidth,
|
|
31
|
+
measureText,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (lines.length === 1) {
|
|
35
|
+
text.textContent = element.text;
|
|
36
|
+
} else {
|
|
37
|
+
const lineHeight = element.style.fontSize * (element.style.lineHeight ?? 1.3);
|
|
38
|
+
for (let i = 0; i < lines.length; i++) {
|
|
39
|
+
const tspan = createSVGElement('tspan');
|
|
40
|
+
setAttrs(tspan, { x: element.x, dy: i === 0 ? 0 : lineHeight });
|
|
41
|
+
tspan.textContent = lines[i];
|
|
42
|
+
text.appendChild(tspan);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
parent.appendChild(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
50
|
+
const g = createSVGElement('g');
|
|
51
|
+
g.setAttribute('class', 'oc-chrome');
|
|
52
|
+
|
|
53
|
+
const { chrome, measureText } = layout;
|
|
54
|
+
|
|
55
|
+
// Top chrome: render at their stored y positions (already absolute)
|
|
56
|
+
if (chrome.title) {
|
|
57
|
+
renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);
|
|
58
|
+
}
|
|
59
|
+
if (chrome.subtitle) {
|
|
60
|
+
renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Bottom chrome starts below x-axis labels/title, not at chart area bottom.
|
|
64
|
+
// Accounts for rotated tick labels which need more vertical space.
|
|
65
|
+
const xAxisExtent = computeXAxisExtent(layout);
|
|
66
|
+
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
|
|
67
|
+
if (chrome.source) {
|
|
68
|
+
renderChromeElement(
|
|
69
|
+
g,
|
|
70
|
+
{ ...chrome.source, y: bottomOffset + chrome.source.y },
|
|
71
|
+
'oc-source',
|
|
72
|
+
'source',
|
|
73
|
+
measureText,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (chrome.byline) {
|
|
77
|
+
renderChromeElement(
|
|
78
|
+
g,
|
|
79
|
+
{ ...chrome.byline, y: bottomOffset + chrome.byline.y },
|
|
80
|
+
'oc-byline',
|
|
81
|
+
'byline',
|
|
82
|
+
measureText,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (chrome.footer) {
|
|
86
|
+
renderChromeElement(
|
|
87
|
+
g,
|
|
88
|
+
{ ...chrome.footer, y: bottomOffset + chrome.footer.y },
|
|
89
|
+
'oc-footer',
|
|
90
|
+
'footer',
|
|
91
|
+
measureText,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
parent.appendChild(g);
|
|
96
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legend rendering: swatches + labels with wrap/overflow handling.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { LegendLayout } from '@opendata-ai/openchart-core';
|
|
6
|
+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
7
|
+
import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
|
|
8
|
+
|
|
9
|
+
export function renderLegend(parent: SVGElement, legend: LegendLayout): void {
|
|
10
|
+
if (legend.entries.length === 0) return;
|
|
11
|
+
|
|
12
|
+
const g = createSVGElement('g');
|
|
13
|
+
g.setAttribute('class', 'oc-legend');
|
|
14
|
+
g.setAttribute('role', 'list');
|
|
15
|
+
g.setAttribute('aria-label', 'Chart legend');
|
|
16
|
+
|
|
17
|
+
const isHorizontal = legend.position === 'top' || legend.position === 'bottom';
|
|
18
|
+
let offsetX = legend.bounds.x;
|
|
19
|
+
let offsetY = legend.bounds.y;
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < legend.entries.length; i++) {
|
|
22
|
+
const entry = legend.entries[i];
|
|
23
|
+
|
|
24
|
+
// Pre-check: wrap to next line if this entry would overflow bounds
|
|
25
|
+
if (isHorizontal && i > 0) {
|
|
26
|
+
const labelWidth = estimateTextWidth(
|
|
27
|
+
entry.label,
|
|
28
|
+
legend.labelStyle.fontSize,
|
|
29
|
+
legend.labelStyle.fontWeight,
|
|
30
|
+
);
|
|
31
|
+
const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
|
|
32
|
+
if (offsetX + entryWidth > legend.bounds.x + legend.bounds.width) {
|
|
33
|
+
offsetX = legend.bounds.x;
|
|
34
|
+
offsetY += legend.swatchSize + 6;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const entryG = createSVGElement('g');
|
|
38
|
+
entryG.setAttribute('class', 'oc-legend-entry');
|
|
39
|
+
entryG.setAttribute('role', 'listitem');
|
|
40
|
+
entryG.setAttribute('data-legend-index', String(i));
|
|
41
|
+
entryG.setAttribute('data-legend-label', entry.label);
|
|
42
|
+
if (entry.overflow) {
|
|
43
|
+
entryG.setAttribute('data-legend-overflow', 'true');
|
|
44
|
+
entryG.setAttribute('aria-label', entry.label);
|
|
45
|
+
entryG.setAttribute('opacity', '0.5');
|
|
46
|
+
} else {
|
|
47
|
+
entryG.setAttribute(
|
|
48
|
+
'aria-label',
|
|
49
|
+
`${entry.label}: ${entry.active !== false ? 'visible' : 'hidden'}`,
|
|
50
|
+
);
|
|
51
|
+
entryG.setAttribute('style', 'cursor: pointer');
|
|
52
|
+
|
|
53
|
+
// Apply dimming for inactive entries
|
|
54
|
+
if (entry.active === false) {
|
|
55
|
+
entryG.setAttribute('opacity', '0.3');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Swatch
|
|
60
|
+
if (entry.shape === 'circle') {
|
|
61
|
+
const circle = createSVGElement('circle');
|
|
62
|
+
setAttrs(circle, {
|
|
63
|
+
cx: offsetX + legend.swatchSize / 2,
|
|
64
|
+
cy: offsetY + legend.swatchSize / 2,
|
|
65
|
+
r: legend.swatchSize / 2,
|
|
66
|
+
fill: entry.color,
|
|
67
|
+
});
|
|
68
|
+
entryG.appendChild(circle);
|
|
69
|
+
} else if (entry.shape === 'line') {
|
|
70
|
+
// Line swatch: a short line segment with a dot in the middle
|
|
71
|
+
const line = createSVGElement('line');
|
|
72
|
+
setAttrs(line, {
|
|
73
|
+
x1: offsetX,
|
|
74
|
+
y1: offsetY + legend.swatchSize / 2,
|
|
75
|
+
x2: offsetX + legend.swatchSize,
|
|
76
|
+
y2: offsetY + legend.swatchSize / 2,
|
|
77
|
+
stroke: entry.color,
|
|
78
|
+
'stroke-width': 2,
|
|
79
|
+
});
|
|
80
|
+
entryG.appendChild(line);
|
|
81
|
+
// Small dot at center
|
|
82
|
+
const dot = createSVGElement('circle');
|
|
83
|
+
setAttrs(dot, {
|
|
84
|
+
cx: offsetX + legend.swatchSize / 2,
|
|
85
|
+
cy: offsetY + legend.swatchSize / 2,
|
|
86
|
+
r: 2.5,
|
|
87
|
+
fill: entry.color,
|
|
88
|
+
});
|
|
89
|
+
entryG.appendChild(dot);
|
|
90
|
+
} else {
|
|
91
|
+
const rect = createSVGElement('rect');
|
|
92
|
+
setAttrs(rect, {
|
|
93
|
+
x: offsetX,
|
|
94
|
+
y: offsetY,
|
|
95
|
+
width: legend.swatchSize,
|
|
96
|
+
height: legend.swatchSize,
|
|
97
|
+
fill: entry.color,
|
|
98
|
+
rx: 2,
|
|
99
|
+
});
|
|
100
|
+
entryG.appendChild(rect);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Label
|
|
104
|
+
const label = createSVGElement('text');
|
|
105
|
+
setAttrs(label, {
|
|
106
|
+
x: offsetX + legend.swatchSize + legend.swatchGap,
|
|
107
|
+
y: offsetY + legend.swatchSize / 2,
|
|
108
|
+
'dominant-baseline': 'central',
|
|
109
|
+
});
|
|
110
|
+
applyTextStyle(label, legend.labelStyle);
|
|
111
|
+
label.textContent = entry.label;
|
|
112
|
+
entryG.appendChild(label);
|
|
113
|
+
|
|
114
|
+
g.appendChild(entryG);
|
|
115
|
+
|
|
116
|
+
// Advance position for next entry
|
|
117
|
+
if (isHorizontal) {
|
|
118
|
+
const labelWidth = estimateTextWidth(
|
|
119
|
+
entry.label,
|
|
120
|
+
legend.labelStyle.fontSize,
|
|
121
|
+
legend.labelStyle.fontWeight,
|
|
122
|
+
);
|
|
123
|
+
const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
|
|
124
|
+
offsetX += entryWidth;
|
|
125
|
+
} else {
|
|
126
|
+
offsetY += legend.swatchSize + legend.entryGap;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
parent.appendChild(g);
|
|
131
|
+
}
|