@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/styles.css CHANGED
@@ -662,6 +662,8 @@ th[aria-sort="descending"] .viz-table-sort-btn::before {
662
662
  overflow: hidden;
663
663
  background: var(--viz-bg);
664
664
  font-family: var(--viz-font-family);
665
+ width: 100%;
666
+ height: 100%;
665
667
  }
666
668
 
667
669
  .viz-graph-canvas {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-core",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "description": "Types, theme, colors, accessibility, and utilities for openchart",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
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
@@ -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
+ }
@@ -3,4 +3,4 @@
3
3
  */
4
4
 
5
5
  export type { LabelCandidate, LabelPriority } from './collision';
6
- export { detectCollision, resolveCollisions } from './collision';
6
+ export { computeLabelBounds, detectCollision, resolveCollisions } from './collision';
@@ -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,
@@ -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 { estimateTextHeight, estimateTextWidth } from './text-measure';
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
- measureText?: MeasureTextFn,
84
+ _measureText?: MeasureTextFn,
91
85
  ): number {
92
- const fullWidth = measureWidth(text, style, measureText);
93
- if (fullWidth <= maxWidth) return 1;
94
- return Math.ceil(fullWidth / maxWidth);
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, maxWidth, measureText);
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
 
@@ -3,4 +3,9 @@
3
3
  */
4
4
 
5
5
  export { computeChrome } from './chrome';
6
- export { estimateTextHeight, estimateTextWidth } from './text-measure';
6
+ export {
7
+ BRAND_RESERVE_WIDTH,
8
+ estimateCharWidth,
9
+ estimateTextHeight,
10
+ estimateTextWidth,
11
+ } from './text-measure';
@@ -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
- const weightFactor = WEIGHT_ADJUSTMENT[fontWeight] ?? 1.0;
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
  *
@@ -662,6 +662,8 @@ th[aria-sort="descending"] .viz-table-sort-btn::before {
662
662
  overflow: hidden;
663
663
  background: var(--viz-bg);
664
664
  font-family: var(--viz-font-family);
665
+ width: 100%;
666
+ height: 100%;
665
667
  }
666
668
 
667
669
  .viz-graph-canvas {
@@ -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
+ });