@internetstiftelsen/charts 0.11.0 → 0.13.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/tooltip.js CHANGED
@@ -6,9 +6,15 @@ const TOOLTIP_VIEWPORT_PADDING_PX = 10;
6
6
  const TOOLTIP_CONNECTOR_INSET_PX = 14;
7
7
  const TOOLTIP_CONNECTOR_PADDING_PX = 4;
8
8
  const TOOLTIP_CONNECTOR_ELBOW_RATIO = 0.45;
9
+ const TOOLTIP_BORDER_WIDTH_PX = 1;
9
10
  const TOOLTIP_BOX_ARROW_LENGTH_PX = 10;
10
11
  const TOOLTIP_BOX_ARROW_HALF_HEIGHT_PX = 6;
11
12
  const TOOLTIP_CONNECTOR_ALIGNMENT_TOLERANCE_PX = 1;
13
+ const TOOLTIP_CONNECTOR_Z_INDEX = 3;
14
+ const TOOLTIP_ARROW_BORDER_Z_INDEX = 4;
15
+ const TOOLTIP_ARROW_FILL_Z_INDEX = 5;
16
+ const TOOLTIP_BODY_Z_INDEX = 6;
17
+ const TOOLTIP_TOTAL_BORDER_WIDTH_PX = TOOLTIP_BORDER_WIDTH_PX * 2;
12
18
  const SPLIT_TOOLTIP_GAP_PX = 8;
