@opendata-ai/openchart-core 2.9.1 → 2.10.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": "2.9.1",
3
+ "version": "2.10.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
@@ -66,12 +66,15 @@ export type {
66
66
  AnnotationPosition,
67
67
  AxisLabelDensity,
68
68
  Breakpoint,
69
+ ChromeMode,
70
+ HeightClass,
69
71
  LabelMode,
70
72
  LayoutStrategy,
71
73
  LegendPosition,
72
74
  } from './responsive/index';
73
75
  export {
74
76
  getBreakpoint,
77
+ getHeightClass,
75
78
  getLayoutStrategy,
76
79
  } from './responsive/index';
77
80
 
@@ -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,
@@ -28,16 +37,32 @@ function normalizeChromeText(
28
37
  return { text: value.text, style: value.style, offset: value.offset };
29
38
  }
30
39
 
31
- /** Build a TextStyle from chrome defaults + optional overrides. */
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: overrides?.fontSize ?? defaults.fontSize,
65
+ fontSize,
41
66
  fontWeight: overrides?.fontWeight ?? defaults.fontWeight,
42
67
  fill: overrides?.color ?? textColor ?? defaults.color,
43
68
  lineHeight: defaults.lineHeight,
@@ -83,24 +108,28 @@ function estimateLineCount(
83
108
  * @param theme - The fully resolved theme.
84
109
  * @param width - Total available width in pixels.
85
110
  * @param measureText - Optional real text measurement function from the adapter.
111
+ * @param chromeMode - Chrome display mode: full, compact (title only), or hidden.
112
+ * @param padding - Override padding (for scaled padding from dimensions).
86
113
  */
87
114
  export function computeChrome(
88
115
  chrome: Chrome | undefined,
89
116
  theme: ResolvedTheme,
90
117
  width: number,
91
118
  measureText?: MeasureTextFn,
119
+ chromeMode: ChromeMode = 'full',
120
+ padding?: number,
92
121
  ): ResolvedChrome {
93
- if (!chrome) {
122
+ if (!chrome || chromeMode === 'hidden') {
94
123
  return { topHeight: 0, bottomHeight: 0 };
95
124
  }
96
125
 
97
- const padding = theme.spacing.padding;
126
+ const pad = padding ?? theme.spacing.padding;
98
127
  const chromeGap = theme.spacing.chromeGap;
99
- const maxWidth = width - padding * 2;
128
+ const maxWidth = width - pad * 2;
100
129
  const fontFamily = theme.fonts.family;
101
130
 
102
131
  // Track vertical cursor for top elements
103
- let topY = padding;
132
+ let topY = pad;
104
133
  const topElements: Partial<Pick<ResolvedChrome, 'title' | 'subtitle'>> = {};
105
134
 
106
135
  // Title
@@ -110,12 +139,13 @@ export function computeChrome(
110
139
  theme.chrome.title,
111
140
  fontFamily,
112
141
  theme.chrome.title.color,
142
+ width,
113
143
  titleNorm.style,
114
144
  );
115
145
  const lineCount = estimateLineCount(titleNorm.text, style, maxWidth, measureText);
116
146
  const element: ResolvedChromeElement = {
117
147
  text: titleNorm.text,
118
- x: padding + (titleNorm.offset?.dx ?? 0),
148
+ x: pad + (titleNorm.offset?.dx ?? 0),
119
149
  y: topY + (titleNorm.offset?.dy ?? 0),
120
150
  maxWidth,
121
151
  style,
@@ -124,19 +154,20 @@ export function computeChrome(
124
154
  topY += estimateTextHeight(style.fontSize, lineCount, style.lineHeight) + chromeGap;
125
155
  }
126
156
 
127
- // Subtitle
128
- const subtitleNorm = normalizeChromeText(chrome.subtitle);
157
+ // Subtitle (hidden in compact mode)
158
+ const subtitleNorm = chromeMode === 'compact' ? null : normalizeChromeText(chrome.subtitle);
129
159
  if (subtitleNorm) {
130
160
  const style = buildTextStyle(
131
161
  theme.chrome.subtitle,
132
162
  fontFamily,
133
163
  theme.chrome.subtitle.color,
164
+ width,
134
165
  subtitleNorm.style,
135
166
  );
136
167
  const lineCount = estimateLineCount(subtitleNorm.text, style, maxWidth, measureText);
137
168
  const element: ResolvedChromeElement = {
138
169
  text: subtitleNorm.text,
139
- x: padding + (subtitleNorm.offset?.dx ?? 0),
170
+ x: pad + (subtitleNorm.offset?.dx ?? 0),
140
171
  y: topY + (subtitleNorm.offset?.dy ?? 0),
141
172
  maxWidth,
142
173
  style,
@@ -147,10 +178,18 @@ export function computeChrome(
147
178
 
148
179
  // Add chromeToChart gap if there are any top elements
149
180
  const hasTopChrome = titleNorm || subtitleNorm;
150
- const topHeight = hasTopChrome ? topY - padding + theme.spacing.chromeToChart - chromeGap : 0;
181
+ const topHeight = hasTopChrome ? topY - pad + theme.spacing.chromeToChart - chromeGap : 0;
182
+
183
+ // Bottom elements hidden in compact mode
184
+ if (chromeMode === 'compact') {
185
+ return {
186
+ topHeight,
187
+ bottomHeight: 0,
188
+ ...topElements,
189
+ };
190
+ }
151
191
 
152
192
  // Bottom elements: source, byline, footer
153
- // We compute heights bottom-up but position them after knowing total
154
193
  const bottomElements: Partial<Pick<ResolvedChrome, 'source' | 'byline' | 'footer'>> = {};
155
194
  let bottomHeight = 0;
156
195
 
@@ -191,7 +230,13 @@ export function computeChrome(
191
230
  bottomHeight += theme.spacing.chartToFooter;
192
231
 
193
232
  for (const item of bottomItems) {
194
- const style = buildTextStyle(item.defaults, fontFamily, item.defaults.color, item.norm.style);
233
+ const style = buildTextStyle(
234
+ item.defaults,
235
+ fontFamily,
236
+ item.defaults.color,
237
+ width,
238
+ item.norm.style,
239
+ );
195
240
  const lineCount = estimateLineCount(item.norm.text, style, maxWidth, measureText);
196
241
  const height = estimateTextHeight(style.fontSize, lineCount, style.lineHeight);
197
242
 
@@ -199,7 +244,7 @@ export function computeChrome(
199
244
  // chart area by the engine. We store offsets from bottom start.
200
245
  bottomElements[item.key] = {
201
246
  text: item.norm.text,
202
- x: padding + (item.norm.offset?.dx ?? 0),
247
+ x: pad + (item.norm.offset?.dx ?? 0),
203
248
  y: bottomHeight + (item.norm.offset?.dy ?? 0), // offset from where bottom chrome starts
204
249
  maxWidth,
205
250
  style,
@@ -211,7 +256,7 @@ export function computeChrome(
211
256
  // Remove trailing gap
212
257
  bottomHeight -= chromeGap;
213
258
  // Add bottom padding
214
- bottomHeight += padding;
259
+ bottomHeight += pad;
215
260
  }
216
261
 
217
262
  return {
@@ -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
- * Three breakpoints based on container width:
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
- // Breakpoint type and detection
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 the current breakpoint.
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 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.
94
+ * Get the base layout strategy for a width breakpoint.
67
95
  */
68
- export function getLayoutStrategy(breakpoint: Breakpoint): LayoutStrategy {
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
+ }
@@ -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';
@@ -394,6 +394,8 @@ export interface LegendEntry {
394
394
  shape: 'circle' | 'square' | 'line';
395
395
  /** Whether this entry is currently highlighted/active. */
396
396
  active?: boolean;
397
+ /** True for overflow indicator entries ("+N more"). Not interactive. */
398
+ overflow?: boolean;
397
399
  }
398
400
 
399
401
  /** Resolved legend layout with position and entries. */