@opendata-ai/openchart-core 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-core",
3
- "version": "6.19.3",
3
+ "version": "6.21.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
@@ -58,6 +58,7 @@ export {
58
58
  BRAND_RESERVE_WIDTH,
59
59
  computeChrome,
60
60
  estimateTextWidth,
61
+ wrapText,
61
62
  } from './layout/index';
62
63
 
63
64
  // ---------------------------------------------------------------------------
@@ -30,6 +30,15 @@ describe('estimateTextWidth', () => {
30
30
  expect(width).toBeGreaterThan(100);
31
31
  expect(width).toBeLessThan(250);
32
32
  });
33
+
34
+ // Characterization test (refactor/v7-cohesion step 1):
35
+ // Pins the AVG_CHAR_WIDTH_RATIO = 0.57 constant introduced in commit e7b98f9.
36
+ // Any future tuning of the ratio changes layout-wide text wrapping decisions,
37
+ // so we freeze the exact numeric output of the canonical call.
38
+ it('estimateTextWidth("sample", 14, 400) returns the locked numeric value', () => {
39
+ // 6 chars * (14 * 0.57 * 1.0) = 6 * 7.98 = 47.88
40
+ expect(estimateTextWidth('sample', 14, 400)).toBeCloseTo(47.88, 5);
41
+ });
33
42
  });
34
43
 
35
44
  describe('estimateTextHeight', () => {
@@ -11,3 +11,4 @@ export {
11
11
  estimateTextHeight,
12
12
  estimateTextWidth,
13
13
  } from './text-measure';
14
+ export { wrapText } from './text-wrap';
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Word-wrap a text string into lines that fit within a max width.
3
+ *
4
+ * Shared by the SVG chart renderer and the sankey renderer. When a
5
+ * `measureText` callback is provided, uses real DOM measurement for
6
+ * accurate wrapping. Otherwise falls back to a character-width
7
+ * heuristic driven by `estimateCharWidth` from text-measure.ts.
8
+ *
9
+ * Callers that want heuristic-only behavior (e.g. sankey) should omit
10
+ * the measureText argument. Do not change the signature without
11
+ * re-verifying visual baselines for every caller.
12
+ */
13
+
14
+ import type { MeasureTextFn } from '../types/layout';
15
+ import { estimateCharWidth } from './text-measure';
16
+
17
+ /**
18
+ * Break text into lines that fit within maxWidth using word wrapping.
19
+ *
20
+ * Splits on explicit newlines first, then word-wraps each segment.
21
+ * Preserves empty segments so consecutive newlines produce blank lines.
22
+ *
23
+ * @param text - The text to wrap. May contain `\n` for forced breaks.
24
+ * @param fontSize - Font size in pixels.
25
+ * @param fontWeight - Font weight (100-900).
26
+ * @param maxWidth - Maximum line width in pixels. Non-positive values return `[text]` unchanged.
27
+ * @param measureText - Optional real text measurer. When omitted, uses a character-width heuristic.
28
+ */
29
+ export function wrapText(
30
+ text: string,
31
+ fontSize: number,
32
+ fontWeight: number,
33
+ maxWidth: number,
34
+ measureText?: MeasureTextFn,
35
+ ): string[] {
36
+ if (maxWidth <= 0) return [text];
37
+
38
+ // Split on explicit newlines first
39
+ const segments = text.split('\n');
40
+ if (segments.length > 1) {
41
+ return segments.flatMap((segment) =>
42
+ segment.length === 0 ? [''] : wrapText(segment, fontSize, fontWeight, maxWidth, measureText),
43
+ );
44
+ }
45
+
46
+ // Use real text measurement when available
47
+ if (measureText) {
48
+ const textWidth = measureText(text, fontSize, fontWeight).width;
49
+ if (textWidth <= maxWidth) return [text];
50
+
51
+ const words = text.split(' ');
52
+ const lines: string[] = [];
53
+ let current = '';
54
+
55
+ for (const word of words) {
56
+ const candidate = current ? `${current} ${word}` : word;
57
+ const candidateWidth = measureText(candidate, fontSize, fontWeight).width;
58
+ if (candidateWidth > maxWidth && current) {
59
+ lines.push(current);
60
+ current = word;
61
+ } else {
62
+ current = candidate;
63
+ }
64
+ }
65
+ if (current) lines.push(current);
66
+
67
+ return lines;
68
+ }
69
+
70
+ // Heuristic: estimate character width from font size and weight.
71
+ // Reuses the same ratio and weight adjustment constants as text-measure.
72
+ const charWidth = estimateCharWidth(fontSize, fontWeight);
73
+ const maxChars = Math.floor(maxWidth / charWidth);
74
+
75
+ if (text.length <= maxChars) return [text];
76
+
77
+ const words = text.split(' ');
78
+ const lines: string[] = [];
79
+ let current = '';
80
+
81
+ for (const word of words) {
82
+ const candidate = current ? `${current} ${word}` : word;
83
+ if (candidate.length > maxChars && current) {
84
+ lines.push(current);
85
+ current = word;
86
+ } else {
87
+ current = candidate;
88
+ }
89
+ }
90
+ if (current) lines.push(current);
91
+
92
+ return lines;
93
+ }