13
19
  const DEFAULT_TOOLTIP_TRANSITION = {
14
20
  show: false,
@@ -706,7 +712,7 @@ export class Tooltip {
706
712
  tooltip
707
713
  .style('position', 'absolute')
708
714
  .style('background-color', theme.tooltip.background)
709
- .style('border', `1px solid ${theme.tooltip.border}`)
715
+ .style('border', `${TOOLTIP_BORDER_WIDTH_PX}px solid ${theme.tooltip.border}`)
710
716
  .style('border-radius', '4px')
711
717
  .style('padding', '8px')
712
718
  .style('box-shadow', '0 2px 4px rgba(0,0,0,0.1)')
@@ -762,6 +768,7 @@ export class Tooltip {
762
768
  return;
763
769
  }
764
770
  this.appendTooltipConnector(tooltip, connectorLayout);
771
+ this.appendTooltipArrow(tooltip, connectorLayout);
765
772
  this.showTooltipAt(tooltip, left, top);
766
773
  }
767
774
  renderTooltipWithoutConnector(tooltip, left, top) {
@@ -806,10 +813,9 @@ export class Tooltip {
806
813
  if (body.empty()) {
807
814
  return;
808
815
  }
809
- body.style('position', 'relative').style('z-index', '1');
816
+ body.style('position', 'relative').style('z-index', String(TOOLTIP_BODY_Z_INDEX));
810
817
  }
811
818
  appendTooltipConnector(tooltip, connectorLayout) {
812
- const tooltipBackground = this.tooltipTheme?.background ?? '#ffffff';
813
819
  const tooltipBorder = this.tooltipTheme?.border ?? '#dddddd';
814
820
  const connector = tooltip
815
821
  .append('svg')
@@ -824,7 +830,7 @@ export class Tooltip {
824
830
  .style('top', `${connectorLayout.top}px`)
825
831
  .style('pointer-events', 'none')
826
832
  .style('overflow', 'visible')
827
- .style('z-index', '0');
833
+ .style('z-index', String(TOOLTIP_CONNECTOR_Z_INDEX));
828
834
  connector
829
835
  .append('path')
830
836
  .attr('data-chart-tooltip-connector-path', 'true')
@@ -834,29 +840,76 @@ export class Tooltip {
834
840
  .attr('stroke-width', 1.25)
835
841
  .attr('stroke-linecap', 'round')
836
842
  .attr('stroke-linejoin', 'round');
837
- connector
838
- .append('path')
839
- .attr('data-chart-tooltip-arrow', 'true')
840
- .attr('d', connectorLayout.arrowPath)
841
- .attr('fill', tooltipBackground)
842
- .attr('stroke', 'none');
843
- connector
844
- .append('path')
845
- .attr('data-chart-tooltip-arrow-base-mask', 'true')
846
- .attr('d', connectorLayout.arrowBaseMaskPath)
847
- .attr('fill', 'none')
848
- .attr('stroke', tooltipBackground)
849
- .attr('stroke-width', 2)
850
- .attr('stroke-linecap', 'butt');
851
- connector
852
- .append('path')
853
- .attr('data-chart-tooltip-arrow-border', 'true')
854
- .attr('d', connectorLayout.arrowBorderPath)
855
- .attr('fill', 'none')
856
- .attr('stroke', tooltipBorder)
857
- .attr('stroke-width', 1)
858
- .attr('stroke-linecap', 'round')
859
- .attr('stroke-linejoin', 'round');
843
+ }
844
+ appendTooltipArrow(tooltip, connectorLayout) {
845
+ const tooltipBackground = this.tooltipTheme?.background ?? '#ffffff';
846
+ const tooltipBorder = this.tooltipTheme?.border ?? '#dddddd';
847
+ this.appendTooltipArrowTriangle(tooltip, connectorLayout, 'data-chart-tooltip-arrow', tooltipBorder, TOOLTIP_BOX_ARROW_LENGTH_PX, TOOLTIP_BOX_ARROW_HALF_HEIGHT_PX, TOOLTIP_ARROW_BORDER_Z_INDEX);
848
+ this.appendTooltipArrowTriangle(tooltip, connectorLayout, 'data-chart-tooltip-arrow-fill', tooltipBackground, TOOLTIP_BOX_ARROW_LENGTH_PX - 1, TOOLTIP_BOX_ARROW_HALF_HEIGHT_PX - 1, TOOLTIP_ARROW_FILL_Z_INDEX);
849
+ }
850
+ appendTooltipArrowTriangle(tooltip, connectorLayout, dataAttribute, color, length, halfHeight, zIndex) {
851
+ const position = this.resolveTooltipArrowPosition(connectorLayout.arrowEdge, connectorLayout.arrowX, connectorLayout.arrowY, length, halfHeight);
852
+ const arrow = tooltip
853
+ .append('div')
854
+ .attr(dataAttribute, 'true')
855
+ .attr('data-chart-tooltip-arrow-edge', connectorLayout.arrowEdge)
856
+ .attr('aria-hidden', 'true')
857
+ .style('position', 'absolute')
858
+ .style('left', `${position.left}px`)
859
+ .style('top', `${position.top}px`)
860
+ .style('width', '0')
861
+ .style('height', '0')
862
+ .style('pointer-events', 'none')
863
+ .style('z-index', String(zIndex));
864
+ if (connectorLayout.arrowEdge === 'left') {
865
+ arrow
866
+ .style('border-top', `${halfHeight}px solid transparent`)
867
+ .style('border-bottom', `${halfHeight}px solid transparent`)
868
+ .style('border-right', `${length}px solid ${color}`);
869
+ return;
870
+ }
871
+ if (connectorLayout.arrowEdge === 'right') {
872
+ arrow
873
+ .style('border-top', `${halfHeight}px solid transparent`)
874
+ .style('border-bottom', `${halfHeight}px solid transparent`)
875
+ .style('border-left', `${length}px solid ${color}`);
876
+ return;
877
+ }
878
+ if (connectorLayout.arrowEdge === 'top') {
879
+ arrow
880
+ .style('border-left', `${halfHeight}px solid transparent`)
881
+ .style('border-right', `${halfHeight}px solid transparent`)
882
+ .style('border-bottom', `${length}px solid ${color}`);
883
+ return;
884
+ }
885
+ arrow
886
+ .style('border-left', `${halfHeight}px solid transparent`)
887
+ .style('border-right', `${halfHeight}px solid transparent`)
888
+ .style('border-top', `${length}px solid ${color}`);
889
+ }
890
+ resolveTooltipArrowPosition(arrowEdge, boxX, boxY, length, halfHeight) {
891
+ switch (arrowEdge) {
892
+ case 'left':
893
+ return {
894
+ left: boxX - length,
895
+ top: boxY - halfHeight,
896
+ };
897
+ case 'right':
898
+ return {
899
+ left: boxX,
900
+ top: boxY - halfHeight,
901
+ };
902
+ case 'top':
903
+ return {
904
+ left: boxX - halfHeight,
905
+ top: boxY - length,
906
+ };
907
+ case 'bottom':
908
+ return {
909
+ left: boxX - halfHeight,
910
+ top: boxY,
911
+ };
912
+ }
860
913
  }
861
914
  resolveBarTooltipAnchor(svgNode, dataKey, index) {
862
915
  const barNode = svgNode.querySelector(`.bar-${sanitizeForCSS(dataKey)}[data-index="${index}"]`);
@@ -1050,25 +1103,21 @@ export class Tooltip {
1050
1103
  return null;
1051
1104
  }
1052
1105
  const boxArrowPosition = this.resolveTooltipBoxArrowPosition(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY);
1053
- const arrow = this.resolveTooltipBoxArrow(arrowEdge, boxArrowPosition.x, boxArrowPosition.y);
1054
- const minX = Math.min(arrow.tipX, arrow.baseStartX, arrow.baseEndX, localTargetX) - TOOLTIP_CONNECTOR_PADDING_PX;
1055
- const maxX = Math.max(arrow.tipX, arrow.baseStartX, arrow.baseEndX, localTargetX) + TOOLTIP_CONNECTOR_PADDING_PX;
1056
- const minY = Math.min(arrow.tipY, arrow.baseStartY, arrow.baseEndY, localTargetY) - TOOLTIP_CONNECTOR_PADDING_PX;
1057
- const maxY = Math.max(arrow.tipY, arrow.baseStartY, arrow.baseEndY, localTargetY) + TOOLTIP_CONNECTOR_PADDING_PX;
1106
+ const arrowTip = this.resolveTooltipArrowTip(arrowEdge, boxArrowPosition.x, boxArrowPosition.y, TOOLTIP_BOX_ARROW_LENGTH_PX);
1107
+ const minX = Math.min(arrowTip.x, localTargetX) - TOOLTIP_CONNECTOR_PADDING_PX;
1108
+ const maxX = Math.max(arrowTip.x, localTargetX) + TOOLTIP_CONNECTOR_PADDING_PX;
1109
+ const minY = Math.min(arrowTip.y, localTargetY) - TOOLTIP_CONNECTOR_PADDING_PX;
1110
+ const maxY = Math.max(arrowTip.y, localTargetY) + TOOLTIP_CONNECTOR_PADDING_PX;
1058
1111
  const width = Math.max(1, maxX - minX);
1059
1112
  const height = Math.max(1, maxY - minY);
1060
1113
  const boxX = boxArrowPosition.x - minX;
1061
1114
  const boxY = boxArrowPosition.y - minY;
1062
- const startX = arrow.tipX - minX;
1063
- const startY = arrow.tipY - minY;
1064
- const arrowBaseStartX = arrow.baseStartX - minX;
1065
- const arrowBaseStartY = arrow.baseStartY - minY;
1066
- const arrowBaseEndX = arrow.baseEndX - minX;
1067
- const arrowBaseEndY = arrow.baseEndY - minY;
1115
+ const startX = arrowTip.x - minX;
1116
+ const startY = arrowTip.y - minY;
1068
1117
  const endX = localTargetX - minX;
1069
1118
  const endY = localTargetY - minY;
1070
1119
  const connectorPath = this.resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY);
1071
- if (!this.hasFiniteNumbers(width, height, boxX, boxY, startX, startY, arrowBaseStartX, arrowBaseStartY, arrowBaseEndX, arrowBaseEndY, endX, endY)) {
1120
+ if (!this.hasFiniteNumbers(width, height, boxX, boxY, startX, startY, endX, endY)) {
1072
1121
  return null;
1073
1122
  }
1074
1123
  return {
@@ -1078,12 +1127,15 @@ export class Tooltip {
1078
1127
  width,
1079
1128
  height,
1080
1129
  path: connectorPath,
1081
- arrowPath: `M ${startX},${startY} L ${arrowBaseStartX},${arrowBaseStartY} L ${arrowBaseEndX},${arrowBaseEndY} Z`,
1082
- arrowBaseMaskPath: this.resolveTooltipArrowBaseMaskPath(arrowEdge, boxX, boxY, arrowBaseStartX, arrowBaseStartY, arrowBaseEndX, arrowBaseEndY),
1083
- arrowBorderPath: `M ${arrowBaseStartX},${arrowBaseStartY} L ${startX},${startY} L ${arrowBaseEndX},${arrowBaseEndY}`,
1130
+ arrowX: boxArrowPosition.x,
1131
+ arrowY: boxArrowPosition.y,
1084
1132
  };
1085
1133
  }
1086
1134
  resolveTooltipBoxArrowPosition(arrowEdge, tooltipLeft, tooltipTop, tooltipWidth, tooltipHeight, targetX, targetY) {
1135
+ // Arrow offsets are relative to the padding box, while measured
1136
+ // tooltip dimensions include both borders.
1137
+ const rightInnerBorderX = tooltipWidth - TOOLTIP_TOTAL_BORDER_WIDTH_PX;
1138
+ const bottomInnerBorderY = tooltipHeight - TOOLTIP_TOTAL_BORDER_WIDTH_PX;
1087
1139
  switch (arrowEdge) {
1088
1140
  case 'left':
1089
1141
  return {
@@ -1092,7 +1144,7 @@ export class Tooltip {
1092
1144
  };
1093
1145
  case 'right':
1094
1146
  return {
1095
- x: tooltipWidth,
1147
+ x: rightInnerBorderX,
1096
1148
  y: this.getTooltipConnectorOffset(tooltipTop, tooltipHeight, targetY),
1097
1149
  };
1098
1150
  case 'top':
@@ -1103,22 +1155,10 @@ export class Tooltip {
1103
1155
  case 'bottom':
1104
1156
  return {
1105
1157
  x: this.getTooltipConnectorOffset(tooltipLeft, tooltipWidth, targetX),
1106
- y: tooltipHeight,
1158
+ y: bottomInnerBorderY,
1107
1159
  };
1108
1160
  }
1109
1161
  }
1110
- resolveTooltipArrowBaseMaskPath(arrowEdge, boxX, boxY, startX, startY, endX, endY) {
1111
- if (arrowEdge === 'top') {
1112
- return `M ${startX - 1},${boxY + 1} L ${endX + 1},${boxY + 1}`;
1113
- }
1114
- if (arrowEdge === 'bottom') {
1115
- return `M ${startX - 1},${boxY - 1} L ${endX + 1},${boxY - 1}`;
1116
- }
1117
- if (arrowEdge === 'left') {
1118
- return `M ${boxX + 1},${startY - 1} L ${boxX + 1},${endY + 1}`;
1119
- }
1120
- return `M ${boxX - 1},${startY - 1} L ${boxX - 1},${endY + 1}`;
1121
- }
1122
1162
  resolveTooltipConnectorPath(arrowEdge, startX, startY, endX, endY) {
1123
1163
  if (arrowEdge === 'left' || arrowEdge === 'right') {
1124
1164
  if (Math.abs(endY - startY) <=
@@ -1134,32 +1174,16 @@ export class Tooltip {
1134
1174
  const elbowY = startY + (endY - startY) * TOOLTIP_CONNECTOR_ELBOW_RATIO;
1135
1175
  return `M ${startX},${startY} L ${startX},${elbowY} L ${endX},${endY}`;
1136
1176
  }
1137
- resolveTooltipBoxArrow(arrowEdge, boxX, boxY) {
1177
+ resolveTooltipArrowTip(arrowEdge, boxX, boxY, length) {
1138
1178
  if (arrowEdge === 'left' || arrowEdge === 'right') {
1139
- const baseX = boxX;
1140
- const tipX = arrowEdge === 'left'
1141
- ? baseX - TOOLTIP_BOX_ARROW_LENGTH_PX
1142
- : baseX + TOOLTIP_BOX_ARROW_LENGTH_PX;
1143
1179
  return {
1144
- tipX,
1145
- tipY: boxY,
1146
- baseStartX: baseX,
1147
- baseStartY: boxY - TOOLTIP_BOX_ARROW_HALF_HEIGHT_PX,
1148
- baseEndX: baseX,
1149
- baseEndY: boxY + TOOLTIP_BOX_ARROW_HALF_HEIGHT_PX,
1180
+ x: arrowEdge === 'left' ? boxX - length : boxX + length,
1181
+ y: boxY,
1150
1182
  };
1151
1183
  }
1152
- const baseY = boxY;
1153
- const tipY = arrowEdge === 'top'
1154
- ? baseY - TOOLTIP_BOX_ARROW_LENGTH_PX
1155
- : baseY + TOOLTIP_BOX_ARROW_LENGTH_PX;
1156
1184
  return {
1157
- tipX: boxX,
1158
- tipY,
1159
- baseStartX: boxX - TOOLTIP_BOX_ARROW_HALF_HEIGHT_PX,
1160
- baseStartY: baseY,
1161
- baseEndX: boxX + TOOLTIP_BOX_ARROW_HALF_HEIGHT_PX,
1162
- baseEndY: baseY,
1185
+ x: boxX,
1186
+ y: arrowEdge === 'top' ? boxY - length : boxY + length,
1163
1187
  };
1164
1188
  }
1165
1189
  hasFiniteNumbers(...values) {
package/dist/types.d.ts CHANGED
@@ -69,6 +69,9 @@ export type ChartTheme = {
69
69
  itemSpacingX: number;
70
70
  itemSpacingY: number;
71
71
  };
72
+ text: {
73
+ variants: Record<string, TextVariantStyle>;
74
+ };
72
75
  tooltip: {
73
76
  background: string;
74
77
  border: string;
@@ -333,13 +336,43 @@ export type LegendItem = {
333
336
  color: string;
334
337
  visible: boolean;
335
338
  };
339
+ export type TextPosition = 'top' | 'bottom';
340
+ export type TextAlign = 'left' | 'center' | 'right';
341
+ export type TextVariantStyle = {
342
+ fontSize?: number;
343
+ fontWeight?: string;
344
+ fontFamily?: string;
345
+ color?: string;
346
+ lineHeight?: number;
347
+ marginTop?: number;
348
+ marginBottom?: number;
349
+ };
350
+ export type TextConfigBase = {
351
+ display?: boolean;
352
+ text: string;
353
+ position?: TextPosition;
354
+ variant?: string;
355
+ align?: TextAlign;
356
+ fontSize?: number;
357
+ fontWeight?: string;
358
+ fontFamily?: string;
359
+ color?: string;
360
+ lineHeight?: number;
361
+ marginTop?: number;
362
+ marginBottom?: number;
363
+ };
364
+ export type TextConfig = TextConfigBase & {
365
+ exportHooks?: ExportHooks<TextConfigBase>;
366
+ };
336
367
  export type TitleConfigBase = {
337
368
  display?: boolean;
338
369
  text: string;
339
370
  fontSize?: number;
340
371
  fontWeight?: string;
341
372
  fontFamily?: string;
342
- align?: 'left' | 'center' | 'right';
373
+ align?: TextAlign;
374
+ color?: string;
375
+ lineHeight?: number;
343
376
  marginTop?: number;
344
377
  marginBottom?: number;
345
378
  };
package/dist/xy-chart.js CHANGED
@@ -263,7 +263,7 @@ export class XYChart extends BaseChart {
263
263
  setScaleConfigOverride(override, rerender = true) {
264
264
  this.scaleConfigOverride = override;
265
265
  if (rerender) {
266
- this.rerender();
266
+ this.rerender('component');
267
267
  }
268
268
  return this;
269
269
  }
@@ -18,7 +18,7 @@ new ChartGroup(config: ChartGroupConfig)
18
18
  | `syncY` | `boolean` | `false` | Sync the Y domain across visible vertical `XYChart` children |
19
19
  | `height` | `number` | container height | Fixed total group height. Omit it to size from the container |
20
20
  | `chartHeight` | `number` | `DEFAULT_CHART_HEIGHT` | Fallback child height when neither the child nor container provides one |
21
- | `theme` | `DeepPartial<ChartTheme>` | - | Theme override for the shared group legend and title |
21
+ | `theme` | `DeepPartial<ChartTheme>` | - | Theme override for the shared group legend and text |
22
22
  | `responsive` | `ChartGroupResponsiveConfig` | - | Declarative responsive overrides for group-level `cols` and `gap` |
23
23
 
24
24
  `ChartGroup` manages child widths. If a child chart has an explicit `width`,
@@ -32,6 +32,7 @@ import { XYChart } from '@internetstiftelsen/charts/xy-chart';
32
32
  import { Line } from '@internetstiftelsen/charts/line';
33
33
  import { Bar } from '@internetstiftelsen/charts/bar';
34
34
  import { Legend } from '@internetstiftelsen/charts/legend';
35
+ import { Text } from '@internetstiftelsen/charts/text';
35
36
  import { Title } from '@internetstiftelsen/charts/title';
36
37
 
37
38
  const lineChart = new XYChart({ data: lineData });
@@ -53,6 +54,13 @@ const group = new ChartGroup({
53
54
 
54
55
  group
55
56
  .addChild(new Title({ text: 'Revenue vs Expenses' }))
57
+ .addChild(
58
+ new Text({
59
+ text: 'Source: finance team',
60
+ position: 'bottom',
61
+ variant: 'caption',
62
+ }),
63
+ )
56
64
  .addChart(barChart, { span: 1 })
57
65
  .addChart(lineChart, { span: 2 })
58
66
  .addChild(new Legend());
@@ -165,13 +173,21 @@ After a breakpoint's `maxWidth`, that breakpoint stops matching. Use the base
165
173
  group config plus `minWidth` breakpoints for mobile-first layouts, or the base
166
174
  group config plus `maxWidth` breakpoints for desktop-down layouts.
167
175
 
168
- ## Title
176
+ ## Text and Title
169
177
 
170
- `ChartGroup` accepts `Title` via `addChild()` and renders it above the grouped
171
- chart layout:
178
+ `ChartGroup` accepts `Text` and `Title` via `addChild()`. Top text renders above
179
+ the grouped chart layout. Bottom text renders below the grouped chart layout and
180
+ shared legend:
172
181
 
173
182
  ```typescript
174
183
  group.addChild(new Title({ text: 'Revenue vs Expenses' }));
184
+ group.addChild(
185
+ new Text({
186
+ text: 'Source: finance team',
187
+ position: 'bottom',
188
+ variant: 'caption',
189
+ }),
190
+ );
175
191
  ```
176
192
 
177
193
  ## Legend
@@ -208,6 +224,9 @@ await group.export('pdf');
208
224
 
209
225
  - Export width can be overridden with `options.width`
210
226
  - Export height is layout-derived in v1 and cannot be overridden
211
- - Group titles are included in the combined export
227
+ - Group text is included in the combined export
228
+ - Group-level `Text` and `Title` export hooks run during visual export, so text
229
+ can be hidden live with `display: false` and included only in export by
230
+ returning `{ display: true }` from `beforeRender`
212
231
  - Child chart legends are suppressed in the combined export
213
232
  - Non-visual exports (`json`, `csv`, `xlsx`) are not supported in v1
@@ -224,34 +224,116 @@ charts from one shared legend.
224
224
 
225
225
  ---
226
226
 
227
- ## Title
227
+ ## Text
228
228
 
229
- Renders a title for the chart.
229
+ Renders layout-aware text above or below the chart. Use it for titles,
230
+ subtitles, captions, source notes, and other short chart copy.
230
231
 
231
232
  ```typescript
232
- new Title({
233
- display?: boolean, // Render title and reserve layout space (default: true)
234
- text: string, // Title text (required)
235
- fontSize?: number, // Font size in pixels (default: 18)
236
- fontWeight?: string, // Font weight (default: 'bold')
237
- align?: 'left' | 'center' | 'right', // Alignment (default: 'center')
238
- marginTop?: number, // Space above title (default: 10)
239
- marginBottom?: number, // Space below title (default: 15)
233
+ new Text({
234
+ display?: boolean, // Render text and reserve layout space (default: true)
235
+ text: string, // Text content (required)
236
+ position?: 'top' | 'bottom', // Layout position (default: 'top')
237
+ variant?: string, // Theme variant (default: 'caption')
238
+ align?: 'left' | 'center' | 'right', // Alignment (default: 'center')
239
+ fontSize?: number, // Override variant font size
240
+ fontWeight?: string, // Override variant font weight
241
+ fontFamily?: string, // Override variant font family
242
+ color?: string, // Override variant text color
243
+ lineHeight?: number, // Override variant line height
244
+ marginTop?: number, // Override variant top spacing
245
+ marginBottom?: number, // Override variant bottom spacing
240
246
  })
241
247
  ```
242
248
 
243
249
  ### Example
244
250
 
245
251
  ```javascript
252
+ import { Text } from '@internetstiftelsen/charts/text';
253
+
254
+ chart
255
+ .addChild(
256
+ new Text({
257
+ text: 'Monthly Revenue',
258
+ variant: 'title',
259
+ align: 'left',
260
+ }),
261
+ )
262
+ .addChild(
263
+ new Text({
264
+ text: 'Revenue by month, SEK',
265
+ variant: 'subtitle',
266
+ align: 'left',
267
+ }),
268
+ )
269
+ .addChild(
270
+ new Text({
271
+ text: 'Source: Internal reporting',
272
+ position: 'bottom',
273
+ variant: 'caption',
274
+ align: 'left',
275
+ }),
276
+ );
277
+ ```
278
+
279
+ Text variants come from `theme.text.variants`. Built-in variants are `title`,
280
+ `subtitle`, and `caption`. Component config overrides the selected variant.
281
+
282
+ ```javascript
283
+ const chart = new XYChart({
284
+ data,
285
+ theme: {
286
+ text: {
287
+ variants: {
288
+ source: {
289
+ fontSize: 10,
290
+ color: '#64748b',
291
+ marginTop: 6,
292
+ marginBottom: 0,
293
+ },
294
+ },
295
+ },
296
+ },
297
+ });
298
+
246
299
  chart.addChild(
247
- new Title({
248
- text: 'Monthly Revenue',
249
- align: 'left',
250
- fontSize: 20,
300
+ new Text({
301
+ text: 'Source: Internetstiftelsen',
302
+ position: 'bottom',
303
+ variant: 'source',
251
304
  }),
252
305
  );
253
306
  ```
254
307
 
308
+ ## Title
309
+
310
+ `Title` remains available as a compatibility shortcut for top title text:
311
+
312
+ ```typescript
313
+ new Title({
314
+ display?: boolean,
315
+ text: string,
316
+ fontSize?: number,
317
+ fontWeight?: string,
318
+ fontFamily?: string,
319
+ align?: 'left' | 'center' | 'right',
320
+ color?: string,
321
+ lineHeight?: number,
322
+ marginTop?: number,
323
+ marginBottom?: number,
324
+ })
325
+ ```
326
+
327
+ It maps to the same rendering and layout behavior as:
328
+
329
+ ```javascript
330
+ new Text({
331
+ text: 'Monthly Revenue',
332
+ position: 'top',
333
+ variant: 'title',
334
+ });
335
+ ```
336
+
255
337
  ---
256
338
 
257
339
  ## Export Hooks
@@ -346,12 +428,14 @@ Components are rendered in the order they're added. A typical order:
346
428
 
347
429
  ```javascript
348
430
  chart
349
- .addChild(new Title({ text: 'Chart Title' }))
431
+ .addChild(new Text({ text: 'Chart Title', variant: 'title' }))
432
+ .addChild(new Text({ text: 'Subtitle', variant: 'subtitle' }))
350
433
  .addChild(new Grid({ value: true }))
351
434
  .addChild(new XAxis({ dataKey: 'date' }))
352
435
  .addChild(new YAxis())
353
436
  .addChild(new Tooltip())
354
437
  .addChild(new Legend({ position: 'bottom' }))
438
+ .addChild(new Text({ text: 'Source note', position: 'bottom' }))
355
439
  .addChild(new Line({ dataKey: 'value1' }))
356
440
  .addChild(new Line({ dataKey: 'value2' }));
357
441
  ```
@@ -176,7 +176,8 @@ new DonutCenterContent({
176
176
  DonutChart supports the following components via `addChild()`:
177
177
 
178
178
  - `DonutCenterContent` - Center text content
179
- - `Title` - Chart title
179
+ - `Text` - Chart text, captions, and source notes
180
+ - `Title` - Shortcut for top title text
180
181
  - `Legend` - Interactive legend (click to toggle segments)
181
182
  - `Tooltip` - Hover tooltips with segment info
182
183
 
@@ -170,6 +170,7 @@ const chart = new GaugeChart({
170
170
 
171
171
  GaugeChart supports the following components via `addChild()`:
172
172
 
173
- - `Title` - Chart title
173
+ - `Text` - Chart text, captions, and source notes
174
+ - `Title` - Shortcut for top title text
174
175
  - `Tooltip` - Hover tooltip (value, target)
175
176
  - `Legend` - Segment legend with visibility toggles
@@ -57,6 +57,30 @@ chart.update(newData);
57
57
  chart.destroy();
58
58
  ```
59
59
 
60
+ ## Lifecycle Events
61
+
62
+ Charts support lifecycle listeners with `on()` and `off()`.
63
+
64
+ ```javascript
65
+ const handleReady = (event) => {
66
+ console.log(event.reason);
67
+ };
68
+
69
+ chart.on('ready', handleReady);
70
+ chart.off('ready', handleReady);
71
+ ```
72
+
73
+ All event payloads include `type` and `chart`.
74
+
75
+ | Name | Description | Params |
76
+ | --------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------- |
77
+ | `render:start` | A render pass is starting, before the SVG is recreated. | `reason` |
78
+ | `render` | Synchronous rendering has completed. | `reason` |
79
+ | `ready` | The current render has fully settled, matching `chart.whenReady()`. | `reason` |
80
+ | `data` | `chart.update(data)` has accepted new data, before the update render. | `data`, `previousData` |
81
+ | `legend:change` | Legend visibility changed. | - |
82
+ | `destroy` | The chart is being destroyed, before DOM and internal state are cleaned up. | - |
83
+
60
84
  ## Sizing
61
85
 
62
86
  By default, charts size themselves from the render container.
@@ -342,5 +366,5 @@ directly when laying out and rendering the cloud.
342
366
  - [DonutChart API](./donut-chart.md) - Donut/pie charts
343
367
  - [PieChart API](./pie-chart.md) - Pie charts
344
368
  - [GaugeChart API](./gauge-chart.md) - Gauge charts
345
- - [Components](./components.md) - Axes, Grid, Tooltip, Legend, Title
369
+ - [Components](./components.md) - Axes, Grid, Tooltip, Legend, Text, Title
346
370
  - [Theming](./theming.md) - Customize colors and styles
package/docs/pie-chart.md CHANGED
@@ -108,7 +108,8 @@ fit inside its slice is hidden. In `auto` mode, labels that are too tight (based
108
108
 
109
109
  PieChart supports the following components via `addChild()`:
110
110
 
111
- - `Title` - Chart title
111
+ - `Text` - Chart text, captions, and source notes
112
+ - `Title` - Shortcut for top title text
112
113
  - `Legend` - Interactive legend (click to toggle slices)
113
114
  - `Tooltip` - Hover/focus tooltips with slice info
114
115
 
package/docs/theming.md CHANGED
@@ -111,6 +111,41 @@ const data = [
111
111
 
112
112
  ---
113
113
 
114
+ ## Text Variants
115
+
116
+ `Text` uses `theme.text.variants` for reusable typography and spacing. Built-in
117
+ variants are `title`, `subtitle`, and `caption`; add custom keys when a chart
118
+ needs another text style.
119
+
120
+ ```javascript
121
+ theme: {
122
+ text: {
123
+ variants: {
124
+ title: {
125
+ fontSize: 18,
126
+ fontWeight: 'bold',
127
+ color: '#1f2a36',
128
+ lineHeight: 1.2,
129
+ marginTop: 10,
130
+ marginBottom: 15,
131
+ },
132
+ source: {
133
+ fontSize: 10,
134
+ color: '#64748b',
135
+ lineHeight: 1.3,
136
+ marginTop: 6,
137
+ marginBottom: 0,
138
+ },
139
+ },
140
+ },
141
+ }
142
+ ```
143
+
144
+ Component config overrides the chosen variant, so a one-off chart can adjust
145
+ `fontSize`, `color`, or spacing without changing the theme.
146
+
147
+ ---
148
+
114
149
  ## Donut Theme Defaults
115
150
 
116
151
  DonutChart has additional theme defaults: