@opendata-ai/openchart-core 2.9.1 → 2.11.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 +53 -14
- package/dist/index.js +119 -31
- package/dist/index.js.map +1 -1
- package/dist/styles.css +2 -0
- package/package.json +1 -1
- package/src/index.ts +5 -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 +90 -30
- package/src/layout/index.ts +6 -1
- package/src/layout/text-measure.ts +18 -2
- package/src/responsive/__tests__/breakpoints.test.ts +89 -1
- package/src/responsive/breakpoints.ts +89 -9
- package/src/responsive/index.ts +5 -0
- package/src/styles/viz.css +2 -0
- package/src/theme/__tests__/resolve.test.ts +57 -0
- package/src/types/layout.ts +2 -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';
|
|
@@ -66,12 +67,15 @@ export type {
|
|
|
66
67
|
AnnotationPosition,
|
|
67
68
|
AxisLabelDensity,
|
|
68
69
|
Breakpoint,
|
|
70
|
+
ChromeMode,
|
|
71
|
+
HeightClass,
|
|
69
72
|
LabelMode,
|
|
70
73
|
LayoutStrategy,
|
|
71
74
|
LegendPosition,
|
|
72
75
|
} from './responsive/index';
|
|
73
76
|
export {
|
|
74
77
|
getBreakpoint,
|
|
78
|
+
getHeightClass,
|
|
75
79
|
getLayoutStrategy,
|
|
76
80
|
} from './responsive/index';
|
|
77
81
|
|
|
@@ -83,7 +87,7 @@ export type {
|
|
|
83
87
|
LabelCandidate,
|
|
84
88
|
LabelPriority,
|
|
85
89
|
} from './labels/index';
|
|
86
|
-
export { resolveCollisions } from './labels/index';
|
|
90
|
+
export { computeLabelBounds, detectCollision, resolveCollisions } from './labels/index';
|
|
87
91
|
|
|
88
92
|
// ---------------------------------------------------------------------------
|
|
89
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
|
@@ -3,8 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Takes a Chrome spec + resolved theme and produces a ResolvedChrome
|
|
5
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.
|
|
6
14
|
*/
|
|
7
15
|
|
|
16
|
+
import type { ChromeMode } from '../responsive/breakpoints';
|
|
8
17
|
import type {
|
|
9
18
|
MeasureTextFn,
|
|
10
19
|
ResolvedChrome,
|
|
@@ -13,7 +22,7 @@ import type {
|
|
|
13
22
|
} from '../types/layout';
|
|
14
23
|
import type { Chrome, ChromeText } from '../types/spec';
|
|
15
24
|
import type { ChromeDefaults, ResolvedTheme } from '../types/theme';
|
|
16
|
-
import {
|
|
25
|
+
import { BRAND_RESERVE_WIDTH, estimateCharWidth, estimateTextHeight } from './text-measure';
|
|
17
26
|
|
|
18
27
|
// ---------------------------------------------------------------------------
|
|
19
28
|
// Helpers
|
|
@@ -28,16 +37,32 @@ function normalizeChromeText(
|
|
|
28
37
|
return { text: value.text, style: value.style, offset: value.offset };
|
|
29
38
|
}
|
|
30
39
|
|
|
31
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Scale a font size based on container width. Only applies to default sizes
|
|
42
|
+
* (not user overrides). Scales from 100% at >= 500px down to 72% at <= 250px.
|
|
43
|
+
*/
|
|
44
|
+
function scaleFontSize(baseFontSize: number, width: number): number {
|
|
45
|
+
if (width >= 500) return baseFontSize;
|
|
46
|
+
if (width <= 250) return Math.max(Math.round(baseFontSize * 0.72), 10);
|
|
47
|
+
const t = (width - 250) / 250;
|
|
48
|
+
return Math.max(Math.round(baseFontSize * (0.72 + t * 0.28)), 10);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Build a TextStyle from chrome defaults + optional overrides, with width-based scaling. */
|
|
32
52
|
function buildTextStyle(
|
|
33
53
|
defaults: ChromeDefaults,
|
|
34
54
|
fontFamily: string,
|
|
35
55
|
textColor: string,
|
|
56
|
+
width: number,
|
|
36
57
|
overrides?: ChromeText['style'],
|
|
37
58
|
): TextStyle {
|
|
59
|
+
const hasExplicitSize = overrides?.fontSize !== undefined;
|
|
60
|
+
const baseFontSize = overrides?.fontSize ?? defaults.fontSize;
|
|
61
|
+
const fontSize = hasExplicitSize ? baseFontSize : scaleFontSize(baseFontSize, width);
|
|
62
|
+
|
|
38
63
|
return {
|
|
39
64
|
fontFamily: overrides?.fontFamily ?? fontFamily,
|
|
40
|
-
fontSize
|
|
65
|
+
fontSize,
|
|
41
66
|
fontWeight: overrides?.fontWeight ?? defaults.fontWeight,
|
|
42
67
|
fill: overrides?.color ?? textColor ?? defaults.color,
|
|
43
68
|
lineHeight: defaults.lineHeight,
|
|
@@ -46,27 +71,40 @@ function buildTextStyle(
|
|
|
46
71
|
};
|
|
47
72
|
}
|
|
48
73
|
|
|
49
|
-
/** Measure text width using the provided function or heuristic fallback. */
|
|
50
|
-
function measureWidth(text: string, style: TextStyle, measureText?: MeasureTextFn): number {
|
|
51
|
-
if (measureText) {
|
|
52
|
-
return measureText(text, style.fontSize, style.fontWeight).width;
|
|
53
|
-
}
|
|
54
|
-
return estimateTextWidth(text, style.fontSize, style.fontWeight);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
74
|
/**
|
|
58
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).
|
|
59
78
|
* Returns at least 1.
|
|
60
79
|
*/
|
|
61
80
|
function estimateLineCount(
|
|
62
81
|
text: string,
|
|
63
82
|
style: TextStyle,
|
|
64
83
|
maxWidth: number,
|
|
65
|
-
|
|
84
|
+
_measureText?: MeasureTextFn,
|
|
66
85
|
): number {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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;
|
|
70
108
|
}
|
|
71
109
|
|
|
72
110
|
// ---------------------------------------------------------------------------
|
|
@@ -83,24 +121,28 @@ function estimateLineCount(
|
|
|
83
121
|
* @param theme - The fully resolved theme.
|
|
84
122
|
* @param width - Total available width in pixels.
|
|
85
123
|
* @param measureText - Optional real text measurement function from the adapter.
|
|
124
|
+
* @param chromeMode - Chrome display mode: full, compact (title only), or hidden.
|
|
125
|
+
* @param padding - Override padding (for scaled padding from dimensions).
|
|
86
126
|
*/
|
|
87
127
|
export function computeChrome(
|
|
88
128
|
chrome: Chrome | undefined,
|
|
89
129
|
theme: ResolvedTheme,
|
|
90
130
|
width: number,
|
|
91
131
|
measureText?: MeasureTextFn,
|
|
132
|
+
chromeMode: ChromeMode = 'full',
|
|
133
|
+
padding?: number,
|
|
92
134
|
): ResolvedChrome {
|
|
93
|
-
if (!chrome) {
|
|
135
|
+
if (!chrome || chromeMode === 'hidden') {
|
|
94
136
|
return { topHeight: 0, bottomHeight: 0 };
|
|
95
137
|
}
|
|
96
138
|
|
|
97
|
-
const
|
|
139
|
+
const pad = padding ?? theme.spacing.padding;
|
|
98
140
|
const chromeGap = theme.spacing.chromeGap;
|
|
99
|
-
const maxWidth = width -
|
|
141
|
+
const maxWidth = width - pad * 2;
|
|
100
142
|
const fontFamily = theme.fonts.family;
|
|
101
143
|
|
|
102
144
|
// Track vertical cursor for top elements
|
|
103
|
-
let topY =
|
|
145
|
+
let topY = pad;
|
|
104
146
|
const topElements: Partial<Pick<ResolvedChrome, 'title' | 'subtitle'>> = {};
|
|
105
147
|
|
|
106
148
|
// Title
|
|
@@ -110,12 +152,13 @@ export function computeChrome(
|
|
|
110
152
|
theme.chrome.title,
|
|
111
153
|
fontFamily,
|
|
112
154
|
theme.chrome.title.color,
|
|
155
|
+
width,
|
|
113
156
|
titleNorm.style,
|
|
114
157
|
);
|
|
115
158
|
const lineCount = estimateLineCount(titleNorm.text, style, maxWidth, measureText);
|
|
116
159
|
const element: ResolvedChromeElement = {
|
|
117
160
|
text: titleNorm.text,
|
|
118
|
-
x:
|
|
161
|
+
x: pad + (titleNorm.offset?.dx ?? 0),
|
|
119
162
|
y: topY + (titleNorm.offset?.dy ?? 0),
|
|
120
163
|
maxWidth,
|
|
121
164
|
style,
|
|
@@ -124,19 +167,20 @@ export function computeChrome(
|
|
|
124
167
|
topY += estimateTextHeight(style.fontSize, lineCount, style.lineHeight) + chromeGap;
|
|
125
168
|
}
|
|
126
169
|
|
|
127
|
-
// Subtitle
|
|
128
|
-
const subtitleNorm = normalizeChromeText(chrome.subtitle);
|
|
170
|
+
// Subtitle (hidden in compact mode)
|
|
171
|
+
const subtitleNorm = chromeMode === 'compact' ? null : normalizeChromeText(chrome.subtitle);
|
|
129
172
|
if (subtitleNorm) {
|
|
130
173
|
const style = buildTextStyle(
|
|
131
174
|
theme.chrome.subtitle,
|
|
132
175
|
fontFamily,
|
|
133
176
|
theme.chrome.subtitle.color,
|
|
177
|
+
width,
|
|
134
178
|
subtitleNorm.style,
|
|
135
179
|
);
|
|
136
180
|
const lineCount = estimateLineCount(subtitleNorm.text, style, maxWidth, measureText);
|
|
137
181
|
const element: ResolvedChromeElement = {
|
|
138
182
|
text: subtitleNorm.text,
|
|
139
|
-
x:
|
|
183
|
+
x: pad + (subtitleNorm.offset?.dx ?? 0),
|
|
140
184
|
y: topY + (subtitleNorm.offset?.dy ?? 0),
|
|
141
185
|
maxWidth,
|
|
142
186
|
style,
|
|
@@ -147,10 +191,20 @@ export function computeChrome(
|
|
|
147
191
|
|
|
148
192
|
// Add chromeToChart gap if there are any top elements
|
|
149
193
|
const hasTopChrome = titleNorm || subtitleNorm;
|
|
150
|
-
const topHeight = hasTopChrome ? topY -
|
|
194
|
+
const topHeight = hasTopChrome ? topY - pad + theme.spacing.chromeToChart - chromeGap : 0;
|
|
195
|
+
|
|
196
|
+
// Bottom elements hidden in compact mode
|
|
197
|
+
if (chromeMode === 'compact') {
|
|
198
|
+
return {
|
|
199
|
+
topHeight,
|
|
200
|
+
bottomHeight: 0,
|
|
201
|
+
...topElements,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
151
204
|
|
|
152
205
|
// Bottom elements: source, byline, footer
|
|
153
|
-
//
|
|
206
|
+
// Reserve space on the right for the brand watermark so text doesn't overlap it
|
|
207
|
+
const bottomMaxWidth = maxWidth - BRAND_RESERVE_WIDTH;
|
|
154
208
|
const bottomElements: Partial<Pick<ResolvedChrome, 'source' | 'byline' | 'footer'>> = {};
|
|
155
209
|
let bottomHeight = 0;
|
|
156
210
|
|
|
@@ -191,17 +245,23 @@ export function computeChrome(
|
|
|
191
245
|
bottomHeight += theme.spacing.chartToFooter;
|
|
192
246
|
|
|
193
247
|
for (const item of bottomItems) {
|
|
194
|
-
const style = buildTextStyle(
|
|
195
|
-
|
|
248
|
+
const style = buildTextStyle(
|
|
249
|
+
item.defaults,
|
|
250
|
+
fontFamily,
|
|
251
|
+
item.defaults.color,
|
|
252
|
+
width,
|
|
253
|
+
item.norm.style,
|
|
254
|
+
);
|
|
255
|
+
const lineCount = estimateLineCount(item.norm.text, style, bottomMaxWidth, measureText);
|
|
196
256
|
const height = estimateTextHeight(style.fontSize, lineCount, style.lineHeight);
|
|
197
257
|
|
|
198
258
|
// y positions will be computed relative to the bottom of the
|
|
199
259
|
// chart area by the engine. We store offsets from bottom start.
|
|
200
260
|
bottomElements[item.key] = {
|
|
201
261
|
text: item.norm.text,
|
|
202
|
-
x:
|
|
262
|
+
x: pad + (item.norm.offset?.dx ?? 0),
|
|
203
263
|
y: bottomHeight + (item.norm.offset?.dy ?? 0), // offset from where bottom chrome starts
|
|
204
|
-
maxWidth,
|
|
264
|
+
maxWidth: bottomMaxWidth,
|
|
205
265
|
style,
|
|
206
266
|
};
|
|
207
267
|
|
|
@@ -211,7 +271,7 @@ export function computeChrome(
|
|
|
211
271
|
// Remove trailing gap
|
|
212
272
|
bottomHeight -= chromeGap;
|
|
213
273
|
// Add bottom padding
|
|
214
|
-
bottomHeight +=
|
|
274
|
+
bottomHeight += pad;
|
|
215
275
|
}
|
|
216
276
|
|
|
217
277
|
return {
|
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
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { getBreakpoint, getLayoutStrategy } from '../breakpoints';
|
|
2
|
+
import { getBreakpoint, getHeightClass, getLayoutStrategy } from '../breakpoints';
|
|
3
3
|
|
|
4
4
|
describe('getBreakpoint', () => {
|
|
5
5
|
it('returns compact for widths below 400', () => {
|
|
@@ -55,4 +55,92 @@ describe('getLayoutStrategy', () => {
|
|
|
55
55
|
expect(compact.legendPosition).not.toBe(full.legendPosition);
|
|
56
56
|
expect(compact.labelMode).not.toBe(full.labelMode);
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
it('includes chromeMode and legendMaxHeight at normal height', () => {
|
|
60
|
+
const strategy = getLayoutStrategy('full');
|
|
61
|
+
expect(strategy.chromeMode).toBe('full');
|
|
62
|
+
expect(strategy.legendMaxHeight).toBe(-1);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Height class detection
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
describe('getHeightClass', () => {
|
|
71
|
+
it('returns cramped for heights below 200', () => {
|
|
72
|
+
expect(getHeightClass(100)).toBe('cramped');
|
|
73
|
+
expect(getHeightClass(199)).toBe('cramped');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns short for heights 200-350', () => {
|
|
77
|
+
expect(getHeightClass(200)).toBe('short');
|
|
78
|
+
expect(getHeightClass(280)).toBe('short');
|
|
79
|
+
expect(getHeightClass(350)).toBe('short');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns normal for heights above 350', () => {
|
|
83
|
+
expect(getHeightClass(351)).toBe('normal');
|
|
84
|
+
expect(getHeightClass(800)).toBe('normal');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('handles edge cases', () => {
|
|
88
|
+
expect(getHeightClass(0)).toBe('cramped');
|
|
89
|
+
expect(getHeightClass(-10)).toBe('cramped');
|
|
90
|
+
expect(getHeightClass(5000)).toBe('normal');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Height-aware layout strategy
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
describe('getLayoutStrategy with height class', () => {
|
|
99
|
+
it('normal height does not modify the width strategy', () => {
|
|
100
|
+
const withoutHeight = getLayoutStrategy('full');
|
|
101
|
+
const withNormal = getLayoutStrategy('full', 'normal');
|
|
102
|
+
expect(withoutHeight).toEqual(withNormal);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('cramped height hides chrome and labels', () => {
|
|
106
|
+
const strategy = getLayoutStrategy('full', 'cramped');
|
|
107
|
+
expect(strategy.chromeMode).toBe('hidden');
|
|
108
|
+
expect(strategy.legendMaxHeight).toBe(0);
|
|
109
|
+
expect(strategy.labelMode).toBe('none');
|
|
110
|
+
expect(strategy.annotationPosition).toBe('tooltip-only');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('cramped overrides even compact width strategy', () => {
|
|
114
|
+
const strategy = getLayoutStrategy('compact', 'cramped');
|
|
115
|
+
expect(strategy.chromeMode).toBe('hidden');
|
|
116
|
+
expect(strategy.legendMaxHeight).toBe(0);
|
|
117
|
+
expect(strategy.labelMode).toBe('none');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('short height compresses chrome and caps legend', () => {
|
|
121
|
+
const strategy = getLayoutStrategy('full', 'short');
|
|
122
|
+
expect(strategy.chromeMode).toBe('compact');
|
|
123
|
+
expect(strategy.legendMaxHeight).toBe(0.15);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('short height preserves width-based label and legend settings', () => {
|
|
127
|
+
const strategy = getLayoutStrategy('full', 'short');
|
|
128
|
+
// Width strategy for 'full' sets these; short only touches chromeMode and legendMaxHeight
|
|
129
|
+
expect(strategy.labelMode).toBe('all');
|
|
130
|
+
expect(strategy.legendPosition).toBe('right');
|
|
131
|
+
expect(strategy.axisLabelDensity).toBe('full');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('short height preserves compact width label settings', () => {
|
|
135
|
+
const strategy = getLayoutStrategy('compact', 'short');
|
|
136
|
+
expect(strategy.labelMode).toBe('none');
|
|
137
|
+
expect(strategy.legendPosition).toBe('top');
|
|
138
|
+
expect(strategy.chromeMode).toBe('compact');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('defaults heightClass to normal when omitted', () => {
|
|
142
|
+
const strategy = getLayoutStrategy('medium');
|
|
143
|
+
expect(strategy.chromeMode).toBe('full');
|
|
144
|
+
expect(strategy.legendMaxHeight).toBe(-1);
|
|
145
|
+
});
|
|
58
146
|
});
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Responsive breakpoints and layout strategies.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Width breakpoints:
|
|
5
5
|
* - compact: < 400px (mobile, small embeds)
|
|
6
6
|
* - medium: 400-700px (tablet, sidebars)
|
|
7
7
|
* - full: > 700px (desktop, full-width)
|
|
8
|
+
*
|
|
9
|
+
* Height classes:
|
|
10
|
+
* - cramped: < 200px (dashboard widgets, thumbnails)
|
|
11
|
+
* - short: 200-350px (embedded panels, short containers)
|
|
12
|
+
* - normal: > 350px (standard containers)
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
15
|
// ---------------------------------------------------------------------------
|
|
11
|
-
//
|
|
16
|
+
// Width breakpoint type and detection
|
|
12
17
|
// ---------------------------------------------------------------------------
|
|
13
18
|
|
|
14
19
|
/** Responsive breakpoint based on container width. */
|
|
@@ -27,6 +32,26 @@ export function getBreakpoint(width: number): Breakpoint {
|
|
|
27
32
|
return 'full';
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Height class type and detection
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/** Height classification based on container height. */
|
|
40
|
+
export type HeightClass = 'cramped' | 'short' | 'normal';
|
|
41
|
+
|
|
42
|
+
/** Height class thresholds in pixels. */
|
|
43
|
+
export const HEIGHT_CRAMPED_MAX = 200;
|
|
44
|
+
export const HEIGHT_SHORT_MAX = 350;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Determine the height class for a given container height.
|
|
48
|
+
*/
|
|
49
|
+
export function getHeightClass(height: number): HeightClass {
|
|
50
|
+
if (height < HEIGHT_CRAMPED_MAX) return 'cramped';
|
|
51
|
+
if (height <= HEIGHT_SHORT_MAX) return 'short';
|
|
52
|
+
return 'normal';
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
// ---------------------------------------------------------------------------
|
|
31
56
|
// Layout strategy
|
|
32
57
|
// ---------------------------------------------------------------------------
|
|
@@ -43,9 +68,12 @@ export type AnnotationPosition = 'inline' | 'tooltip-only';
|
|
|
43
68
|
/** Axis label density (controls tick count reduction). */
|
|
44
69
|
export type AxisLabelDensity = 'full' | 'reduced' | 'minimal';
|
|
45
70
|
|
|
71
|
+
/** Chrome display mode based on available height. */
|
|
72
|
+
export type ChromeMode = 'full' | 'compact' | 'hidden';
|
|
73
|
+
|
|
46
74
|
/**
|
|
47
75
|
* Layout strategy defining how the visualization adapts to available space.
|
|
48
|
-
* Returned by getLayoutStrategy() based on
|
|
76
|
+
* Returned by getLayoutStrategy() based on width breakpoint and height class.
|
|
49
77
|
*/
|
|
50
78
|
export interface LayoutStrategy {
|
|
51
79
|
/** How data labels are displayed. */
|
|
@@ -56,16 +84,16 @@ export interface LayoutStrategy {
|
|
|
56
84
|
annotationPosition: AnnotationPosition;
|
|
57
85
|
/** Axis tick density. */
|
|
58
86
|
axisLabelDensity: AxisLabelDensity;
|
|
87
|
+
/** Chrome display mode: full, compact (title only), or hidden. */
|
|
88
|
+
chromeMode: ChromeMode;
|
|
89
|
+
/** Max fraction of container height for legend (0-1). -1 means unlimited. */
|
|
90
|
+
legendMaxHeight: number;
|
|
59
91
|
}
|
|
60
92
|
|
|
61
93
|
/**
|
|
62
|
-
* Get the layout strategy for a
|
|
63
|
-
*
|
|
64
|
-
* Compact: minimal chrome, no inline labels, legend on top, reduced axes.
|
|
65
|
-
* Medium: moderate labels, legend on top, reduced axes.
|
|
66
|
-
* Full: all labels, legend on right, full axes.
|
|
94
|
+
* Get the base layout strategy for a width breakpoint.
|
|
67
95
|
*/
|
|
68
|
-
|
|
96
|
+
function getWidthStrategy(breakpoint: Breakpoint): LayoutStrategy {
|
|
69
97
|
switch (breakpoint) {
|
|
70
98
|
case 'compact':
|
|
71
99
|
return {
|
|
@@ -73,6 +101,8 @@ export function getLayoutStrategy(breakpoint: Breakpoint): LayoutStrategy {
|
|
|
73
101
|
legendPosition: 'top',
|
|
74
102
|
annotationPosition: 'tooltip-only',
|
|
75
103
|
axisLabelDensity: 'minimal',
|
|
104
|
+
chromeMode: 'full',
|
|
105
|
+
legendMaxHeight: -1,
|
|
76
106
|
};
|
|
77
107
|
case 'medium':
|
|
78
108
|
return {
|
|
@@ -80,6 +110,8 @@ export function getLayoutStrategy(breakpoint: Breakpoint): LayoutStrategy {
|
|
|
80
110
|
legendPosition: 'top',
|
|
81
111
|
annotationPosition: 'inline',
|
|
82
112
|
axisLabelDensity: 'reduced',
|
|
113
|
+
chromeMode: 'full',
|
|
114
|
+
legendMaxHeight: -1,
|
|
83
115
|
};
|
|
84
116
|
case 'full':
|
|
85
117
|
return {
|
|
@@ -87,6 +119,54 @@ export function getLayoutStrategy(breakpoint: Breakpoint): LayoutStrategy {
|
|
|
87
119
|
legendPosition: 'right',
|
|
88
120
|
annotationPosition: 'inline',
|
|
89
121
|
axisLabelDensity: 'full',
|
|
122
|
+
chromeMode: 'full',
|
|
123
|
+
legendMaxHeight: -1,
|
|
90
124
|
};
|
|
91
125
|
}
|
|
92
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Apply height constraints to a width-based strategy.
|
|
130
|
+
* Short containers compress chrome and cap legend height.
|
|
131
|
+
* Cramped containers hide chrome and labels entirely.
|
|
132
|
+
*/
|
|
133
|
+
function applyHeightConstraints(
|
|
134
|
+
strategy: LayoutStrategy,
|
|
135
|
+
heightClass: HeightClass,
|
|
136
|
+
): LayoutStrategy {
|
|
137
|
+
if (heightClass === 'normal') return strategy;
|
|
138
|
+
|
|
139
|
+
if (heightClass === 'cramped') {
|
|
140
|
+
return {
|
|
141
|
+
...strategy,
|
|
142
|
+
chromeMode: 'hidden',
|
|
143
|
+
legendMaxHeight: 0,
|
|
144
|
+
labelMode: 'none',
|
|
145
|
+
annotationPosition: 'tooltip-only',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// short
|
|
150
|
+
return {
|
|
151
|
+
...strategy,
|
|
152
|
+
chromeMode: 'compact',
|
|
153
|
+
legendMaxHeight: 0.15,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get the layout strategy for a given breakpoint and height class.
|
|
159
|
+
*
|
|
160
|
+
* Compact: minimal chrome, no inline labels, legend on top, reduced axes.
|
|
161
|
+
* Medium: moderate labels, legend on top, reduced axes.
|
|
162
|
+
* Full: all labels, legend on right, full axes.
|
|
163
|
+
*
|
|
164
|
+
* Height constraints further reduce chrome and legend when container is short.
|
|
165
|
+
*/
|
|
166
|
+
export function getLayoutStrategy(
|
|
167
|
+
breakpoint: Breakpoint,
|
|
168
|
+
heightClass: HeightClass = 'normal',
|
|
169
|
+
): LayoutStrategy {
|
|
170
|
+
const base = getWidthStrategy(breakpoint);
|
|
171
|
+
return applyHeightConstraints(base, heightClass);
|
|
172
|
+
}
|
package/src/responsive/index.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type {
|
|
|
6
6
|
AnnotationPosition,
|
|
7
7
|
AxisLabelDensity,
|
|
8
8
|
Breakpoint,
|
|
9
|
+
ChromeMode,
|
|
10
|
+
HeightClass,
|
|
9
11
|
LabelMode,
|
|
10
12
|
LayoutStrategy,
|
|
11
13
|
LegendPosition,
|
|
@@ -14,5 +16,8 @@ export {
|
|
|
14
16
|
BREAKPOINT_COMPACT_MAX,
|
|
15
17
|
BREAKPOINT_MEDIUM_MAX,
|
|
16
18
|
getBreakpoint,
|
|
19
|
+
getHeightClass,
|
|
17
20
|
getLayoutStrategy,
|
|
21
|
+
HEIGHT_CRAMPED_MAX,
|
|
22
|
+
HEIGHT_SHORT_MAX,
|
|
18
23
|
} from './breakpoints';
|