Skip to content

Commit 3b59d20

Browse files
committed
refactor(vanilla): extract renderBrand into renderers/brand.ts
1 parent ab63110 commit 3b59d20

4 files changed

Lines changed: 119 additions & 113 deletions

File tree

examples/src/animation.stories.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -498,14 +498,14 @@ const annotationDelaySpec: ChartSpec = {
498498
},
499499
},
500500
annotations: [
501-
{
502-
type: 'text',
503-
text: 'Product relaunch',
504-
x: '2024-04',
505-
y: 53,
506-
offset: { dx: -80, dy: -18 },
507-
connector: true,
508-
},
501+
// {
502+
// type: 'text',
503+
// text: 'Product relaunch',
504+
// x: '2024-04',
505+
// y: 53,
506+
// offset: { dx: -80, dy: -18 },
507+
// connector: true,
508+
// },
509509
{
510510
type: 'refline',
511511
y: 70,
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

packages/vanilla/src/renderers/svg-dom.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* Pure, stateless utilities. No layout/theme knowledge.
55
*/
66

7-
import type { TextStyle } from '@opendata-ai/openchart-core';
7+
import type { ChartLayout, TextStyle } from '@opendata-ai/openchart-core';
8+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
89

910
export const SVG_NS = 'http://www.w3.org/2000/svg';
1011
export const XLINK_NS = 'http://www.w3.org/1999/xlink';
@@ -38,3 +39,28 @@ export function applyTextStyle(el: SVGElement, style: TextStyle): void {
3839
el.setAttribute('font-variant', style.fontVariant);
3940
}
4041
}
42+
43+
/**
44+
* Compute the vertical extent of x-axis labels below the chart area.
45+
* Accounts for rotated tick labels which need more vertical space.
46+
*/
47+
export function computeXAxisExtent(layout: ChartLayout): number {
48+
const xAxis = layout.axes.x;
49+
if (!xAxis) return 0;
50+
51+
if (xAxis.tickAngle && Math.abs(xAxis.tickAngle) > 10) {
52+
// Rotated labels: estimate height from the longest tick label.
53+
const fontSize = xAxis.tickLabelStyle.fontSize;
54+
const fontWeight = xAxis.tickLabelStyle.fontWeight;
55+
const angleRad = Math.abs(xAxis.tickAngle) * (Math.PI / 180);
56+
let maxLabelWidth = 40;
57+
for (const tick of xAxis.ticks) {
58+
const w = estimateTextWidth(tick.label, fontSize, fontWeight);
59+
if (w > maxLabelWidth) maxLabelWidth = w;
60+
}
61+
const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
62+
return xAxis.label ? rotatedHeight + 20 : rotatedHeight;
63+
}
64+
65+
return xAxis.label ? 48 : 26;
66+
}

packages/vanilla/src/svg-renderer.ts

Lines changed: 9 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,17 @@ import type {
2828
TextMarkLayout,
2929
TickMarkLayout,
3030
} from '@opendata-ai/openchart-core';
31-
import {
32-
BRAND_FONT_SIZE,
33-
BRAND_MIN_WIDTH,
34-
estimateTextWidth,
35-
wrapText,
36-
} from '@opendata-ai/openchart-core';
31+
import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core';
3732
import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
3833
import { buildGradientDefs, resolveMarkFill } from './gradient-utils';
39-
import { applyTextStyle, createSVGElement, SVG_NS, setAttrs, XLINK_NS } from './renderers/svg-dom';
34+
import { renderBrand } from './renderers/brand';
35+
import {
36+
applyTextStyle,
37+
computeXAxisExtent,
38+
createSVGElement,
39+
SVG_NS,
40+
setAttrs,
41+
} from './renderers/svg-dom';
4042
import { nextSvgId } from './svg-ids';
4143

4244
/**
@@ -73,31 +75,6 @@ const EASE_VAR_MAP: Record<string, string> = {
7375
snappy: 'var(--oc-ease-snappy)',
7476
};
7577

76-
/**
77-
* Compute the vertical extent of x-axis labels below the chart area.
78-
* Accounts for rotated tick labels which need more vertical space.
79-
*/
80-
function computeXAxisExtent(layout: ChartLayout): number {
81-
const xAxis = layout.axes.x;
82-
if (!xAxis) return 0;
83-
84-
if (xAxis.tickAngle && Math.abs(xAxis.tickAngle) > 10) {
85-
// Rotated labels: estimate height from the longest tick label.
86-
const fontSize = xAxis.tickLabelStyle.fontSize;
87-
const fontWeight = xAxis.tickLabelStyle.fontWeight;
88-
const angleRad = Math.abs(xAxis.tickAngle) * (Math.PI / 180);
89-
let maxLabelWidth = 40;
90-
for (const tick of xAxis.ticks) {
91-
const w = estimateTextWidth(tick.label, fontSize, fontWeight);
92-
if (w > maxLabelWidth) maxLabelWidth = w;
93-
}
94-
const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
95-
return xAxis.label ? rotatedHeight + 20 : rotatedHeight;
96-
}
97-
98-
return xAxis.label ? 48 : 26;
99-
}
100-
10178
// ---------------------------------------------------------------------------
10279
// Chrome rendering
10380
// ---------------------------------------------------------------------------
@@ -1049,78 +1026,6 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
10491026
parent.appendChild(g);
10501027
}
10511028

