@opendata-ai/openchart-core 8.0.0-rc.8 → 8.0.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 +461 -56
- package/dist/index.js +62 -24
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +1 -2
- package/schema/chart.schema.json +373 -67
- package/schema/table.schema.json +35 -1
- package/schema/vizspec.schema.json +950 -152
- package/src/accessibility/__tests__/alt-text.test.ts +0 -110
- package/src/accessibility/__tests__/aria.test.ts +0 -125
- package/src/accessibility/alt-text.ts +0 -155
- package/src/accessibility/aria.ts +0 -75
- package/src/accessibility/index.ts +0 -6
- package/src/colors/__tests__/colorblind.test.ts +0 -75
- package/src/colors/__tests__/contrast.test.ts +0 -119
- package/src/colors/__tests__/palettes.test.ts +0 -74
- package/src/colors/colorblind.ts +0 -131
- package/src/colors/contrast.ts +0 -143
- package/src/colors/index.ts +0 -36
- package/src/colors/palettes.ts +0 -206
- package/src/helpers/__tests__/deep-merge.test.ts +0 -54
- package/src/helpers/__tests__/spec-builders.test.ts +0 -338
- package/src/helpers/deep-merge.ts +0 -29
- package/src/helpers/spec-builders.ts +0 -479
- package/src/index.ts +0 -215
- package/src/labels/__tests__/collision.test.ts +0 -310
- package/src/labels/collision.ts +0 -249
- package/src/labels/index.ts +0 -13
- package/src/layout/__tests__/chrome.test.ts +0 -208
- package/src/layout/__tests__/text-measure.test.ts +0 -57
- package/src/layout/chrome.ts +0 -470
- package/src/layout/index.ts +0 -18
- package/src/layout/text-measure.ts +0 -404
- package/src/layout/text-wrap.ts +0 -93
- package/src/locale/__tests__/format.test.ts +0 -606
- package/src/locale/format.ts +0 -459
- package/src/locale/index.ts +0 -21
- package/src/responsive/__tests__/breakpoints.test.ts +0 -146
- package/src/responsive/__tests__/metrics.test.ts +0 -52
- package/src/responsive/breakpoints.ts +0 -172
- package/src/responsive/index.ts +0 -53
- package/src/responsive/metrics.ts +0 -299
- package/src/schema/__tests__/generated-artifacts.test.ts +0 -42
- package/src/schema/__tests__/schema.test.ts +0 -179
- package/src/styles/__tests__/reduced-motion-selectors.test.ts +0 -28
- package/src/styles/animation.css +0 -208
- package/src/styles/base.css +0 -102
- package/src/styles/chrome.css +0 -174
- package/src/styles/dark.css +0 -44
- package/src/styles/graph.css +0 -135
- package/src/styles/index.css +0 -28
- package/src/styles/keyframes.css +0 -151
- package/src/styles/legend.css +0 -18
- package/src/styles/reduced-motion.css +0 -67
- package/src/styles/series-search.css +0 -153
- package/src/styles/sparkline.css +0 -16
- package/src/styles/table-animation.css +0 -93
- package/src/styles/table.css +0 -398
- package/src/styles/token-definitions.ts +0 -199
- package/src/styles/tokens.css +0 -156
- package/src/styles/tooltip.css +0 -126
- package/src/styles/you-draw-it.css +0 -126
- package/src/theme/__tests__/dark-mode.test.ts +0 -232
- package/src/theme/__tests__/defaults.test.ts +0 -73
- package/src/theme/__tests__/resolve.test.ts +0 -277
- package/src/theme/dark-mode.ts +0 -258
- package/src/theme/defaults.ts +0 -110
- package/src/theme/index.ts +0 -8
- package/src/theme/presets.ts +0 -64
- package/src/theme/resolve.ts +0 -312
- package/src/types/__tests__/encoding.test.ts +0 -368
- package/src/types/__tests__/gradient.test.ts +0 -95
- package/src/types/__tests__/spec.test.ts +0 -499
- package/src/types/encoding.ts +0 -280
- package/src/types/events.ts +0 -154
- package/src/types/index.ts +0 -289
- package/src/types/layout.ts +0 -2001
- package/src/types/schema-roots.ts +0 -32
- package/src/types/spec.ts +0 -2906
- package/src/types/table.ts +0 -108
- package/src/types/theme.ts +0 -209
package/src/layout/chrome.ts
DELETED
|
@@ -1,470 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chrome layout computation.
|
|
3
|
-
*
|
|
4
|
-
* Takes a Chrome spec + resolved theme and produces a ResolvedChrome
|
|
5
|
-
* with computed text positions, styles, and total chrome heights.
|
|
6
|
-
*
|
|
7
|
-
* Supports three chrome modes:
|
|
8
|
-
* - full: all chrome elements rendered at normal size
|
|
9
|
-
* - compact: title only, no subtitle/source/byline/footer
|
|
10
|
-
* - hidden: no chrome at all (maximizes chart area)
|
|
11
|
-
*
|
|
12
|
-
* Font sizes scale down continuously at narrow widths to keep
|
|
13
|
-
* chrome proportional to the container.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type { ChromeMode } from '../responsive/breakpoints';
|
|
17
|
-
import type {
|
|
18
|
-
MeasureTextFn,
|
|
19
|
-
ResolvedChrome,
|
|
20
|
-
ResolvedChromeElement,
|
|
21
|
-
TextStyle,
|
|
22
|
-
} from '../types/layout';
|
|
23
|
-
import type { Chrome, ChromeText } from '../types/spec';
|
|
24
|
-
import type { ChromeDefaults, ResolvedTheme } from '../types/theme';
|
|
25
|
-
import {
|
|
26
|
-
BRAND_FONT_SIZE,
|
|
27
|
-
BRAND_MIN_WIDTH,
|
|
28
|
-
BRAND_RESERVE_WIDTH,
|
|
29
|
-
COMPACT_WIDTH,
|
|
30
|
-
estimateCharWidth,
|
|
31
|
-
estimateTextHeight,
|
|
32
|
-
} from './text-measure';
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Helpers
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
/** Normalize a chrome field to text + optional style and offset overrides. */
|
|
39
|
-
function normalizeChromeText(
|
|
40
|
-
value: string | ChromeText | undefined,
|
|
41
|
-
): { text: string; style?: ChromeText['style']; offset?: ChromeText['offset'] } | null {
|
|
42
|
-
if (value === undefined) return null;
|
|
43
|
-
if (typeof value === 'string') return { text: value };
|
|
44
|
-
return { text: value.text, style: value.style, offset: value.offset };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Scale a font size based on container width. Only applies to default sizes
|
|
49
|
-
* (not user overrides). Scales from 100% at >= 500px down to 72% at <= 250px.
|
|
50
|
-
*/
|
|
51
|
-
function scaleFontSize(baseFontSize: number, width: number): number {
|
|
52
|
-
if (width >= 500) return baseFontSize;
|
|
53
|
-
if (width <= 250) return Math.max(Math.round(baseFontSize * 0.72), 10);
|
|
54
|
-
const t = (width - 250) / 250;
|
|
55
|
-
return Math.max(Math.round(baseFontSize * (0.72 + t * 0.28)), 10);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Build a TextStyle from chrome defaults + optional overrides, with width-based scaling. */
|
|
59
|
-
function buildTextStyle(
|
|
60
|
-
defaults: ChromeDefaults,
|
|
61
|
-
fontFamily: string,
|
|
62
|
-
textColor: string,
|
|
63
|
-
width: number,
|
|
64
|
-
overrides?: ChromeText['style'],
|
|
65
|
-
): TextStyle {
|
|
66
|
-
const hasExplicitSize = overrides?.fontSize !== undefined;
|
|
67
|
-
const baseFontSize = overrides?.fontSize ?? defaults.fontSize;
|
|
68
|
-
const fontSize = hasExplicitSize ? baseFontSize : scaleFontSize(baseFontSize, width);
|
|
69
|
-
|
|
70
|
-
// No dominantBaseline: chrome y positions are top-edge coordinates and
|
|
71
|
-
// renderers convert to the alphabetic baseline via textAscent(). WebKit
|
|
72
|
-
// renders `hanging` from different font metrics than Blink and never
|
|
73
|
-
// inherits it into tspans (bugs 139258/297455), which shifted wrapped
|
|
74
|
-
// titles above the container on iOS Safari.
|
|
75
|
-
return {
|
|
76
|
-
fontFamily: overrides?.fontFamily ?? fontFamily,
|
|
77
|
-
fontSize,
|
|
78
|
-
fontWeight: overrides?.fontWeight ?? defaults.fontWeight,
|
|
79
|
-
fill: overrides?.color ?? textColor ?? defaults.color,
|
|
80
|
-
lineHeight: defaults.lineHeight,
|
|
81
|
-
textAnchor: 'start',
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Estimate how many lines text will wrap to, given a max width.
|
|
87
|
-
* Uses character-count word-wrapping that matches the SVG renderer's
|
|
88
|
-
* wrapText behavior (word-boundary breaks, same charWidth heuristic).
|
|
89
|
-
* Returns at least 1.
|
|
90
|
-
*/
|
|
91
|
-
function estimateLineCount(
|
|
92
|
-
text: string,
|
|
93
|
-
style: TextStyle,
|
|
94
|
-
maxWidth: number,
|
|
95
|
-
measureText?: MeasureTextFn,
|
|
96
|
-
): number {
|
|
97
|
-
if (maxWidth <= 0) return 1;
|
|
98
|
-
|
|
99
|
-
// Split on explicit newlines first, then estimate wrapping per segment
|
|
100
|
-
const segments = text.split('\n');
|
|
101
|
-
if (segments.length > 1) {
|
|
102
|
-
return segments.reduce((total, segment) => {
|
|
103
|
-
return (
|
|
104
|
-
total +
|
|
105
|
-
(segment.length === 0 ? 1 : estimateLineCount(segment, style, maxWidth, measureText))
|
|
106
|
-
);
|
|
107
|
-
}, 0);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Use real text measurement when available, fall back to heuristic
|
|
111
|
-
if (measureText) {
|
|
112
|
-
const textWidth = measureText(text, style.fontSize, style.fontWeight).width;
|
|
113
|
-
if (textWidth <= maxWidth) return 1;
|
|
114
|
-
|
|
115
|
-
const words = text.split(' ');
|
|
116
|
-
let lines = 1;
|
|
117
|
-
let current = '';
|
|
118
|
-
|
|
119
|
-
for (const word of words) {
|
|
120
|
-
const candidate = current ? `${current} ${word}` : word;
|
|
121
|
-
const candidateWidth = measureText(candidate, style.fontSize, style.fontWeight).width;
|
|
122
|
-
if (candidateWidth > maxWidth && current) {
|
|
123
|
-
lines++;
|
|
124
|
-
current = word;
|
|
125
|
-
} else {
|
|
126
|
-
current = candidate;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return lines;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const charWidth = estimateCharWidth(style.fontSize, style.fontWeight);
|
|
134
|
-
const maxChars = Math.floor(maxWidth / charWidth);
|
|
135
|
-
|
|
136
|
-
if (text.length <= maxChars) return 1;
|
|
137
|
-
|
|
138
|
-
const words = text.split(' ');
|
|
139
|
-
let lines = 1;
|
|
140
|
-
let current = '';
|
|
141
|
-
|
|
142
|
-
for (const word of words) {
|
|
143
|
-
const candidate = current ? `${current} ${word}` : word;
|
|
144
|
-
if (candidate.length > maxChars && current) {
|
|
145
|
-
lines++;
|
|
146
|
-
current = word;
|
|
147
|
-
} else {
|
|
148
|
-
current = candidate;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return lines;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
// Public API
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Line-height multiplier for the auto-thinning footnote list.
|
|
161
|
-
*
|
|
162
|
-
* Exported because the chrome renderer steps each footnote line down by it
|
|
163
|
-
* while the band height below is computed from it -- the two have to agree, or
|
|
164
|
-
* the last line drifts outside the band that was reserved for it.
|
|
165
|
-
*/
|
|
166
|
-
export const FOOTNOTE_LINE_HEIGHT = 1.3;
|
|
167
|
-
/** Gap below the footnote list, separating it from the source row. */
|
|
168
|
-
const FOOTNOTE_BAND_GAP = 4;
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Vertical space a footnote list of `count` lines occupies below the plot.
|
|
172
|
-
*
|
|
173
|
-
* Single source of truth, deliberately: the engine reserves this band in the
|
|
174
|
-
* bottom margin, the chrome renderer shifts the source row down by it, and the
|
|
175
|
-
* brand watermark shifts by it too. When those three disagree the footnotes
|
|
176
|
-
* land on top of the watermark, which is exactly the bug this centralizes away.
|
|
177
|
-
*/
|
|
178
|
-
export function footnoteBandHeight(count: number, theme: ResolvedTheme): number {
|
|
179
|
-
if (count <= 0) return 0;
|
|
180
|
-
return count * theme.fonts.sizes.small * FOOTNOTE_LINE_HEIGHT + FOOTNOTE_BAND_GAP;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Compute resolved chrome layout from a Chrome spec and resolved theme.
|
|
185
|
-
*
|
|
186
|
-
* Produces positioned text elements and total chrome heights (top and bottom).
|
|
187
|
-
* Top chrome: title, subtitle. Bottom chrome: source, byline, footer.
|
|
188
|
-
*
|
|
189
|
-
* @param chrome - The Chrome spec from the user's VizSpec.
|
|
190
|
-
* @param theme - The fully resolved theme.
|
|
191
|
-
* @param width - Total available width in pixels.
|
|
192
|
-
* @param measureText - Optional real text measurement function from the adapter.
|
|
193
|
-
* @param chromeMode - Chrome display mode: full, compact (title only), or hidden.
|
|
194
|
-
* @param padding - Override padding (for scaled padding from dimensions).
|
|
195
|
-
* @param watermark - Whether the brand watermark renders (affects bottom space).
|
|
196
|
-
* @param bottomLegendHeight - Reserved height for a bottom-positioned legend
|
|
197
|
-
* (legend bounds height + gap). When > 0, source/byline/footer y positions
|
|
198
|
-
* are shifted down by this amount so the chrome stacks BELOW the legend
|
|
199
|
-
* rather than colliding with it. The returned `bottomHeight` includes this
|
|
200
|
-
* reservation, so callers should not double-reserve it in margin math.
|
|
201
|
-
*/
|
|
202
|
-
export function computeChrome(
|
|
203
|
-
chrome: Chrome | undefined,
|
|
204
|
-
theme: ResolvedTheme,
|
|
205
|
-
width: number,
|
|
206
|
-
measureText?: MeasureTextFn,
|
|
207
|
-
chromeMode: ChromeMode = 'full',
|
|
208
|
-
padding?: number,
|
|
209
|
-
watermark: boolean = true,
|
|
210
|
-
bottomLegendHeight: number = 0,
|
|
211
|
-
): ResolvedChrome {
|
|
212
|
-
if (!chrome || chromeMode === 'hidden') {
|
|
213
|
-
// Brand watermark is also skipped at cramped sizes (height < 200px triggers
|
|
214
|
-
// hidden mode, and those containers are typically < BRAND_MIN_WIDTH), so
|
|
215
|
-
// bottomHeight: 0 is safe here.
|
|
216
|
-
return { topHeight: 0, bottomHeight: 0 };
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const pad = padding ?? theme.spacing.padding;
|
|
220
|
-
const chromeGap = theme.spacing.chromeGap;
|
|
221
|
-
// Subtract a small buffer beyond padding so the heuristic char-width
|
|
222
|
-
// estimate triggers word-wrap before the real font overflows the container.
|
|
223
|
-
const WRAP_SAFETY_BUFFER = 5;
|
|
224
|
-
const maxWidth = width - pad * 2 - WRAP_SAFETY_BUFFER;
|
|
225
|
-
const fontFamily = theme.fonts.family;
|
|
226
|
-
|
|
227
|
-
// Track vertical cursor for top elements
|
|
228
|
-
let topY = pad;
|
|
229
|
-
const topElements: Partial<Pick<ResolvedChrome, 'eyebrow' | 'title' | 'subtitle'>> = {};
|
|
230
|
-
|
|
231
|
-
// Eyebrow (hidden in compact mode — same rule as subtitle, keeps the
|
|
232
|
-
// title alone at narrow viewports).
|
|
233
|
-
const eyebrowNorm = chromeMode === 'compact' ? null : normalizeChromeText(chrome.eyebrow);
|
|
234
|
-
if (eyebrowNorm) {
|
|
235
|
-
const style = buildTextStyle(
|
|
236
|
-
theme.chrome.eyebrow,
|
|
237
|
-
fontFamily,
|
|
238
|
-
theme.chrome.eyebrow.color,
|
|
239
|
-
width,
|
|
240
|
-
eyebrowNorm.style,
|
|
241
|
-
);
|
|
242
|
-
const lineCount = estimateLineCount(eyebrowNorm.text, style, maxWidth, measureText);
|
|
243
|
-
const element: ResolvedChromeElement = {
|
|
244
|
-
text: eyebrowNorm.text,
|
|
245
|
-
x: pad + (eyebrowNorm.offset?.dx ?? 0),
|
|
246
|
-
y: topY + (eyebrowNorm.offset?.dy ?? 0),
|
|
247
|
-
maxWidth,
|
|
248
|
-
style,
|
|
249
|
-
};
|
|
250
|
-
topElements.eyebrow = element;
|
|
251
|
-
topY += estimateTextHeight(style.fontSize, lineCount, style.lineHeight) + chromeGap;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Title
|
|
255
|
-
const titleNorm = normalizeChromeText(chrome.title);
|
|
256
|
-
if (titleNorm) {
|
|
257
|
-
const style = buildTextStyle(
|
|
258
|
-
theme.chrome.title,
|
|
259
|
-
fontFamily,
|
|
260
|
-
theme.chrome.title.color,
|
|
261
|
-
width,
|
|
262
|
-
titleNorm.style,
|
|
263
|
-
);
|
|
264
|
-
const lineCount = estimateLineCount(titleNorm.text, style, maxWidth, measureText);
|
|
265
|
-
const element: ResolvedChromeElement = {
|
|
266
|
-
text: titleNorm.text,
|
|
267
|
-
x: pad + (titleNorm.offset?.dx ?? 0),
|
|
268
|
-
y: topY + (titleNorm.offset?.dy ?? 0),
|
|
269
|
-
maxWidth,
|
|
270
|
-
style,
|
|
271
|
-
};
|
|
272
|
-
topElements.title = element;
|
|
273
|
-
topY += estimateTextHeight(style.fontSize, lineCount, style.lineHeight) + chromeGap;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Subtitle (hidden in compact mode)
|
|
277
|
-
const subtitleNorm = chromeMode === 'compact' ? null : normalizeChromeText(chrome.subtitle);
|
|
278
|
-
if (subtitleNorm) {
|
|
279
|
-
const style = buildTextStyle(
|
|
280
|
-
theme.chrome.subtitle,
|
|
281
|
-
fontFamily,
|
|
282
|
-
theme.chrome.subtitle.color,
|
|
283
|
-
width,
|
|
284
|
-
subtitleNorm.style,
|
|
285
|
-
);
|
|
286
|
-
const lineCount = estimateLineCount(subtitleNorm.text, style, maxWidth, measureText);
|
|
287
|
-
const element: ResolvedChromeElement = {
|
|
288
|
-
text: subtitleNorm.text,
|
|
289
|
-
x: pad + (subtitleNorm.offset?.dx ?? 0),
|
|
290
|
-
y: topY + (subtitleNorm.offset?.dy ?? 0),
|
|
291
|
-
maxWidth,
|
|
292
|
-
style,
|
|
293
|
-
};
|
|
294
|
-
topElements.subtitle = element;
|
|
295
|
-
topY += estimateTextHeight(style.fontSize, lineCount, style.lineHeight) + chromeGap;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Add chromeToChart gap if there are any top elements. Tighten on narrow
|
|
299
|
-
// viewports so the subtitle doesn't float far above a legend or chart area.
|
|
300
|
-
const hasTopChrome = eyebrowNorm || titleNorm || subtitleNorm;
|
|
301
|
-
const chromeToChart =
|
|
302
|
-
width < COMPACT_WIDTH ? Math.min(theme.spacing.chromeToChart, 2) : theme.spacing.chromeToChart;
|
|
303
|
-
const topHeight = hasTopChrome ? topY - pad + chromeToChart - chromeGap : 0;
|
|
304
|
-
|
|
305
|
-
// Bottom chrome text hidden in compact mode, but brand watermark still
|
|
306
|
-
// renders for wide-enough charts. Reserve space so it doesn't overflow.
|
|
307
|
-
if (chromeMode === 'compact') {
|
|
308
|
-
let compactBottom = 0;
|
|
309
|
-
if (watermark && width >= BRAND_MIN_WIDTH) {
|
|
310
|
-
const brandHeight = estimateTextHeight(BRAND_FONT_SIZE, 1);
|
|
311
|
-
compactBottom = theme.spacing.chartToFooter + brandHeight + pad + bottomLegendHeight;
|
|
312
|
-
} else if (bottomLegendHeight > 0) {
|
|
313
|
-
// No bottom chrome content but a bottom legend was reserved upstream.
|
|
314
|
-
// Surface the reservation through bottomHeight so margin math stays
|
|
315
|
-
// additive and consistent with full mode.
|
|
316
|
-
compactBottom = bottomLegendHeight;
|
|
317
|
-
}
|
|
318
|
-
return {
|
|
319
|
-
topHeight,
|
|
320
|
-
bottomHeight: compactBottom,
|
|
321
|
-
...topElements,
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Bottom elements: source, byline, footer, and an optional custom brand
|
|
326
|
-
// (`chrome.brand`). When a custom brand is supplied it suppresses the default
|
|
327
|
-
// tryOpenData.ai watermark and renders right-anchored on the same baseline.
|
|
328
|
-
const brandNorm = normalizeChromeText(chrome.brand);
|
|
329
|
-
const showWatermark = watermark && !brandNorm;
|
|
330
|
-
// Reserve space on the right for the brand watermark or custom brand so the
|
|
331
|
-
// left-anchored bottom chrome items don't overlap.
|
|
332
|
-
const shouldReserveBrandWidth = (showWatermark || !!brandNorm) && width >= BRAND_MIN_WIDTH;
|
|
333
|
-
const bottomMaxWidth = maxWidth - (shouldReserveBrandWidth ? BRAND_RESERVE_WIDTH : 0);
|
|
334
|
-
const bottomElements: Partial<Pick<ResolvedChrome, 'source' | 'byline' | 'footer'>> = {};
|
|
335
|
-
let bottomHeight = 0;
|
|
336
|
-
|
|
337
|
-
const bottomItems: Array<{
|
|
338
|
-
key: 'source' | 'byline' | 'footer';
|
|
339
|
-
norm: { text: string; style?: ChromeText['style']; offset?: ChromeText['offset'] };
|
|
340
|
-
defaults: ChromeDefaults;
|
|
341
|
-
}> = [];
|
|
342
|
-
|
|
343
|
-
const sourceNorm = normalizeChromeText(chrome.source);
|
|
344
|
-
if (sourceNorm) {
|
|
345
|
-
bottomItems.push({
|
|
346
|
-
key: 'source',
|
|
347
|
-
norm: sourceNorm,
|
|
348
|
-
defaults: theme.chrome.source,
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const bylineNorm = normalizeChromeText(chrome.byline);
|
|
353
|
-
if (bylineNorm) {
|
|
354
|
-
bottomItems.push({
|
|
355
|
-
key: 'byline',
|
|
356
|
-
norm: bylineNorm,
|
|
357
|
-
defaults: theme.chrome.byline,
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const footerNorm = normalizeChromeText(chrome.footer);
|
|
362
|
-
if (footerNorm) {
|
|
363
|
-
bottomItems.push({
|
|
364
|
-
key: 'footer',
|
|
365
|
-
norm: footerNorm,
|
|
366
|
-
defaults: theme.chrome.footer,
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
if (bottomItems.length > 0) {
|
|
371
|
-
bottomHeight += theme.spacing.chartToFooter;
|
|
372
|
-
// Push bottom chrome below the bottom legend (if reserved). Stored y values
|
|
373
|
-
// include this offset so renderers don't need to know about the legend band.
|
|
374
|
-
bottomHeight += bottomLegendHeight;
|
|
375
|
-
|
|
376
|
-
for (const item of bottomItems) {
|
|
377
|
-
const style = buildTextStyle(
|
|
378
|
-
item.defaults,
|
|
379
|
-
fontFamily,
|
|
380
|
-
item.defaults.color,
|
|
381
|
-
width,
|
|
382
|
-
item.norm.style,
|
|
383
|
-
);
|
|
384
|
-
const lineCount = estimateLineCount(item.norm.text, style, bottomMaxWidth, measureText);
|
|
385
|
-
const height = estimateTextHeight(style.fontSize, lineCount, style.lineHeight);
|
|
386
|
-
|
|
387
|
-
// y positions will be computed relative to the bottom of the
|
|
388
|
-
// chart area by the engine. We store offsets from bottom start.
|
|
389
|
-
bottomElements[item.key] = {
|
|
390
|
-
text: item.norm.text,
|
|
391
|
-
x: pad + (item.norm.offset?.dx ?? 0),
|
|
392
|
-
y: bottomHeight + (item.norm.offset?.dy ?? 0), // offset from where bottom chrome starts
|
|
393
|
-
maxWidth: bottomMaxWidth,
|
|
394
|
-
style,
|
|
395
|
-
};
|
|
396
|
-
|
|
397
|
-
bottomHeight += height + chromeGap;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Remove trailing gap
|
|
401
|
-
bottomHeight -= chromeGap;
|
|
402
|
-
|
|
403
|
-
// Ensure bottom height accommodates the brand watermark, which renders
|
|
404
|
-
// at the same Y as the first bottom chrome item but is taller (20px font
|
|
405
|
-
// vs 12px source). Without this, the brand overflows the SVG viewBox.
|
|
406
|
-
if (showWatermark && width >= BRAND_MIN_WIDTH) {
|
|
407
|
-
const brandHeight = estimateTextHeight(BRAND_FONT_SIZE, 1);
|
|
408
|
-
// firstItemY is chartToFooter (the Y offset of the first bottom item).
|
|
409
|
-
// The brand needs brandHeight below that point; bottom chrome content
|
|
410
|
-
// needs (bottomHeight - chartToFooter). Take the max.
|
|
411
|
-
const contentBelowFirstItem = bottomHeight - theme.spacing.chartToFooter;
|
|
412
|
-
if (brandHeight > contentBelowFirstItem) {
|
|
413
|
-
bottomHeight += brandHeight - contentBelowFirstItem;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Add bottom padding
|
|
418
|
-
bottomHeight += pad;
|
|
419
|
-
} else if (showWatermark && width >= BRAND_MIN_WIDTH) {
|
|
420
|
-
// No bottom chrome items, but brand watermark still renders.
|
|
421
|
-
// Reserve space: chartToFooter gap + brand text height + padding,
|
|
422
|
-
// plus the bottom-legend reservation so the watermark sits below it.
|
|
423
|
-
const brandHeight = estimateTextHeight(BRAND_FONT_SIZE, 1);
|
|
424
|
-
bottomHeight = theme.spacing.chartToFooter + brandHeight + pad + bottomLegendHeight;
|
|
425
|
-
} else if (bottomLegendHeight > 0) {
|
|
426
|
-
// No bottom chrome content and no watermark, but a bottom legend was
|
|
427
|
-
// reserved upstream. Surface the reservation so callers don't need to
|
|
428
|
-
// re-add it on top of bottomHeight.
|
|
429
|
-
bottomHeight = bottomLegendHeight;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Custom brand is right-anchored on the same row as the first bottom chrome
|
|
433
|
-
// item. Compute its style/position once we know whether bottom chrome exists.
|
|
434
|
-
let brandElement: ResolvedChromeElement | undefined;
|
|
435
|
-
if (brandNorm && width >= BRAND_MIN_WIDTH) {
|
|
436
|
-
const brandStyle = buildTextStyle(
|
|
437
|
-
theme.chrome.footer,
|
|
438
|
-
fontFamily,
|
|
439
|
-
theme.chrome.footer.color,
|
|
440
|
-
width,
|
|
441
|
-
brandNorm.style,
|
|
442
|
-
);
|
|
443
|
-
brandStyle.textAnchor = 'end';
|
|
444
|
-
// Y matches the first bottom item (chartToFooter offset). When there are
|
|
445
|
-
// no bottom items, the brand becomes the sole footer element and we need
|
|
446
|
-
// to allocate the same baseline.
|
|
447
|
-
const brandY =
|
|
448
|
-
bottomItems.length > 0 ? theme.spacing.chartToFooter : theme.spacing.chartToFooter;
|
|
449
|
-
brandElement = {
|
|
450
|
-
text: brandNorm.text,
|
|
451
|
-
x: width - pad + (brandNorm.offset?.dx ?? 0),
|
|
452
|
-
y: brandY + (brandNorm.offset?.dy ?? 0),
|
|
453
|
-
maxWidth: BRAND_RESERVE_WIDTH,
|
|
454
|
-
style: brandStyle,
|
|
455
|
-
};
|
|
456
|
-
// Ensure bottomHeight reserves space when brand is the only footer item.
|
|
457
|
-
if (bottomItems.length === 0) {
|
|
458
|
-
const brandHeight = estimateTextHeight(brandStyle.fontSize, 1, brandStyle.lineHeight);
|
|
459
|
-
bottomHeight = theme.spacing.chartToFooter + brandHeight + pad;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
return {
|
|
464
|
-
topHeight,
|
|
465
|
-
bottomHeight,
|
|
466
|
-
...topElements,
|
|
467
|
-
...bottomElements,
|
|
468
|
-
...(brandElement ? { brand: brandElement } : {}),
|
|
469
|
-
};
|
|
470
|
-
}
|
package/src/layout/index.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Layout module barrel export.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
export { computeChrome, FOOTNOTE_LINE_HEIGHT, footnoteBandHeight } from './chrome';
|
|
6
|
-
export {
|
|
7
|
-
BRAND_FONT_SIZE,
|
|
8
|
-
BRAND_MIN_WIDTH,
|
|
9
|
-
BRAND_RESERVE_WIDTH,
|
|
10
|
-
COMPACT_WIDTH,
|
|
11
|
-
estimateCharWidth,
|
|
12
|
-
estimateTextHeight,
|
|
13
|
-
estimateTextWidth,
|
|
14
|
-
heuristicMeasure,
|
|
15
|
-
resolveMeasurer,
|
|
16
|
-
textAscent,
|
|
17
|
-
} from './text-measure';
|
|
18
|
-
export { wrapText } from './text-wrap';
|