@internetstiftelsen/charts 0.13.3 → 0.14.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.
@@ -1,7 +1,9 @@
1
1
  import { BaseChart } from './base-chart.js';
2
2
  import { DEFAULT_CHART_HEIGHT } from './theme.js';
3
+ import { measureTextWidth, truncateText, wrapText } from './utils.js';
3
4
  const TOOLTIP_OFFSET_PX = 12;
4
5
  const EDGE_MARGIN_PX = 10;
6
+ const DEFAULT_LABEL_FONT_SIZE = 14;
5
7
  export class RadialChartBase extends BaseChart {
6
8
  initializeTooltip() {
7
9
  this.tooltip?.initialize(this.renderTheme);
@@ -27,6 +29,119 @@ export class RadialChartBase extends BaseChart {
27
29
  fill: item.color,
28
30
  }));
29
31
  }
32
+ renderRadialLabelText(textElement, text, options, verticalAnchor = 'middle') {
33
+ const layout = this.resolveRadialLabelLayout(text, options);
34
+ textElement.text(null).style('visibility', null);
35
+ if (layout.hidden) {
36
+ textElement.text(text).style('visibility', 'hidden');
37
+ return;
38
+ }
39
+ if (layout.lines.length === 1) {
40
+ textElement.text(layout.lines[0]);
41
+ this.addRadialLabelTitle(textElement, layout.titleText);
42
+ return;
43
+ }
44
+ const lineHeight = this.resolveRadialLabelLineHeight(options.fontSize);
45
+ const startDy = verticalAnchor === 'middle'
46
+ ? -((layout.lines.length - 1) * lineHeight) / 2
47
+ : 0;
48
+ const x = textElement.attr('x') ?? '0';
49
+ layout.lines.forEach((line, index) => {
50
+ textElement
51
+ .append('tspan')
52
+ .attr('x', x)
53
+ .attr('dy', index === 0 ? `${startDy}px` : `${lineHeight}px`)
54
+ .text(line);
55
+ });
56
+ this.addRadialLabelTitle(textElement, layout.titleText);
57
+ }
58
+ renderRadialStructuredLabelText(textElement, labelText, valueText, separator, options, verticalAnchor = 'middle') {
59
+ const layout = this.resolveRadialLabelLayout(labelText, options);
60
+ const fullText = `${labelText}${separator}${valueText}`;
61
+ textElement.text(null).style('visibility', null);
62
+ if (layout.hidden) {
63
+ textElement.text(fullText).style('visibility', 'hidden');
64
+ return;
65
+ }
66
+ if (layout.lines.length === 1) {
67
+ textElement.text(`${layout.lines[0]}${separator}${valueText}`);
68
+ this.addRadialLabelTitle(textElement, layout.titleText ? fullText : null);
69
+ return;
70
+ }
71
+ const lineHeight = this.resolveRadialLabelLineHeight(options.fontSize);
72
+ const startDy = verticalAnchor === 'middle'
73
+ ? -((layout.lines.length - 1) * lineHeight) / 2
74
+ : 0;
75
+ const x = textElement.attr('x') ?? '0';
76
+ const lastLineIndex = layout.lines.length - 1;
77
+ layout.lines.forEach((line, index) => {
78
+ const renderedLine = index === lastLineIndex
79
+ ? `${line}${separator}${valueText}`
80
+ : line;
81
+ textElement
82
+ .append('tspan')
83
+ .attr('x', x)
84
+ .attr('dy', index === 0 ? `${startDy}px` : `${lineHeight}px`)
85
+ .text(renderedLine);
86
+ });
87
+ this.addRadialLabelTitle(textElement, layout.titleText ? fullText : null);
88
+ }
89
+ measureRadialLabelDimensions(text, options) {
90
+ const layout = this.resolveRadialLabelLayout(text, options);
91
+ if (layout.hidden) {
92
+ return {
93
+ width: 0,
94
+ height: 0,
95
+ };
96
+ }
97
+ const svgNode = this.svg?.node();
98
+ const fontWeight = String(options.fontWeight);
99
+ const width = svgNode
100
+ ? layout.lines.reduce((maxWidth, line) => {
101
+ return Math.max(maxWidth, measureTextWidth(line, options.fontSize, options.fontFamily, fontWeight, svgNode));
102
+ }, 0)
103
+ : (options.maxLabelWidth ?? 0);
104
+ return {
105
+ width,
106
+ height: Math.max(layout.lines.length, 1) *
107
+ this.resolveRadialLabelLineHeight(options.fontSize),
108
+ };
109
+ }
110
+ measureRadialStructuredLabelDimensions(labelText, valueText, separator, options) {
111
+ const layout = this.resolveRadialLabelLayout(labelText, options);
112
+ if (layout.hidden) {
113
+ return {
114
+ width: 0,
115
+ height: 0,
116
+ };
117
+ }
118
+ const svgNode = this.svg?.node();
119
+ const fontWeight = String(options.fontWeight);
120
+ const lastLineIndex = layout.lines.length - 1;
121
+ const width = svgNode
122
+ ? layout.lines.reduce((maxWidth, line, index) => {
123
+ const renderedLine = index === lastLineIndex
124
+ ? `${line}${separator}${valueText}`
125
+ : line;
126
+ return Math.max(maxWidth, measureTextWidth(renderedLine, options.fontSize, options.fontFamily, fontWeight, svgNode));
127
+ }, 0)
128
+ : (options.maxLabelWidth ?? 0);
129
+ return {
130
+ width,
131
+ height: Math.max(layout.lines.length, 1) *
132
+ this.resolveRadialLabelLineHeight(options.fontSize),
133
+ };
134
+ }
135
+ resolveRadialLabelLineHeight(fontSize) {
136
+ if (typeof fontSize === 'number' && Number.isFinite(fontSize)) {
137
+ return fontSize * 1.2;
138
+ }
139
+ const parsedFontSize = Number.parseFloat(String(fontSize));
140
+ if (Number.isFinite(parsedFontSize)) {
141
+ return parsedFontSize * 1.2;
142
+ }
143
+ return DEFAULT_LABEL_FONT_SIZE * 1.2;
144
+ }
30
145
  showTooltipFromPointer(event, content) {
31
146
  if (!this.tooltip) {
32
147
  return;
@@ -84,6 +199,72 @@ export class RadialChartBase extends BaseChart {
84
199
  const y = Math.max(EDGE_MARGIN_PX, Math.min(rawY, window.innerHeight + window.scrollY - height - EDGE_MARGIN_PX));
85
200
  this.tooltip?.showAt(x, y);
86
201
  }
202
+ resolveRadialLabelLayout(text, options) {
203
+ const overflowContext = this.resolveRadialLabelOverflowContext(options.maxLabelWidth);
204
+ if (!overflowContext) {
205
+ return this.createVisibleRadialLabelLayout(text);
206
+ }
207
+ const { maxLabelWidth, svgNode } = overflowContext;
208
+ const fontWeight = String(options.fontWeight);
209
+ const textWidth = measureTextWidth(text, options.fontSize, options.fontFamily, fontWeight, svgNode);
210
+ if (textWidth <= maxLabelWidth) {
211
+ return this.createVisibleRadialLabelLayout(text);
212
+ }
213
+ return this.resolveOverflowingRadialLabelLayout(text, options, maxLabelWidth, fontWeight, svgNode);
214
+ }
215
+ resolveRadialLabelOverflowContext(maxLabelWidth) {
216
+ const svgNode = this.svg?.node();
217
+ if (maxLabelWidth === undefined ||
218
+ !Number.isFinite(maxLabelWidth) ||
219
+ maxLabelWidth <= 0 ||
220
+ !svgNode) {
221
+ return null;
222
+ }
223
+ return { maxLabelWidth, svgNode };
224
+ }
225
+ createVisibleRadialLabelLayout(text) {
226
+ return {
227
+ lines: [text],
228
+ hidden: false,
229
+ titleText: null,
230
+ };
231
+ }
232
+ resolveOverflowingRadialLabelLayout(text, options, maxLabelWidth, fontWeight, svgNode) {
233
+ if (options.oversizedBehavior === 'hide') {
234
+ if (options.forceVisible) {
235
+ return {
236
+ lines: [text],
237
+ hidden: false,
238
+ titleText: null,
239
+ };
240
+ }
241
+ return {
242
+ lines: [text],
243
+ hidden: true,
244
+ titleText: null,
245
+ };
246
+ }
247
+ if (options.oversizedBehavior === 'wrap') {
248
+ const lines = wrapText(text, maxLabelWidth, options.fontSize, options.fontFamily, fontWeight, svgNode);
249
+ return {
250
+ lines,
251
+ hidden: false,
252
+ titleText: lines.length > 1 ? text : null,
253
+ };
254
+ }
255
+ const result = truncateText(text, maxLabelWidth, options.fontSize, options.fontFamily, fontWeight, svgNode);
256
+ return {
257
+ lines: [result.text],
258
+ hidden: false,
259
+ titleText: result.truncated ? text : null,
260
+ };
261
+ }
262
+ addRadialLabelTitle(textElement, text) {
263
+ if (!text) {
264
+ return;
265
+ }
266
+ textElement.append('title').text(text);
267
+ }
87
268
  resolveRadialFontScale(outerRadius, theme) {
88
269
  const referenceHeight = this.configuredHeight ?? DEFAULT_CHART_HEIGHT;
89
270
  const plotHeight = Math.max(1, referenceHeight - theme.margins.top - theme.margins.bottom);
package/dist/scatter.js CHANGED
@@ -183,6 +183,7 @@ export class Scatter {
183
183
  const border = config.border ?? theme.valueLabel.border;
184
184
  const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
185
185
  const padding = config.padding ?? theme.valueLabel.padding;
186
+ const forceVisible = config.forceVisible === true;
186
187
  const labelGroup = plotGroup
187
188
  .append('g')
188
189
  .attr('class', `scatter-value-labels-${sanitizeForCSS(this.dataKey)}`);
@@ -211,7 +212,7 @@ export class Scatter {
211
212
  if (labelY - boxHeight / 2 < plotTop + 4) {
212
213
  labelY = yPos + boxHeight / 2 + pointSize + 4;
213
214
  if (labelY + boxHeight / 2 > plotBottom - 4) {
214
- shouldRender = false;
215
+ shouldRender = forceVisible;
215
216
  }
216
217
  }
217
218
  tempText.remove();
package/dist/theme.d.ts CHANGED
@@ -1,11 +1,26 @@
1
1
  import { type ChartTheme, type ResponsiveConfig } from './types.js';
2
+ export declare const RUBY_COLOR_PALETTE: string[];
3
+ export declare const PEACOCK_COLOR_PALETTE: string[];
4
+ export declare const JADE_COLOR_PALETTE: string[];
5
+ export declare const LEMON_COLOR_PALETTE: string[];
6
+ export declare const OCEAN_COLOR_PALETTE: string[];
2
7
  export declare const DEFAULT_COLOR_PALETTE: string[];
3
8
  export declare const DEFAULT_CHART_WIDTH = 928;
4
9
  export declare const DEFAULT_CHART_HEIGHT = 600;
5
10
  export declare const defaultTheme: ChartTheme;
11
+ export declare const rubyTheme: ChartTheme;
12
+ export declare const peacockTheme: ChartTheme;
13
+ export declare const jadeTheme: ChartTheme;
14
+ export declare const lemonTheme: ChartTheme;
15
+ export declare const oceanTheme: ChartTheme;
6
16
  export declare const newspaperTheme: ChartTheme;
7
17
  export declare const defaultResponsiveConfig: ResponsiveConfig;
8
18
  export declare const themes: {
9
19
  default: ChartTheme;
20
+ ruby: ChartTheme;
21
+ peacock: ChartTheme;
22
+ jade: ChartTheme;
23
+ lemon: ChartTheme;
24
+ ocean: ChartTheme;
10
25
  newspaper: ChartTheme;
11
26
  };
package/dist/theme.js CHANGED
@@ -1,3 +1,39 @@
1
+ const SYSTEM_FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"';
2
+ export const RUBY_COLOR_PALETTE = [
3
+ '#ff4069',
4
+ '#c51f46',
5
+ '#ff7f99',
6
+ '#ffb3c2',
7
+ '#7f102d',
8
+ ];
9
+ export const PEACOCK_COLOR_PALETTE = [
10
+ '#c27fec',
11
+ '#934bc5',
12
+ '#d5a4f3',
13
+ '#ead2fa',
14
+ '#5f287f',
15
+ ];
16
+ export const JADE_COLOR_PALETTE = [
17
+ '#55c7b4',
18
+ '#2f8f80',
19
+ '#88dacd',
20
+ '#c4eee8',
21
+ '#1b5f55',
22
+ ];
23
+ export const LEMON_COLOR_PALETTE = [
24
+ '#ffce2e',
25
+ '#c89200',
26
+ '#ffe07a',
27
+ '#fff0b8',
28
+ '#7a5900',
29
+ ];
30
+ export const OCEAN_COLOR_PALETTE = [
31
+ '#50b2fc',
32
+ '#147eca',
33
+ '#8bcbfd',
34
+ '#c6e6fe',
35
+ '#0f4f7f',
36
+ ];
1
37
  export const DEFAULT_COLOR_PALETTE = [
2
38
  '#50b2fc', // ocean
3
39
  '#ff4069', // ruby
@@ -11,7 +47,7 @@ export const DEFAULT_COLOR_PALETTE = [
11
47
  export const DEFAULT_CHART_WIDTH = 928;
12
48
  export const DEFAULT_CHART_HEIGHT = 600;
13
49
  export const defaultTheme = {
14
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
50
+ fontFamily: SYSTEM_FONT,
15
51
  margins: {
16
52
  top: 20,
17
53
  right: 20,
@@ -24,7 +60,7 @@ export const defaultTheme = {
24
60
  },
25
61
  colorPalette: [...DEFAULT_COLOR_PALETTE],
26
62
  axis: {
27
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
63
+ fontFamily: SYSTEM_FONT,
28
64
  fontSize: 14,
29
65
  fontWeight: 'normal',
30
66
  groupLabel: {
@@ -72,7 +108,7 @@ export const defaultTheme = {
72
108
  background: '#ffffff',
73
109
  border: '#dddddd',
74
110
  color: '#1f2a36',
75
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
111
+ fontFamily: SYSTEM_FONT,
76
112
  fontSize: 12,
77
113
  fontWeight: 'normal',
78
114
  },
@@ -86,7 +122,7 @@ export const defaultTheme = {
86
122
  },
87
123
  valueLabel: {
88
124
  fontSize: 12,
89
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
125
+ fontFamily: SYSTEM_FONT,
90
126
  fontWeight: '600',
91
127
  color: '#1f2a36',
92
128
  background: '#ffffff',
@@ -117,6 +153,51 @@ export const defaultTheme = {
117
153
  },
118
154
  },
119
155
  };
156
+ function cloneTheme(theme) {
157
+ return {
158
+ ...theme,
159
+ margins: { ...theme.margins },
160
+ grid: { ...theme.grid },
161
+ colorPalette: [...theme.colorPalette],
162
+ axis: {
163
+ ...theme.axis,
164
+ groupLabel: theme.axis.groupLabel
165
+ ? { ...theme.axis.groupLabel }
166
+ : undefined,
167
+ },
168
+ legend: { ...theme.legend },
169
+ text: {
170
+ variants: Object.fromEntries(Object.entries(theme.text.variants).map(([name, style]) => [
171
+ name,
172
+ { ...style },
173
+ ])),
174
+ },
175
+ tooltip: { ...theme.tooltip },
176
+ line: {
177
+ ...theme.line,
178
+ point: { ...theme.line.point },
179
+ },
180
+ valueLabel: { ...theme.valueLabel },
181
+ donut: {
182
+ ...theme.donut,
183
+ centerContent: {
184
+ mainValue: { ...theme.donut.centerContent.mainValue },
185
+ title: { ...theme.donut.centerContent.title },
186
+ subtitle: { ...theme.donut.centerContent.subtitle },
187
+ },
188
+ },
189
+ };
190
+ }
191
+ function createAccentTheme(colorPalette) {
192
+ const theme = cloneTheme(defaultTheme);
193
+ theme.colorPalette = [...colorPalette];
194
+ return theme;
195
+ }
196
+ export const rubyTheme = createAccentTheme(RUBY_COLOR_PALETTE);
197
+ export const peacockTheme = createAccentTheme(PEACOCK_COLOR_PALETTE);
198
+ export const jadeTheme = createAccentTheme(JADE_COLOR_PALETTE);
199
+ export const lemonTheme = createAccentTheme(LEMON_COLOR_PALETTE);
200
+ export const oceanTheme = createAccentTheme(OCEAN_COLOR_PALETTE);
120
201
  export const newspaperTheme = {
121
202
  fontFamily: 'Georgia, "Times New Roman", Times, serif',
122
203
  margins: {
@@ -261,5 +342,10 @@ export const defaultResponsiveConfig = {
261
342
  };
262
343
  export const themes = {
263
344
  default: defaultTheme,
345
+ ruby: rubyTheme,
346
+ peacock: peacockTheme,
347
+ jade: jadeTheme,
348
+ lemon: lemonTheme,
349
+ ocean: oceanTheme,
264
350
  newspaper: newspaperTheme,
265
351
  };
package/dist/types.d.ts CHANGED
@@ -180,6 +180,7 @@ export type ValueLabelConfig = {
180
180
  borderRadius?: number;
181
181
  padding?: number;
182
182
  formatter?: SeriesValueFormatter;
183
+ forceVisible?: boolean;
183
184
  };
184
185
  export type LineValueLabelConfig = ValueLabelConfig & {
185
186
  show?: boolean;
@@ -1,8 +1,10 @@
1
1
  import { ChartValidationError, ChartValidator } from '../validation.js';
2
2
  import { easeBounceOut, easeCubicIn, easeCubicInOut, easeCubicOut, easeElasticOut, easeLinear, } from 'd3';
3
+ import { createCubicBezierEasing } from '../easing.js';
3
4
  const DEFAULT_ANIMATE = false;
4
5
  const DEFAULT_ANIMATION_DURATION_MS = 700;
5
6
  const DEFAULT_ANIMATION_EASING_PRESET = 'ease-in-out';
7
+ const easeSpringOut = createCubicBezierEasing(0.85, 0, 0.15, 1);
6
8
  const XY_ANIMATION_EASING_PRESETS = {
7
9
  linear: easeLinear,
8
10
  'ease-in': easeCubicIn,
@@ -10,6 +12,7 @@ const XY_ANIMATION_EASING_PRESETS = {
10
12
  'ease-in-out': easeCubicInOut,
11
13
  'bounce-out': easeBounceOut,
12
14
  'elastic-out': easeElasticOut,
15
+ 'spring-out': easeSpringOut,
13
16
  };
14
17
  export function normalizeXYAnimationConfig(config) {
15
18
  if (config === undefined) {
@@ -1,5 +1,5 @@
1
1
  import type { Selection } from 'd3';
2
- export type XYAnimationEasingPreset = 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'bounce-out' | 'elastic-out';
2
+ export type XYAnimationEasingPreset = 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'bounce-out' | 'elastic-out' | 'spring-out';
3
3
  export type XYAnimationConfig = {
4
4
  show?: boolean;
5
5
  duration?: number;
@@ -10,17 +10,18 @@ new DonutChart(config: DonutChartConfig)
10
10
 
11
11
  ### Config Options
12
12
 
13
- | Option | Type | Default | Description |
14
- | ------------ | ------------------------- | --------- | --------------------------------------------------------------------- |
15
- | `data` | `DataItem[]` | required | Array of data objects |
16
- | `width` | `number` | - | Explicit chart width in pixels |
17
- | `height` | `number` | - | Explicit chart height in pixels |
18
- | `valueKey` | `string` | `'value'` | Key for numeric values in data |
19
- | `labelKey` | `string` | `'name'` | Key for segment labels in data |
20
- | `donut` | `DonutConfig` | - | Donut-specific configuration |
21
- | `valueLabel` | `DonutValueLabelConfig` | - | On-chart outside label/value rendering configuration |
22
- | `theme` | `DeepPartial<ChartTheme>` | - | Theme customization |
23
- | `responsive` | `ResponsiveConfig` | - | Declarative container-query responsive overrides (theme + components) |
13
+ | Option | Type | Default | Description |
14
+ | ------------ | --------------------------------- | --------- | --------------------------------------------------------------------- |
15
+ | `data` | `DataItem[]` | required | Array of data objects |
16
+ | `width` | `number` | - | Explicit chart width in pixels |
17
+ | `height` | `number` | - | Explicit chart height in pixels |
18
+ | `valueKey` | `string` | `'value'` | Key for numeric values in data |
19
+ | `labelKey` | `string` | `'name'` | Key for segment labels in data |
20
+ | `donut` | `DonutConfig` | - | Donut-specific configuration |
21
+ | `valueLabel` | `DonutValueLabelConfig` | - | On-chart outside label/value rendering configuration |
22
+ | `animate` | `boolean \| DonutAnimationConfig` | `false` | Opt-in segment animation for initial render and `update()` |
23
+ | `theme` | `DeepPartial<ChartTheme>` | - | Theme customization |
24
+ | `responsive` | `ResponsiveConfig` | - | Declarative container-query responsive overrides (theme + components) |
24
25
 
25
26
  ### Donut Config
26
27
 
@@ -40,13 +41,50 @@ valueLabel: {
40
41
  position?: 'outside' | 'auto', // default: 'auto'
41
42
  outsideOffset?: number, // default: 16
42
43
  minVerticalSpacing?: number, // default: 14
43
- formatter?: (label, value, data, percentage) => string, // default: `${label}: ${value}`
44
+ maxLabelWidth?: number,
45
+ oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // default: 'truncate'
46
+ forceVisible?: boolean, // default: false
47
+ labelFormatter?: (label, value, data, percentage) => string,
48
+ valueFormatter?: (label, value, data, percentage) => string,
49
+ separator?: string, // default: ': '
50
+ formatter?: (label, value, data, percentage) => string,
44
51
  }
45
52
  ```
46
53
 
47
54
  Donut value labels are rendered outside the ring with leader lines. `auto`
48
55
  currently resolves to the same outside placement as `outside`.
49
56
 
57
+ By default, donut value labels render as `{label}: {value}`. Use
58
+ `labelFormatter`, `valueFormatter`, and `separator` to customize those parts
59
+ while keeping the label and value structurally separate. When `maxLabelWidth` is
60
+ set, `oversizedBehavior` applies to the label part only, so long labels can be
61
+ truncated, wrapped, or hidden without truncating the value. The `percentage`
62
+ argument is the computed segment share from `0` to `100`.
63
+
64
+ Use `formatter` for full custom label text. When `formatter` is provided, the
65
+ returned string is treated as a single label and overflow behavior applies to
66
+ that whole string.
67
+
68
+ Set `forceVisible: true` to keep value labels rendered when
69
+ `oversizedBehavior: 'hide'` would normally hide them.
70
+
71
+ ### Animation Config
72
+
73
+ ```typescript
74
+ animate: boolean | {
75
+ show?: boolean, // default: true when object is provided
76
+ duration?: number, // default: 700
77
+ easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' |
78
+ 'bounce-out' | 'elastic-out' | 'spring-out' |
79
+ `linear(${string})` | ((progress: number) => number),
80
+ }
81
+ ```
82
+
83
+ Animation is off by default. When enabled, segments grow from zero length on the
84
+ first render and animate from their previous geometry on `chart.update(...)` and
85
+ legend visibility changes. Use `chart.whenReady()` when surrounding UI or tests
86
+ need to wait until the current animation has finished.
87
+
50
88
  ## Example
51
89
 
52
90
  ```javascript
@@ -73,8 +111,13 @@ const chart = new DonutChart({
73
111
  },
74
112
  valueLabel: {
75
113
  show: true,
76
- formatter: (label, _value, _data, percentage) =>
77
- `${label}: ${percentage.toFixed(1)}%`,
114
+ formatter: (label, _value, _data, percentage) => {
115
+ return `${label}: ${percentage.toFixed(1)}%`;
116
+ },
117
+ },
118
+ animate: {
119
+ duration: 700,
120
+ easing: 'ease-in-out',
78
121
  },
79
122
  });
80
123
 
@@ -40,6 +40,7 @@ gauge: {
40
40
  | 'ease-in-out'
41
41
  | 'bounce-out'
42
42
  | 'elastic-out'
43
+ | 'spring-out'
43
44
  | `linear(...)` // CSS-like piecewise linear easing
44
45
  | ((t: number) => number),
45
46
  },
@@ -60,6 +61,9 @@ gauge: {
60
61
  fontFamily?: string, // default: theme.axis.fontFamily
61
62
  fontWeight?: number | string, // default: 700
62
63
  color?: string, // default: #111827
64
+ maxLabelWidth?: number,
65
+ oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // default: 'truncate'
66
+ forceVisible?: boolean, // default: false
63
67
  },
64
68
  needle?: boolean | {
65
69
  show?: boolean, // default: true
@@ -86,6 +90,9 @@ gauge: {
86
90
  fontFamily?: string, // default: theme.axis.fontFamily
87
91
  fontWeight?: number | string, // default: theme.axis.fontWeight
88
92
  color?: string, // default: #4b5563
93
+ maxLabelWidth?: number,
94
+ oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // default: 'truncate'
95
+ forceVisible?: boolean, // default: false
89
96
  },
90
97
  },
91
98
  segments?: [
@@ -99,6 +106,11 @@ gauge: {
99
106
  }
100
107
  ```
101
108
 
109
+ Set `maxLabelWidth` on `valueLabelStyle` or `ticks.labelStyle` to cap rendered
110
+ label width. `oversizedBehavior` controls whether labels over that cap are
111
+ truncated, wrapped, or hidden. Set `forceVisible: true` to keep labels rendered
112
+ when `oversizedBehavior: 'hide'` would normally hide them.
113
+
102
114
  ## Example
103
115
 
104
116
  ```javascript
@@ -151,6 +163,8 @@ chart.render('#gauge-container');
151
163
 
152
164
  ## Animation Easing Example
153
165
 
166
+ `spring-out` is a duration-based cubic-bezier preset.
167
+
154
168
  ```typescript
155
169
  const chart = new GaugeChart({
156
170
  data: [{ value: 72 }],
package/docs/pie-chart.md CHANGED
@@ -10,17 +10,18 @@ new PieChart(config: PieChartConfig)
10
10
 
11
11
  ### Config Options
12
12
 
13
- | Option | Type | Default | Description |
14
- | ------------ | ------------------------- | --------- | --------------------------------------------------------------------- |
15
- | `data` | `DataItem[]` | required | Array of data objects |
16
- | `width` | `number` | - | Explicit chart width in pixels |
17
- | `height` | `number` | - | Explicit chart height in pixels |
18
- | `valueKey` | `string` | `'value'` | Key for numeric values in data |
19
- | `labelKey` | `string` | `'name'` | Key for segment labels in data |
20
- | `pie` | `PieConfig` | - | Pie-specific configuration |
21
- | `valueLabel` | `PieValueLabelConfig` | - | On-chart slice label/value rendering configuration |
22
- | `theme` | `DeepPartial<ChartTheme>` | - | Theme customization |
23
- | `responsive` | `ResponsiveConfig` | - | Declarative container-query responsive overrides (theme + components) |
13
+ | Option | Type | Default | Description |
14
+ | ------------ | ------------------------------- | --------- | --------------------------------------------------------------------- |
15
+ | `data` | `DataItem[]` | required | Array of data objects |
16
+ | `width` | `number` | - | Explicit chart width in pixels |
17
+ | `height` | `number` | - | Explicit chart height in pixels |
18
+ | `valueKey` | `string` | `'value'` | Key for numeric values in data |
19
+ | `labelKey` | `string` | `'name'` | Key for segment labels in data |
20
+ | `pie` | `PieConfig` | - | Pie-specific configuration |
21
+ | `valueLabel` | `PieValueLabelConfig` | - | On-chart slice label/value rendering configuration |
22
+ | `animate` | `boolean \| PieAnimationConfig` | `false` | Opt-in slice animation for initial render and `update()` |
23
+ | `theme` | `DeepPartial<ChartTheme>` | - | Theme customization |
24
+ | `responsive` | `ResponsiveConfig` | - | Declarative container-query responsive overrides (theme + components) |
24
25
 
25
26
  ### Pie Config
26
27
 
@@ -45,11 +46,46 @@ valueLabel: {
45
46
  outsideOffset?: number, // default: 16
46
47
  insideMargin?: number, // default: 8
47
48
  minVerticalSpacing?: number, // default: 14
48
- formatter?: (label, value, data, percentage) => string, // default: `${label}: ${value}`
49
+ maxLabelWidth?: number,
50
+ oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // default: 'truncate'
51
+ forceVisible?: boolean, // default: false
52
+ labelFormatter?: (label, value, data, percentage) => string,
53
+ valueFormatter?: (label, value, data, percentage) => string,
54
+ separator?: string, // default: ': '
55
+ formatter?: (label, value, data, percentage) => string,
49
56
  }
50
57
  ```
51
58
 
52
- Rendered pie value-label text defaults to `{label}: {value}` and can be customized with `valueLabel.formatter`. The `percentage` argument is the computed slice share from `0` to `100`.
59
+ Rendered pie value-label text defaults to `{label}: {value}`. Use
60
+ `labelFormatter`, `valueFormatter`, and `separator` to customize those parts
61
+ while keeping the label and value structurally separate. When `maxLabelWidth` is
62
+ set, `oversizedBehavior` applies to the label part only, so long labels can be
63
+ truncated, wrapped, or hidden without truncating the value. The `percentage`
64
+ argument is the computed slice share from `0` to `100`.
65
+
66
+ Use `formatter` for full custom label text. When `formatter` is provided, the
67
+ returned string is treated as a single label and overflow behavior applies to
68
+ that whole string.
69
+
70
+ Set `forceVisible: true` to keep value labels rendered when inside-fit checks or
71
+ `oversizedBehavior: 'hide'` would normally hide them.
72
+
73
+ ### Animation Config
74
+
75
+ ```typescript
76
+ animate: boolean | {
77
+ show?: boolean, // default: true when object is provided
78
+ duration?: number, // default: 700
79
+ easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' |
80
+ 'bounce-out' | 'elastic-out' | 'spring-out' |
81
+ `linear(${string})` | ((progress: number) => number),
82
+ }
83
+ ```
84
+
85
+ Animation is off by default. When enabled, slices grow from zero length on the
86
+ first render and animate from their previous geometry on `chart.update(...)` and
87
+ legend visibility changes. Use `chart.whenReady()` when surrounding UI or tests
88
+ need to wait until the current animation has finished.
53
89
 
54
90
  ## Example
55
91
 
@@ -76,8 +112,13 @@ const chart = new PieChart({
76
112
  valueLabel: {
77
113
  show: true,
78
114
  position: 'auto',
79
- formatter: (label, _value, _data, percentage) =>
80
- `${label}: ${percentage.toFixed(1)}%`,
115
+ formatter: (label, _value, _data, percentage) => {
116
+ return `${label}: ${percentage.toFixed(1)}%`;
117
+ },
118
+ },
119
+ animate: {
120
+ duration: 700,
121
+ easing: 'ease-in-out',
81
122
  },
82
123
  });
83
124
 
@@ -102,7 +143,8 @@ are negative, the chart throws an error because there is nothing to render.
102
143
 
103
144
  When `valueLabel.position` is `inside`, the formatted value-label text that does not
104
145
  fit inside its slice is hidden. In `auto` mode, labels that are too tight (based on
105
- `insideMargin`) move outside instead.
146
+ `insideMargin`) move outside instead. Set `valueLabel.forceVisible: true` to
147
+ keep inside labels rendered even when they do not fit.
106
148
 
107
149
  ## Supported Components
108
150