1052-
// ---------------------------------------------------------------------------
1053-
// Brand rendering
1054-
// ---------------------------------------------------------------------------
1055-
1056-
const BRAND_URL = 'https://tryopendata.ai';
1057-
1058-
/**
1059-
* Render the "OpenData" brand as a footer-row element, right-aligned on the
1060-
* same baseline as the first bottom chrome text (source/byline/footer).
1061-
* Uses the same font size as chrome source text so it blends in as a subtle
1062-
* footer item rather than occupying independent visual space.
1063-
*/
1064-
function renderBrand(parent: SVGElement, layout: ChartLayout): void {
1065-
if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
1066-
1067-
const { width } = layout.dimensions;
1068-
const padding = layout.theme.spacing.padding;
1069-
const rightEdge = width - padding;
1070-
const fill = layout.theme.colors.axis;
1071-
1072-
// Vertically align with the first bottom chrome element.
1073-
const { chrome } = layout;
1074-
const xAxisExtent = computeXAxisExtent(layout);
1075-
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
1076-
const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
1077-
const chromeY = firstBottom
1078-
? bottomOffset + firstBottom.y
1079-
: bottomOffset + layout.theme.spacing.chartToFooter;
1080-
1081-
const a = createSVGElement('a');
1082-
a.setAttribute('href', BRAND_URL);
1083-
a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
1084-
a.setAttribute('target', '_blank');
1085-
a.setAttribute('rel', 'noopener');
1086-
a.setAttribute('class', 'oc-chrome-ref');
1087-
1088-
// "try" in normal weight, "OpenData" in semibold, ".ai" in normal weight,
1089-
// rendered as a single right-aligned text element with three tspans.
1090-
// Use alphabetic baseline so mixed-size tspans share a common bottom line.
1091-
const BRAND_LARGE = 16;
1092-
const text = createSVGElement('text');
1093-
setAttrs(text, {
1094-
x: rightEdge,
1095-
y: chromeY + BRAND_LARGE,
1096-
'dominant-baseline': 'alphabetic',
1097-
'font-family': layout.theme.fonts.family,
1098-
'font-size': BRAND_FONT_SIZE,
1099-
'text-anchor': 'end',
1100-
'fill-opacity': 0.55,
1101-
});
1102-
(text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
1103-
1104-
const trySpan = createSVGElement('tspan');
1105-
trySpan.setAttribute('font-weight', '500');
1106-
trySpan.textContent = 'try';
1107-
text.appendChild(trySpan);
1108-
1109-
const openDataSpan = createSVGElement('tspan');
1110-
openDataSpan.setAttribute('font-weight', '600');
1111-
openDataSpan.setAttribute('font-size', String(BRAND_LARGE));
1112-
openDataSpan.textContent = 'OpenData';
1113-
text.appendChild(openDataSpan);
1114-
1115-
const aiSpan = createSVGElement('tspan');
1116-
aiSpan.setAttribute('font-weight', '500');
1117-
aiSpan.textContent = '.ai';
1118-
text.appendChild(aiSpan);
1119-
1120-
a.appendChild(text);
1121-
parent.appendChild(a);
1122-
}
1123-
11241029
// ---------------------------------------------------------------------------
11251030
// Main render function
11261031
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)