@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.
@@ -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
+ }