@opendata-ai/openchart-core 2.10.0 → 2.12.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 +17 -9
- package/dist/index.js +42 -13
- package/dist/index.js.map +1 -1
- package/dist/styles.css +2 -0
- package/package.json +1 -1
- package/src/index.ts +2 -1
- package/src/labels/collision.ts +26 -0
- package/src/labels/index.ts +1 -1
- package/src/layout/__tests__/chrome.test.ts +17 -0
- package/src/layout/chrome.ts +30 -15
- package/src/layout/index.ts +6 -1
- package/src/layout/text-measure.ts +18 -2
- package/src/styles/viz.css +2 -0
- package/src/theme/__tests__/resolve.test.ts +57 -0
package/dist/styles.css
CHANGED
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -54,6 +54,7 @@ export {
|
|
|
54
54
|
// ---------------------------------------------------------------------------
|
|
55
55
|
|
|
56
56
|
export {
|
|
57
|
+
BRAND_RESERVE_WIDTH,
|
|
57
58
|
computeChrome,
|
|
58
59
|
estimateTextWidth,
|
|
59
60
|
} from './layout/index';
|
|
@@ -86,7 +87,7 @@ export type {
|
|
|
86
87
|
LabelCandidate,
|
|
87
88
|
LabelPriority,
|
|
88
89
|
} from './labels/index';
|
|
89
|
-
export { resolveCollisions } from './labels/index';
|
|
90
|
+
export { computeLabelBounds, detectCollision, resolveCollisions } from './labels/index';
|
|
90
91
|
|
|
91
92
|
// ---------------------------------------------------------------------------
|
|
92
93
|
// Locale: number and date formatting
|
package/src/labels/collision.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Targeting ~60% of Infrographic quality for Phase 0.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { estimateTextWidth } from '../layout/text-measure';
|
|
9
10
|
import type { Rect, ResolvedLabel, TextStyle } from '../types/layout';
|
|
10
11
|
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
@@ -159,3 +160,28 @@ export function resolveCollisions(labels: LabelCandidate[]): ResolvedLabel[] {
|
|
|
159
160
|
|
|
160
161
|
return results;
|
|
161
162
|
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Label bounds estimation
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Compute the bounding rect of a resolved label from its position and text.
|
|
170
|
+
* Uses heuristic text measurement so it works without DOM access.
|
|
171
|
+
*/
|
|
172
|
+
export function computeLabelBounds(label: ResolvedLabel): Rect {
|
|
173
|
+
const fontSize = label.style.fontSize;
|
|
174
|
+
const fontWeight = label.style.fontWeight;
|
|
175
|
+
const width = estimateTextWidth(label.text, fontSize, fontWeight);
|
|
176
|
+
const height = fontSize * (label.style.lineHeight ?? 1.2);
|
|
177
|
+
|
|
178
|
+
// Adjust x based on text anchor
|
|
179
|
+
let x = label.x;
|
|
180
|
+
if (label.style.textAnchor === 'middle') {
|
|
181
|
+
x = label.x - width / 2;
|
|
182
|
+
} else if (label.style.textAnchor === 'end') {
|
|
183
|
+
x = label.x - width;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { x, y: label.y, width, height };
|
|
187
|
+
}
|
package/src/labels/index.ts
CHANGED
|
@@ -100,6 +100,23 @@ describe('computeChrome', () => {
|
|
|
100
100
|
expect(result.bottomHeight).toBeGreaterThan(0);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
it('reserves extra height when title wraps to multiple lines at narrow widths', () => {
|
|
104
|
+
const longTitle =
|
|
105
|
+
'Global Economic Recovery Trends Show Surprising Resilience Across Major Markets';
|
|
106
|
+
const chrome: Chrome = { title: longTitle, subtitle: 'Subtitle text' };
|
|
107
|
+
|
|
108
|
+
// At wide width, title fits on one line
|
|
109
|
+
const wide = computeChrome(chrome, theme, 800);
|
|
110
|
+
// At narrow width, title wraps to multiple lines
|
|
111
|
+
const narrow = computeChrome(chrome, theme, 300);
|
|
112
|
+
|
|
113
|
+
// Narrow should reserve more top height due to title wrapping
|
|
114
|
+
expect(narrow.topHeight).toBeGreaterThan(wide.topHeight);
|
|
115
|
+
|
|
116
|
+
// Subtitle should be pushed further down to avoid collision
|
|
117
|
+
expect(narrow.subtitle!.y).toBeGreaterThan(wide.subtitle!.y);
|
|
118
|
+
});
|
|
119
|
+
|
|
103
120
|
it('uses measureText function when provided', () => {
|
|
104
121
|
const measureText = (text: string, fontSize: number) => ({
|
|
105
122
|
width: text.length * fontSize * 0.6,
|
package/src/layout/chrome.ts
CHANGED
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
} from '../types/layout';
|
|
23
23
|
import type { Chrome, ChromeText } from '../types/spec';
|
|
24
24
|
import type { ChromeDefaults, ResolvedTheme } from '../types/theme';
|
|
25
|
-
import {
|
|
25
|
+
import { BRAND_RESERVE_WIDTH, estimateCharWidth, estimateTextHeight } from './text-measure';
|
|
26
26
|
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
28
28
|
// Helpers
|
|
@@ -71,27 +71,40 @@ function buildTextStyle(
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/** Measure text width using the provided function or heuristic fallback. */
|
|
75
|
-
function measureWidth(text: string, style: TextStyle, measureText?: MeasureTextFn): number {
|
|
76
|
-
if (measureText) {
|
|
77
|
-
return measureText(text, style.fontSize, style.fontWeight).width;
|
|
78
|
-
}
|
|
79
|
-
return estimateTextWidth(text, style.fontSize, style.fontWeight);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
74
|
/**
|
|
83
75
|
* Estimate how many lines text will wrap to, given a max width.
|
|
76
|
+
* Uses character-count word-wrapping that matches the SVG renderer's
|
|
77
|
+
* wrapText behavior (word-boundary breaks, same charWidth heuristic).
|
|
84
78
|
* Returns at least 1.
|
|
85
79
|
*/
|
|
86
80
|
function estimateLineCount(
|
|
87
81
|
text: string,
|
|
88
82
|
style: TextStyle,
|
|
89
83
|
maxWidth: number,
|
|
90
|
-
|
|
84
|
+
_measureText?: MeasureTextFn,
|
|
91
85
|
): number {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
86
|
+
if (maxWidth <= 0) return 1;
|
|
87
|
+
|
|
88
|
+
const charWidth = estimateCharWidth(style.fontSize, style.fontWeight);
|
|
89
|
+
const maxChars = Math.floor(maxWidth / charWidth);
|
|
90
|
+
|
|
91
|
+
if (text.length <= maxChars) return 1;
|
|
92
|
+
|
|
93
|
+
const words = text.split(' ');
|
|
94
|
+
let lines = 1;
|
|
95
|
+
let current = '';
|
|
96
|
+
|
|
97
|
+
for (const word of words) {
|
|
98
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
99
|
+
if (candidate.length > maxChars && current) {
|
|
100
|
+
lines++;
|
|
101
|
+
current = word;
|
|
102
|
+
} else {
|
|
103
|
+
current = candidate;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return lines;
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
// ---------------------------------------------------------------------------
|
|
@@ -190,6 +203,8 @@ export function computeChrome(
|
|
|
190
203
|
}
|
|
191
204
|
|
|
192
205
|
// Bottom elements: source, byline, footer
|
|
206
|
+
// Reserve space on the right for the brand watermark so text doesn't overlap it
|
|
207
|
+
const bottomMaxWidth = maxWidth - BRAND_RESERVE_WIDTH;
|
|
193
208
|
const bottomElements: Partial<Pick<ResolvedChrome, 'source' | 'byline' | 'footer'>> = {};
|
|
194
209
|
let bottomHeight = 0;
|
|
195
210
|
|
|
@@ -237,7 +252,7 @@ export function computeChrome(
|
|
|
237
252
|
width,
|
|
238
253
|
item.norm.style,
|
|
239
254
|
);
|
|
240
|
-
const lineCount = estimateLineCount(item.norm.text, style,
|
|
255
|
+
const lineCount = estimateLineCount(item.norm.text, style, bottomMaxWidth, measureText);
|
|
241
256
|
const height = estimateTextHeight(style.fontSize, lineCount, style.lineHeight);
|
|
242
257
|
|
|
243
258
|
// y positions will be computed relative to the bottom of the
|
|
@@ -246,7 +261,7 @@ export function computeChrome(
|
|
|
246
261
|
text: item.norm.text,
|
|
247
262
|
x: pad + (item.norm.offset?.dx ?? 0),
|
|
248
263
|
y: bottomHeight + (item.norm.offset?.dy ?? 0), // offset from where bottom chrome starts
|
|
249
|
-
maxWidth,
|
|
264
|
+
maxWidth: bottomMaxWidth,
|
|
250
265
|
style,
|
|
251
266
|
};
|
|
252
267
|
|
package/src/layout/index.ts
CHANGED
|
@@ -27,6 +27,15 @@ const WEIGHT_ADJUSTMENT: Record<number, number> = {
|
|
|
27
27
|
900: 1.12,
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Estimate the average character width for a given font size and weight.
|
|
32
|
+
* Used by both text width estimation and word-wrap line counting.
|
|
33
|
+
*/
|
|
34
|
+
export function estimateCharWidth(fontSize: number, fontWeight = 400): number {
|
|
35
|
+
const weightFactor = WEIGHT_ADJUSTMENT[fontWeight] ?? 1.0;
|
|
36
|
+
return fontSize * AVG_CHAR_WIDTH_RATIO * weightFactor;
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
/**
|
|
31
40
|
* Estimate the rendered width of a text string.
|
|
32
41
|
*
|
|
@@ -38,10 +47,17 @@ const WEIGHT_ADJUSTMENT: Record<number, number> = {
|
|
|
38
47
|
* @param fontWeight - Font weight (100-900). Defaults to 400.
|
|
39
48
|
*/
|
|
40
49
|
export function estimateTextWidth(text: string, fontSize: number, fontWeight = 400): number {
|
|
41
|
-
|
|
42
|
-
return text.length * fontSize * AVG_CHAR_WIDTH_RATIO * weightFactor;
|
|
50
|
+
return text.length * estimateCharWidth(fontSize, fontWeight);
|
|
43
51
|
}
|
|
44
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Width reserved for the "OpenData" brand watermark in the bottom-right corner.
|
|
55
|
+
* Accounts for ~8 chars at font size 20 with mixed 500/600 weight, plus a gap
|
|
56
|
+
* so adjacent text doesn't crowd it. Used by chrome and legend layout to avoid
|
|
57
|
+
* overlapping the brand.
|
|
58
|
+
*/
|
|
59
|
+
export const BRAND_RESERVE_WIDTH = 110;
|
|
60
|
+
|
|
45
61
|
/**
|
|
46
62
|
* Estimate the rendered height of a text block.
|
|
47
63
|
*
|
package/src/styles/viz.css
CHANGED
|
@@ -74,3 +74,60 @@ describe('resolveTheme', () => {
|
|
|
74
74
|
expect(resolved.spacing.padding).toBe(DEFAULT_THEME.spacing.padding);
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Deep merge behavior hardening
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
describe('resolveTheme deep merge edge cases', () => {
|
|
83
|
+
it('replaces categorical array entirely, does not concatenate', () => {
|
|
84
|
+
const custom = ['#aaa', '#bbb'];
|
|
85
|
+
const resolved = resolveTheme({ colors: { categorical: custom } });
|
|
86
|
+
expect(resolved.colors.categorical).toEqual(custom);
|
|
87
|
+
expect(resolved.colors.categorical).toHaveLength(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('skips undefined values, preserving defaults', () => {
|
|
91
|
+
const resolved = resolveTheme({ borderRadius: undefined });
|
|
92
|
+
expect(resolved.borderRadius).toBe(DEFAULT_THEME.borderRadius);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('applies multiple overrides at different depths in one call', () => {
|
|
96
|
+
const resolved = resolveTheme({
|
|
97
|
+
colors: { background: '#222222', text: '#eeeeee' },
|
|
98
|
+
fonts: { family: 'Georgia' },
|
|
99
|
+
spacing: { padding: 32 },
|
|
100
|
+
borderRadius: 4,
|
|
101
|
+
});
|
|
102
|
+
expect(resolved.colors.background).toBe('#222222');
|
|
103
|
+
expect(resolved.colors.text).toBe('#eeeeee');
|
|
104
|
+
expect(resolved.fonts.family).toBe('Georgia');
|
|
105
|
+
expect(resolved.spacing.padding).toBe(32);
|
|
106
|
+
expect(resolved.borderRadius).toBe(4);
|
|
107
|
+
// Non-overridden values preserved
|
|
108
|
+
expect(resolved.fonts.mono).toBe(DEFAULT_THEME.fonts.mono);
|
|
109
|
+
expect(resolved.spacing.chromeGap).toBe(DEFAULT_THEME.spacing.chromeGap);
|
|
110
|
+
expect(resolved.colors.categorical).toEqual(DEFAULT_THEME.colors.categorical);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('empty object override returns defaults unchanged', () => {
|
|
114
|
+
const resolved = resolveTheme({});
|
|
115
|
+
expect(resolved.colors).toEqual(expect.objectContaining(DEFAULT_THEME.colors));
|
|
116
|
+
expect(resolved.fonts).toEqual(DEFAULT_THEME.fonts);
|
|
117
|
+
expect(resolved.spacing).toEqual(DEFAULT_THEME.spacing);
|
|
118
|
+
expect(resolved.borderRadius).toBe(DEFAULT_THEME.borderRadius);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('dark background adapts chrome colors without losing chrome structure', () => {
|
|
122
|
+
const resolved = resolveTheme({ colors: { background: '#111111', text: '#ffffff' } });
|
|
123
|
+
expect(resolved.isDark).toBe(true);
|
|
124
|
+
// Chrome structure should still be fully populated
|
|
125
|
+
expect(resolved.chrome.title).toBeDefined();
|
|
126
|
+
expect(resolved.chrome.subtitle).toBeDefined();
|
|
127
|
+
expect(resolved.chrome.source).toBeDefined();
|
|
128
|
+
expect(resolved.chrome.byline).toBeDefined();
|
|
129
|
+
expect(resolved.chrome.footer).toBeDefined();
|
|
130
|
+
// Title color should be adapted (not the light-mode default)
|
|
131
|
+
expect(resolved.chrome.title.color).not.toBe(DEFAULT_THEME.chrome.title.color);
|
|
132
|
+
});
|
|
133
|
+
});
|