@opendata-ai/openchart-core 2.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.
Files changed (51) hide show
  1. package/README.md +130 -0
  2. package/dist/index.d.ts +2030 -0
  3. package/dist/index.js +1176 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/styles.css +757 -0
  6. package/package.json +61 -0
  7. package/src/accessibility/__tests__/alt-text.test.ts +110 -0
  8. package/src/accessibility/__tests__/aria.test.ts +125 -0
  9. package/src/accessibility/alt-text.ts +120 -0
  10. package/src/accessibility/aria.ts +73 -0
  11. package/src/accessibility/index.ts +6 -0
  12. package/src/colors/__tests__/colorblind.test.ts +63 -0
  13. package/src/colors/__tests__/contrast.test.ts +71 -0
  14. package/src/colors/__tests__/palettes.test.ts +54 -0
  15. package/src/colors/colorblind.ts +122 -0
  16. package/src/colors/contrast.ts +94 -0
  17. package/src/colors/index.ts +27 -0
  18. package/src/colors/palettes.ts +118 -0
  19. package/src/helpers/__tests__/spec-builders.test.ts +336 -0
  20. package/src/helpers/spec-builders.ts +410 -0
  21. package/src/index.ts +129 -0
  22. package/src/labels/__tests__/collision.test.ts +197 -0
  23. package/src/labels/collision.ts +154 -0
  24. package/src/labels/index.ts +6 -0
  25. package/src/layout/__tests__/chrome.test.ts +114 -0
  26. package/src/layout/__tests__/text-measure.test.ts +49 -0
  27. package/src/layout/chrome.ts +223 -0
  28. package/src/layout/index.ts +6 -0
  29. package/src/layout/text-measure.ts +54 -0
  30. package/src/locale/__tests__/format.test.ts +90 -0
  31. package/src/locale/format.ts +132 -0
  32. package/src/locale/index.ts +6 -0
  33. package/src/responsive/__tests__/breakpoints.test.ts +58 -0
  34. package/src/responsive/breakpoints.ts +92 -0
  35. package/src/responsive/index.ts +18 -0
  36. package/src/styles/viz.css +757 -0
  37. package/src/theme/__tests__/dark-mode.test.ts +68 -0
  38. package/src/theme/__tests__/defaults.test.ts +47 -0
  39. package/src/theme/__tests__/resolve.test.ts +61 -0
  40. package/src/theme/dark-mode.ts +123 -0
  41. package/src/theme/defaults.ts +85 -0
  42. package/src/theme/index.ts +7 -0
  43. package/src/theme/resolve.ts +190 -0
  44. package/src/types/__tests__/spec.test.ts +387 -0
  45. package/src/types/encoding.ts +144 -0
  46. package/src/types/events.ts +96 -0
  47. package/src/types/index.ts +141 -0
  48. package/src/types/layout.ts +794 -0
  49. package/src/types/spec.ts +563 -0
  50. package/src/types/table.ts +105 -0
  51. package/src/types/theme.ts +159 -0
@@ -0,0 +1,223 @@
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
+
8
+ import type {
9
+ MeasureTextFn,
10
+ ResolvedChrome,
11
+ ResolvedChromeElement,
12
+ TextStyle,
13
+ } from '../types/layout';
14
+ import type { Chrome, ChromeText } from '../types/spec';
15
+ import type { ChromeDefaults, ResolvedTheme } from '../types/theme';
16
+ import { estimateTextHeight, estimateTextWidth } from './text-measure';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** Normalize a chrome field to text + optional style and offset overrides. */
23
+ function normalizeChromeText(
24
+ value: string | ChromeText | undefined,
25
+ ): { text: string; style?: ChromeText['style']; offset?: ChromeText['offset'] } | null {
26
+ if (value === undefined) return null;
27
+ if (typeof value === 'string') return { text: value };
28
+ return { text: value.text, style: value.style, offset: value.offset };
29
+ }
30
+
31
+ /** Build a TextStyle from chrome defaults + optional overrides. */
32
+ function buildTextStyle(
33
+ defaults: ChromeDefaults,
34
+ fontFamily: string,
35
+ textColor: string,
36
+ overrides?: ChromeText['style'],
37
+ ): TextStyle {
38
+ return {
39
+ fontFamily: overrides?.fontFamily ?? fontFamily,
40
+ fontSize: overrides?.fontSize ?? defaults.fontSize,
41
+ fontWeight: overrides?.fontWeight ?? defaults.fontWeight,
42
+ fill: overrides?.color ?? textColor ?? defaults.color,
43
+ lineHeight: defaults.lineHeight,
44
+ textAnchor: 'start',
45
+ dominantBaseline: 'hanging',
46
+ };
47
+ }
48
+
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
+ /**
58
+ * Estimate how many lines text will wrap to, given a max width.
59
+ * Returns at least 1.
60
+ */
61
+ function estimateLineCount(
62
+ text: string,
63
+ style: TextStyle,
64
+ maxWidth: number,
65
+ measureText?: MeasureTextFn,
66
+ ): number {
67
+ const fullWidth = measureWidth(text, style, measureText);
68
+ if (fullWidth <= maxWidth) return 1;
69
+ return Math.ceil(fullWidth / maxWidth);
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Public API
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Compute resolved chrome layout from a Chrome spec and resolved theme.
78
+ *
79
+ * Produces positioned text elements and total chrome heights (top and bottom).
80
+ * Top chrome: title, subtitle. Bottom chrome: source, byline, footer.
81
+ *
82
+ * @param chrome - The Chrome spec from the user's VizSpec.
83
+ * @param theme - The fully resolved theme.
84
+ * @param width - Total available width in pixels.
85
+ * @param measureText - Optional real text measurement function from the adapter.
86
+ */
87
+ export function computeChrome(
88
+ chrome: Chrome | undefined,
89
+ theme: ResolvedTheme,
90
+ width: number,
91
+ measureText?: MeasureTextFn,
92
+ ): ResolvedChrome {
93
+ if (!chrome) {
94
+ return { topHeight: 0, bottomHeight: 0 };
95
+ }
96
+
97
+ const padding = theme.spacing.padding;
98
+ const chromeGap = theme.spacing.chromeGap;
99
+ const maxWidth = width - padding * 2;
100
+ const fontFamily = theme.fonts.family;
101
+
102
+ // Track vertical cursor for top elements
103
+ let topY = padding;
104
+ const topElements: Partial<Pick<ResolvedChrome, 'title' | 'subtitle'>> = {};
105
+
106
+ // Title
107
+ const titleNorm = normalizeChromeText(chrome.title);
108
+ if (titleNorm) {
109
+ const style = buildTextStyle(
110
+ theme.chrome.title,
111
+ fontFamily,
112
+ theme.chrome.title.color,
113
+ titleNorm.style,
114
+ );
115
+ const lineCount = estimateLineCount(titleNorm.text, style, maxWidth, measureText);
116
+ const element: ResolvedChromeElement = {
117
+ text: titleNorm.text,
118
+ x: padding + (titleNorm.offset?.dx ?? 0),
119
+ y: topY + (titleNorm.offset?.dy ?? 0),
120
+ maxWidth,
121
+ style,
122
+ };
123
+ topElements.title = element;
124
+ topY += estimateTextHeight(style.fontSize, lineCount, style.lineHeight) + chromeGap;
125
+ }
126
+
127
+ // Subtitle
128
+ const subtitleNorm = normalizeChromeText(chrome.subtitle);
129
+ if (subtitleNorm) {
130
+ const style = buildTextStyle(
131
+ theme.chrome.subtitle,
132
+ fontFamily,
133
+ theme.chrome.subtitle.color,
134
+ subtitleNorm.style,
135
+ );
136
+ const lineCount = estimateLineCount(subtitleNorm.text, style, maxWidth, measureText);
137
+ const element: ResolvedChromeElement = {
138
+ text: subtitleNorm.text,
139
+ x: padding + (subtitleNorm.offset?.dx ?? 0),
140
+ y: topY + (subtitleNorm.offset?.dy ?? 0),
141
+ maxWidth,
142
+ style,
143
+ };
144
+ topElements.subtitle = element;
145
+ topY += estimateTextHeight(style.fontSize, lineCount, style.lineHeight) + chromeGap;
146
+ }
147
+
148
+ // Add chromeToChart gap if there are any top elements
149
+ const hasTopChrome = titleNorm || subtitleNorm;
150
+ const topHeight = hasTopChrome ? topY - padding + theme.spacing.chromeToChart - chromeGap : 0;
151
+
152
+ // Bottom elements: source, byline, footer
153
+ // We compute heights bottom-up but position them after knowing total
154
+ const bottomElements: Partial<Pick<ResolvedChrome, 'source' | 'byline' | 'footer'>> = {};
155
+ let bottomHeight = 0;
156
+
157
+ const bottomItems: Array<{
158
+ key: 'source' | 'byline' | 'footer';
159
+ norm: { text: string; style?: ChromeText['style']; offset?: ChromeText['offset'] };
160
+ defaults: ChromeDefaults;
161
+ }> = [];
162
+
163
+ const sourceNorm = normalizeChromeText(chrome.source);
164
+ if (sourceNorm) {
165
+ bottomItems.push({
166
+ key: 'source',
167
+ norm: sourceNorm,
168
+ defaults: theme.chrome.source,
169
+ });
170
+ }
171
+
172
+ const bylineNorm = normalizeChromeText(chrome.byline);
173
+ if (bylineNorm) {
174
+ bottomItems.push({
175
+ key: 'byline',
176
+ norm: bylineNorm,
177
+ defaults: theme.chrome.byline,
178
+ });
179
+ }
180
+
181
+ const footerNorm = normalizeChromeText(chrome.footer);
182
+ if (footerNorm) {
183
+ bottomItems.push({
184
+ key: 'footer',
185
+ norm: footerNorm,
186
+ defaults: theme.chrome.footer,
187
+ });
188
+ }
189
+
190
+ if (bottomItems.length > 0) {
191
+ bottomHeight += theme.spacing.chartToFooter;
192
+
193
+ for (const item of bottomItems) {
194
+ const style = buildTextStyle(item.defaults, fontFamily, item.defaults.color, item.norm.style);
195
+ const lineCount = estimateLineCount(item.norm.text, style, maxWidth, measureText);
196
+ const height = estimateTextHeight(style.fontSize, lineCount, style.lineHeight);
197
+
198
+ // y positions will be computed relative to the bottom of the
199
+ // chart area by the engine. We store offsets from bottom start.
200
+ bottomElements[item.key] = {
201
+ text: item.norm.text,
202
+ x: padding + (item.norm.offset?.dx ?? 0),
203
+ y: bottomHeight + (item.norm.offset?.dy ?? 0), // offset from where bottom chrome starts
204
+ maxWidth,
205
+ style,
206
+ };
207
+
208
+ bottomHeight += height + chromeGap;
209
+ }
210
+
211
+ // Remove trailing gap
212
+ bottomHeight -= chromeGap;
213
+ // Add bottom padding
214
+ bottomHeight += padding;
215
+ }
216
+
217
+ return {
218
+ topHeight,
219
+ bottomHeight,
220
+ ...topElements,
221
+ ...bottomElements,
222
+ };
223
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Layout module barrel export.
3
+ */
4
+
5
+ export { computeChrome } from './chrome';
6
+ export { estimateTextHeight, estimateTextWidth } from './text-measure';
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Heuristic text measurement for environments without a DOM.
3
+ *
4
+ * These are intentionally approximate. Adapters can provide a real
5
+ * measureText function via CompileOptions for higher accuracy.
6
+ * The engine uses the real function when available, falls back to
7
+ * these heuristics when not.
8
+ */
9
+
10
+ /**
11
+ * Average character width as a fraction of font size for Inter.
12
+ * Inter is slightly wider than Helvetica, narrower than Courier.
13
+ * This is a reasonable middle ground.
14
+ */
15
+ const AVG_CHAR_WIDTH_RATIO = 0.55;
16
+
17
+ /** Narrower characters (i, l, t, etc.) bring the average down. */
18
+ const WEIGHT_ADJUSTMENT: Record<number, number> = {
19
+ 100: 0.9,
20
+ 200: 0.92,
21
+ 300: 0.95,
22
+ 400: 1.0,
23
+ 500: 1.02,
24
+ 600: 1.05,
25
+ 700: 1.08,
26
+ 800: 1.1,
27
+ 900: 1.12,
28
+ };
29
+
30
+ /**
31
+ * Estimate the rendered width of a text string.
32
+ *
33
+ * Uses a per-character average width based on font size, adjusted
34
+ * for font weight. Accurate to within ~20% for Latin text in Inter.
35
+ *
36
+ * @param text - The text string to measure.
37
+ * @param fontSize - Font size in pixels.
38
+ * @param fontWeight - Font weight (100-900). Defaults to 400.
39
+ */
40
+ 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;
43
+ }
44
+
45
+ /**
46
+ * Estimate the rendered height of a text block.
47
+ *
48
+ * @param fontSize - Font size in pixels.
49
+ * @param lineCount - Number of lines. Defaults to 1.
50
+ * @param lineHeight - Line height multiplier. Defaults to 1.3.
51
+ */
52
+ export function estimateTextHeight(fontSize: number, lineCount = 1, lineHeight = 1.3): number {
53
+ return fontSize * lineHeight * lineCount;
54
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { abbreviateNumber, formatDate, formatNumber } from '../format';
3
+
4
+ describe('formatNumber', () => {
5
+ it('formats integers with commas', () => {
6
+ expect(formatNumber(1500000)).toBe('1,500,000');
7
+ });
8
+
9
+ it('formats decimals to 2 places', () => {
10
+ expect(formatNumber(42.567)).toBe('42.57');
11
+ });
12
+
13
+ it('handles zero', () => {
14
+ expect(formatNumber(0)).toBe('0');
15
+ });
16
+
17
+ it('handles negative numbers', () => {
18
+ // d3-format uses unicode minus sign (U+2212), not ASCII hyphen-minus
19
+ expect(formatNumber(-1234)).toBe('\u22121,234');
20
+ });
21
+
22
+ it('handles Infinity', () => {
23
+ expect(formatNumber(Infinity)).toBe('Infinity');
24
+ });
25
+
26
+ it('handles NaN', () => {
27
+ expect(formatNumber(NaN)).toBe('NaN');
28
+ });
29
+ });
30
+
31
+ describe('abbreviateNumber', () => {
32
+ it('abbreviates millions', () => {
33
+ expect(abbreviateNumber(1500000)).toBe('1.5M');
34
+ });
35
+
36
+ it('abbreviates billions', () => {
37
+ expect(abbreviateNumber(2300000000)).toBe('2.3B');
38
+ });
39
+
40
+ it('abbreviates thousands', () => {
41
+ expect(abbreviateNumber(2300)).toBe('2.3K');
42
+ });
43
+
44
+ it('drops trailing .0', () => {
45
+ expect(abbreviateNumber(1000000)).toBe('1M');
46
+ expect(abbreviateNumber(2000)).toBe('2K');
47
+ });
48
+
49
+ it('does not abbreviate small numbers', () => {
50
+ expect(abbreviateNumber(42)).toBe('42');
51
+ });
52
+
53
+ it('handles negative numbers', () => {
54
+ expect(abbreviateNumber(-1500000)).toBe('-1.5M');
55
+ });
56
+
57
+ it('abbreviates trillions', () => {
58
+ expect(abbreviateNumber(1_200_000_000_000)).toBe('1.2T');
59
+ });
60
+ });
61
+
62
+ describe('formatDate', () => {
63
+ it('formats a Date object', () => {
64
+ const result = formatDate(new Date('2020-06-15'), undefined, 'day');
65
+ expect(result).toContain('2020');
66
+ expect(result).toContain('Jun');
67
+ expect(result).toContain('15');
68
+ });
69
+
70
+ it('formats an ISO string', () => {
71
+ const result = formatDate('2020-01-01', undefined, 'year');
72
+ expect(result).toBe('2020');
73
+ });
74
+
75
+ it('formats quarters', () => {
76
+ const result = formatDate(new Date('2020-04-15'), undefined, 'quarter');
77
+ expect(result).toBe('Q2 2020');
78
+ });
79
+
80
+ it('formats months', () => {
81
+ const result = formatDate(new Date('2020-03-01'), undefined, 'month');
82
+ expect(result).toContain('Mar');
83
+ expect(result).toContain('2020');
84
+ });
85
+
86
+ it('handles invalid dates gracefully', () => {
87
+ const result = formatDate('not-a-date');
88
+ expect(result).toBe('not-a-date');
89
+ });
90
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Locale-aware number and date formatting utilities.
3
+ *
4
+ * Uses d3-format and d3-time-format for formatting,
5
+ * with convenience wrappers for common patterns.
6
+ */
7
+
8
+ import { format as d3Format } from 'd3-format';
9
+ import { timeFormat, utcFormat } from 'd3-time-format';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Number formatting
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /**
16
+ * Format a number with locale-appropriate separators.
17
+ *
18
+ * Uses d3-format under the hood. Default format: comma-separated
19
+ * with auto-precision (e.g. 1500000 -> "1,500,000").
20
+ *
21
+ * @param value - The number to format.
22
+ * @param locale - Locale string (currently unused, reserved for i18n).
23
+ */
24
+ export function formatNumber(value: number, _locale?: string): string {
25
+ if (!Number.isFinite(value)) return String(value);
26
+ // Auto-detect: use integer format for whole numbers, 2dp for decimals
27
+ if (Number.isInteger(value)) {
28
+ return d3Format(',')(value);
29
+ }
30
+ return d3Format(',.2f')(value);
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Number abbreviation
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Suffixes for abbreviated numbers. */
38
+ const ABBREVIATIONS: Array<{ threshold: number; suffix: string; divisor: number }> = [
39
+ { threshold: 1_000_000_000_000, suffix: 'T', divisor: 1_000_000_000_000 },
40
+ { threshold: 1_000_000_000, suffix: 'B', divisor: 1_000_000_000 },
41
+ { threshold: 1_000_000, suffix: 'M', divisor: 1_000_000 },
42
+ { threshold: 1_000, suffix: 'K', divisor: 1_000 },
43
+ ];
44
+
45
+ /**
46
+ * Abbreviate a large number with a suffix.
47
+ *
48
+ * Examples:
49
+ * - 1500000 -> "1.5M"
50
+ * - 2300 -> "2.3K"
51
+ * - 42 -> "42"
52
+ * - 1000000000 -> "1B"
53
+ */
54
+ export function abbreviateNumber(value: number): string {
55
+ if (!Number.isFinite(value)) return String(value);
56
+
57
+ const absValue = Math.abs(value);
58
+ const sign = value < 0 ? '-' : '';
59
+
60
+ for (const { threshold, suffix, divisor } of ABBREVIATIONS) {
61
+ if (absValue >= threshold) {
62
+ const abbreviated = absValue / divisor;
63
+ // Use .1f precision, drop trailing .0
64
+ const formatted = abbreviated % 1 === 0 ? String(abbreviated) : d3Format('.1f')(abbreviated);
65
+ return `${sign}${formatted}${suffix}`;
66
+ }
67
+ }
68
+
69
+ return formatNumber(value);
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Date formatting
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /** Granularity levels for date formatting. */
77
+ export type DateGranularity = 'year' | 'quarter' | 'month' | 'week' | 'day' | 'hour' | 'minute';
78
+
79
+ /** Format strings for each granularity level. */
80
+ const GRANULARITY_FORMATS: Record<DateGranularity, string> = {
81
+ year: '%Y',
82
+ quarter: '', // Quarter is always special-cased in formatDate() below
83
+ month: '%b %Y',
84
+ week: '%b %d',
85
+ day: '%b %d, %Y',
86
+ hour: '%b %d %H:%M',
87
+ minute: '%H:%M',
88
+ };
89
+
90
+ /**
91
+ * Format a date value for display.
92
+ *
93
+ * @param value - Date object, ISO string, or timestamp number.
94
+ * @param locale - Locale string (currently unused, reserved for i18n).
95
+ * @param granularity - Time granularity for format selection.
96
+ */
97
+ export function formatDate(
98
+ value: Date | string | number,
99
+ _locale?: string,
100
+ granularity?: DateGranularity,
101
+ ): string {
102
+ const date = value instanceof Date ? value : new Date(value);
103
+ if (Number.isNaN(date.getTime())) return String(value);
104
+
105
+ const gran = granularity ?? inferGranularity(date);
106
+
107
+ // Special handling for quarter (not a d3 format token)
108
+ if (gran === 'quarter') {
109
+ const q = Math.ceil((date.getMonth() + 1) / 3);
110
+ return `Q${q} ${date.getFullYear()}`;
111
+ }
112
+
113
+ const formatStr = GRANULARITY_FORMATS[gran];
114
+ // Use UTC format for year/month/day to avoid timezone shifts
115
+ if (['year', 'month', 'day'].includes(gran)) {
116
+ return utcFormat(formatStr)(date);
117
+ }
118
+ return timeFormat(formatStr)(date);
119
+ }
120
+
121
+ /**
122
+ * Infer the appropriate granularity from a date value.
123
+ * If time components are all zero, assume day or higher.
124
+ */
125
+ function inferGranularity(date: Date): DateGranularity {
126
+ if (date.getHours() !== 0 || date.getMinutes() !== 0) {
127
+ return date.getMinutes() !== 0 ? 'minute' : 'hour';
128
+ }
129
+ if (date.getDate() !== 1) return 'day';
130
+ if (date.getMonth() !== 0) return 'month';
131
+ return 'year';
132
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Locale module barrel export.
3
+ */
4
+
5
+ export type { DateGranularity } from './format';
6
+ export { abbreviateNumber, formatDate, formatNumber } from './format';
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getBreakpoint, getLayoutStrategy } from '../breakpoints';
3
+
4
+ describe('getBreakpoint', () => {
5
+ it('returns compact for widths below 400', () => {
6
+ expect(getBreakpoint(320)).toBe('compact');
7
+ expect(getBreakpoint(399)).toBe('compact');
8
+ });
9
+
10
+ it('returns medium for widths 400-700', () => {
11
+ expect(getBreakpoint(400)).toBe('medium');
12
+ expect(getBreakpoint(550)).toBe('medium');
13
+ expect(getBreakpoint(700)).toBe('medium');
14
+ });
15
+
16
+ it('returns full for widths above 700', () => {
17
+ expect(getBreakpoint(701)).toBe('full');
18
+ expect(getBreakpoint(1200)).toBe('full');
19
+ });
20
+
21
+ it('handles edge cases', () => {
22
+ expect(getBreakpoint(0)).toBe('compact');
23
+ expect(getBreakpoint(5000)).toBe('full');
24
+ });
25
+ });
26
+
27
+ describe('getLayoutStrategy', () => {
28
+ it('compact has no labels and minimal axes', () => {
29
+ const strategy = getLayoutStrategy('compact');
30
+ expect(strategy.labelMode).toBe('none');
31
+ expect(strategy.legendPosition).toBe('top');
32
+ expect(strategy.annotationPosition).toBe('tooltip-only');
33
+ expect(strategy.axisLabelDensity).toBe('minimal');
34
+ });
35
+
36
+ it('medium has important labels and reduced axes', () => {
37
+ const strategy = getLayoutStrategy('medium');
38
+ expect(strategy.labelMode).toBe('important');
39
+ expect(strategy.legendPosition).toBe('top');
40
+ expect(strategy.annotationPosition).toBe('inline');
41
+ expect(strategy.axisLabelDensity).toBe('reduced');
42
+ });
43
+
44
+ it('full has all labels and legend on right', () => {
45
+ const strategy = getLayoutStrategy('full');
46
+ expect(strategy.labelMode).toBe('all');
47
+ expect(strategy.legendPosition).toBe('right');
48
+ expect(strategy.annotationPosition).toBe('inline');
49
+ expect(strategy.axisLabelDensity).toBe('full');
50
+ });
51
+
52
+ it('different widths produce different strategies', () => {
53
+ const compact = getLayoutStrategy(getBreakpoint(320));
54
+ const full = getLayoutStrategy(getBreakpoint(800));
55
+ expect(compact.legendPosition).not.toBe(full.legendPosition);
56
+ expect(compact.labelMode).not.toBe(full.labelMode);
57
+ });
58
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Responsive breakpoints and layout strategies.
3
+ *
4
+ * Three breakpoints based on container width:
5
+ * - compact: < 400px (mobile, small embeds)
6
+ * - medium: 400-700px (tablet, sidebars)
7
+ * - full: > 700px (desktop, full-width)
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Breakpoint type and detection
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** Responsive breakpoint based on container width. */
15
+ export type Breakpoint = 'compact' | 'medium' | 'full';
16
+
17
+ /** Breakpoint thresholds in pixels. */
18
+ export const BREAKPOINT_COMPACT_MAX = 400;
19
+ export const BREAKPOINT_MEDIUM_MAX = 700;
20
+
21
+ /**
22
+ * Determine the breakpoint for a given container width.
23
+ */
24
+ export function getBreakpoint(width: number): Breakpoint {
25
+ if (width < BREAKPOINT_COMPACT_MAX) return 'compact';
26
+ if (width <= BREAKPOINT_MEDIUM_MAX) return 'medium';
27
+ return 'full';
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Layout strategy
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** Label display mode at a given breakpoint. */
35
+ export type LabelMode = 'all' | 'important' | 'none';
36
+
37
+ /** Legend position at a given breakpoint. */
38
+ export type LegendPosition = 'top' | 'right' | 'bottom' | 'bottom-right' | 'inline';
39
+
40
+ /** Annotation position strategy. */
41
+ export type AnnotationPosition = 'inline' | 'tooltip-only';
42
+
43
+ /** Axis label density (controls tick count reduction). */
44
+ export type AxisLabelDensity = 'full' | 'reduced' | 'minimal';
45
+
46
+ /**
47
+ * Layout strategy defining how the visualization adapts to available space.
48
+ * Returned by getLayoutStrategy() based on the current breakpoint.
49
+ */
50
+ export interface LayoutStrategy {
51
+ /** How data labels are displayed. */
52
+ labelMode: LabelMode;
53
+ /** Where the legend is positioned. */
54
+ legendPosition: LegendPosition;
55
+ /** How annotations are displayed. */
56
+ annotationPosition: AnnotationPosition;
57
+ /** Axis tick density. */
58
+ axisLabelDensity: AxisLabelDensity;
59
+ }
60
+
61
+ /**
62
+ * Get the layout strategy for a given breakpoint.
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.
67
+ */
68
+ export function getLayoutStrategy(breakpoint: Breakpoint): LayoutStrategy {
69
+ switch (breakpoint) {
70
+ case 'compact':
71
+ return {
72
+ labelMode: 'none',
73
+ legendPosition: 'top',
74
+ annotationPosition: 'tooltip-only',
75
+ axisLabelDensity: 'minimal',
76
+ };
77
+ case 'medium':
78
+ return {
79
+ labelMode: 'important',
80
+ legendPosition: 'top',
81
+ annotationPosition: 'inline',
82
+ axisLabelDensity: 'reduced',
83
+ };
84
+ case 'full':
85
+ return {
86
+ labelMode: 'all',
87
+ legendPosition: 'right',
88
+ annotationPosition: 'inline',
89
+ axisLabelDensity: 'full',
90
+ };
91
+ }
92
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Responsive module barrel export.
3
+ */
4
+
5
+ export type {
6
+ AnnotationPosition,
7
+ AxisLabelDensity,
8
+ Breakpoint,
9
+ LabelMode,
10
+ LayoutStrategy,
11
+ LegendPosition,
12
+ } from './breakpoints';
13
+ export {
14
+ BREAKPOINT_COMPACT_MAX,
15
+ BREAKPOINT_MEDIUM_MAX,
16
+ getBreakpoint,
17
+ getLayoutStrategy,
18
+ } from './breakpoints';