@internetstiftelsen/charts 0.12.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/text.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { type Selection } from 'd3';
2
+ import type { ChartTheme, ExportHooks, TextAlign, TextConfig, TextConfigBase, TextPosition } from './types.js';
3
+ import type { ComponentSpace, LayoutAwareComponent } from './chart-interface.js';
4
+ type TextComponentType = 'text' | 'title';
5
+ type ResolvedTextStyle = {
6
+ fontSize: number;
7
+ fontWeight: string;
8
+ fontFamily: string;
9
+ color: string;
10
+ lineHeight: number;
11
+ marginTop: number;
12
+ marginBottom: number;
13
+ };
14
+ export declare class Text implements LayoutAwareComponent<TextConfigBase> {
15
+ readonly type: TextComponentType;
16
+ readonly display: boolean;
17
+ readonly text: string;
18
+ readonly position: TextPosition;
19
+ readonly variant: string;
20
+ readonly align: TextAlign;
21
+ readonly exportHooks?: ExportHooks<TextConfigBase>;
22
+ protected readonly fontSize?: number;
23
+ protected readonly fontWeight?: string;
24
+ protected readonly fontFamily?: string;
25
+ protected readonly color?: string;
26
+ protected readonly lineHeight?: number;
27
+ protected readonly marginTop?: number;
28
+ protected readonly marginBottom?: number;
29
+ constructor(config: TextConfig, componentType?: TextComponentType);
30
+ getExportConfig(): TextConfigBase;
31
+ createExportComponent(override?: Partial<TextConfigBase>): LayoutAwareComponent<TextConfigBase>;
32
+ getRequiredSpace(theme?: ChartTheme): ComponentSpace;
33
+ render(svg: Selection<SVGSVGElement, undefined, null, undefined>, theme: ChartTheme, width: number, x?: number, y?: number): void;
34
+ protected resolveStyle(theme?: ChartTheme): ResolvedTextStyle;
35
+ private resolveStyleValue;
36
+ private getLines;
37
+ private resolveTextPosition;
38
+ private resolveClassName;
39
+ }
40
+ export {};
package/dist/text.js ADDED
@@ -0,0 +1,217 @@
1
+ import { mergeDeep, sanitizeForCSS } from './utils.js';
2
+ export class Text {
3
+ constructor(config, componentType = 'text') {
4
+ Object.defineProperty(this, "type", {
5
+ enumerable: true,
6
+ configurable: true,
7
+ writable: true,
8
+ value: void 0
9
+ });
10
+ Object.defineProperty(this, "display", {
11
+ enumerable: true,
12
+ configurable: true,
13
+ writable: true,
14
+ value: void 0
15
+ });
16
+ Object.defineProperty(this, "text", {
17
+ enumerable: true,
18
+ configurable: true,
19
+ writable: true,
20
+ value: void 0
21
+ });
22
+ Object.defineProperty(this, "position", {
23
+ enumerable: true,
24
+ configurable: true,
25
+ writable: true,
26
+ value: void 0
27
+ });
28
+ Object.defineProperty(this, "variant", {
29
+ enumerable: true,
30
+ configurable: true,
31
+ writable: true,
32
+ value: void 0
33
+ });
34
+ Object.defineProperty(this, "align", {
35
+ enumerable: true,
36
+ configurable: true,
37
+ writable: true,
38
+ value: void 0
39
+ });
40
+ Object.defineProperty(this, "exportHooks", {
41
+ enumerable: true,
42
+ configurable: true,
43
+ writable: true,
44
+ value: void 0
45
+ });
46
+ Object.defineProperty(this, "fontSize", {
47
+ enumerable: true,
48
+ configurable: true,
49
+ writable: true,
50
+ value: void 0
51
+ });
52
+ Object.defineProperty(this, "fontWeight", {
53
+ enumerable: true,
54
+ configurable: true,
55
+ writable: true,
56
+ value: void 0
57
+ });
58
+ Object.defineProperty(this, "fontFamily", {
59
+ enumerable: true,
60
+ configurable: true,
61
+ writable: true,
62
+ value: void 0
63
+ });
64
+ Object.defineProperty(this, "color", {
65
+ enumerable: true,
66
+ configurable: true,
67
+ writable: true,
68
+ value: void 0
69
+ });
70
+ Object.defineProperty(this, "lineHeight", {
71
+ enumerable: true,
72
+ configurable: true,
73
+ writable: true,
74
+ value: void 0
75
+ });
76
+ Object.defineProperty(this, "marginTop", {
77
+ enumerable: true,
78
+ configurable: true,
79
+ writable: true,
80
+ value: void 0
81
+ });
82
+ Object.defineProperty(this, "marginBottom", {
83
+ enumerable: true,
84
+ configurable: true,
85
+ writable: true,
86
+ value: void 0
87
+ });
88
+ this.type = componentType;
89
+ this.display = config.display ?? true;
90
+ this.text = config.text;
91
+ this.position = config.position ?? 'top';
92
+ this.variant = config.variant ?? 'caption';
93
+ this.align = config.align ?? 'center';
94
+ this.fontSize = config.fontSize;
95
+ this.fontWeight = config.fontWeight;
96
+ this.fontFamily = config.fontFamily;
97
+ this.color = config.color;
98
+ this.lineHeight = config.lineHeight;
99
+ this.marginTop = config.marginTop;
100
+ this.marginBottom = config.marginBottom;
101
+ this.exportHooks = config.exportHooks;
102
+ }
103
+ getExportConfig() {
104
+ return {
105
+ display: this.display,
106
+ text: this.text,
107
+ position: this.position,
108
+ variant: this.variant,
109
+ align: this.align,
110
+ fontSize: this.fontSize,
111
+ fontWeight: this.fontWeight,
112
+ fontFamily: this.fontFamily,
113
+ color: this.color,
114
+ lineHeight: this.lineHeight,
115
+ marginTop: this.marginTop,
116
+ marginBottom: this.marginBottom,
117
+ };
118
+ }
119
+ createExportComponent(override) {
120
+ const merged = mergeDeep(this.getExportConfig(), override);
121
+ return new Text({
122
+ ...merged,
123
+ exportHooks: this.exportHooks,
124
+ }, this.type);
125
+ }
126
+ getRequiredSpace(theme) {
127
+ if (!this.display) {
128
+ return {
129
+ width: 0,
130
+ height: 0,
131
+ position: this.position,
132
+ };
133
+ }
134
+ const style = this.resolveStyle(theme);
135
+ const lineCount = this.getLines().length;
136
+ const textHeight = style.fontSize +
137
+ Math.max(0, lineCount - 1) * style.fontSize * style.lineHeight;
138
+ return {
139
+ width: 0,
140
+ height: style.marginTop + textHeight + style.marginBottom,
141
+ position: this.position,
142
+ };
143
+ }
144
+ render(svg, theme, width, x = 0, y = 0) {
145
+ if (!this.display) {
146
+ return;
147
+ }
148
+ const style = this.resolveStyle(theme);
149
+ const { textX, textAnchor } = this.resolveTextPosition(theme, width);
150
+ const textGroup = svg
151
+ .append('g')
152
+ .attr('class', this.resolveClassName())
153
+ .attr('transform', `translate(${x}, ${y})`);
154
+ const text = textGroup
155
+ .append('text')
156
+ .attr('x', textX)
157
+ .attr('text-anchor', textAnchor)
158
+ .attr('font-size', `${style.fontSize}px`)
159
+ .attr('font-weight', style.fontWeight)
160
+ .attr('font-family', style.fontFamily)
161
+ .attr('fill', style.color);
162
+ this.getLines().forEach((line, index) => {
163
+ const tspan = text.append('tspan').attr('x', textX).text(line);
164
+ if (index === 0) {
165
+ tspan.attr('y', style.marginTop + style.fontSize);
166
+ return;
167
+ }
168
+ tspan.attr('dy', `${style.lineHeight}em`);
169
+ });
170
+ }
171
+ resolveStyle(theme) {
172
+ const variant = theme?.text.variants[this.variant] ?? {};
173
+ return {
174
+ fontSize: this.resolveStyleValue(this.fontSize, variant.fontSize, 12),
175
+ fontWeight: this.resolveStyleValue(this.fontWeight, variant.fontWeight, 'normal'),
176
+ fontFamily: this.fontFamily ??
177
+ variant.fontFamily ??
178
+ theme?.fontFamily ??
179
+ '',
180
+ color: this.resolveStyleValue(this.color, variant.color, '#1f2a36'),
181
+ lineHeight: this.resolveStyleValue(this.lineHeight, variant.lineHeight, 1.2),
182
+ marginTop: this.resolveStyleValue(this.marginTop, variant.marginTop, 0),
183
+ marginBottom: this.resolveStyleValue(this.marginBottom, variant.marginBottom, 0),
184
+ };
185
+ }
186
+ resolveStyleValue(configured, variant, fallback) {
187
+ return configured ?? variant ?? fallback;
188
+ }
189
+ getLines() {
190
+ return this.text.split('\n');
191
+ }
192
+ resolveTextPosition(theme, width) {
193
+ if (this.align === 'left') {
194
+ return {
195
+ textX: theme.margins.left,
196
+ textAnchor: 'start',
197
+ };
198
+ }
199
+ if (this.align === 'right') {
200
+ return {
201
+ textX: width - theme.margins.right,
202
+ textAnchor: 'end',
203
+ };
204
+ }
205
+ return {
206
+ textX: width / 2,
207
+ textAnchor: 'middle',
208
+ };
209
+ }
210
+ resolveClassName() {
211
+ const classes = ['text', `text--${sanitizeForCSS(this.variant)}`];
212
+ if (this.type === 'title') {
213
+ classes.unshift('title');
214
+ }
215
+ return classes.join(' ');
216
+ }
217
+ }
package/dist/theme.js CHANGED
@@ -40,6 +40,34 @@ export const defaultTheme = {
40
40
  itemSpacingX: 20,
41
41
  itemSpacingY: 8,
42
42
  },
43
+ text: {
44
+ variants: {
45
+ title: {
46
+ fontSize: 18,
47
+ fontWeight: 'bold',
48
+ color: '#1f2a36',
49
+ lineHeight: 1.2,
50
+ marginTop: 10,
51
+ marginBottom: 15,
52
+ },
53
+ subtitle: {
54
+ fontSize: 14,
55
+ fontWeight: 'normal',
56
+ color: '#4b5563',
57
+ lineHeight: 1.35,
58
+ marginTop: 0,
59
+ marginBottom: 12,
60
+ },
61
+ caption: {
62
+ fontSize: 12,
63
+ fontWeight: 'normal',
64
+ color: '#6b7280',
65
+ lineHeight: 1.3,
66
+ marginTop: 8,
67
+ marginBottom: 0,
68
+ },
69
+ },
70
+ },
43
71
  tooltip: {
44
72
  background: '#ffffff',
45
73
  border: '#dddddd',
@@ -128,6 +156,34 @@ export const newspaperTheme = {
128
156
  itemSpacingX: 20,
129
157
  itemSpacingY: 8,
130
158
  },
159
+ text: {
160
+ variants: {
161
+ title: {
162
+ fontSize: 18,
163
+ fontWeight: 'bold',
164
+ color: '#1a1a1a',
165
+ lineHeight: 1.2,
166
+ marginTop: 10,
167
+ marginBottom: 15,
168
+ },
169
+ subtitle: {
170
+ fontSize: 14,
171
+ fontWeight: 'normal',
172
+ color: '#4a4a4a',
173
+ lineHeight: 1.35,
174
+ marginTop: 0,
175
+ marginBottom: 12,
176
+ },
177
+ caption: {
178
+ fontSize: 12,
179
+ fontWeight: 'normal',
180
+ color: '#6b6b6b',
181
+ lineHeight: 1.3,
182
+ marginTop: 8,
183
+ marginBottom: 0,
184
+ },
185
+ },
186
+ },
131
187
  tooltip: {
132
188
  background: '#111111',
133
189
  border: '#111111',
package/dist/title.d.ts CHANGED
@@ -1,23 +1,17 @@
1
1
  import { type Selection } from 'd3';
2
- import type { TitleConfig, ChartTheme, ExportHooks, TitleConfigBase } from './types.js';
3
- import type { LayoutAwareComponent, ComponentSpace } from './chart-interface.js';
2
+ import type { ChartTheme, ExportHooks, TitleConfig, TitleConfigBase } from './types.js';
3
+ import type { ComponentSpace, LayoutAwareComponent } from './chart-interface.js';
4
4
  export declare class Title implements LayoutAwareComponent<TitleConfigBase> {
5
5
  readonly type: "title";
6
6
  readonly display: boolean;
7
7
  readonly text: string;
8
+ readonly position: "top";
9
+ readonly variant = "title";
8
10
  readonly exportHooks?: ExportHooks<TitleConfigBase>;
9
- private readonly fontSize;
10
- private readonly fontWeight;
11
- private readonly fontFamily?;
12
- private readonly align;
13
- private readonly marginTop;
14
- private readonly marginBottom;
11
+ private readonly textComponent;
15
12
  constructor(config: TitleConfig);
16
13
  getExportConfig(): TitleConfigBase;
17
14
  createExportComponent(override?: Partial<TitleConfigBase>): LayoutAwareComponent<TitleConfigBase>;
18
- /**
19
- * Returns the space required by the title
20
- */
21
- getRequiredSpace(): ComponentSpace;
15
+ getRequiredSpace(theme?: ChartTheme): ComponentSpace;
22
16
  render(svg: Selection<SVGSVGElement, undefined, null, undefined>, theme: ChartTheme, width: number, x?: number, y?: number): void;
23
17
  }
package/dist/title.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Text } from './text.js';
1
2
  import { mergeDeep } from './utils.js';
2
3
  export class Title {
3
4
  constructor(config) {
@@ -19,68 +20,53 @@ export class Title {
19
20
  writable: true,
20
21
  value: void 0
21
22
  });
22
- Object.defineProperty(this, "exportHooks", {
23
- enumerable: true,
24
- configurable: true,
25
- writable: true,
26
- value: void 0
27
- });
28
- Object.defineProperty(this, "fontSize", {
29
- enumerable: true,
30
- configurable: true,
31
- writable: true,
32
- value: void 0
33
- });
34
- Object.defineProperty(this, "fontWeight", {
35
- enumerable: true,
36
- configurable: true,
37
- writable: true,
38
- value: void 0
39
- });
40
- Object.defineProperty(this, "fontFamily", {
23
+ Object.defineProperty(this, "position", {
41
24
  enumerable: true,
42
25
  configurable: true,
43
26
  writable: true,
44
- value: void 0
27
+ value: 'top'
45
28
  });
46
- Object.defineProperty(this, "align", {
29
+ Object.defineProperty(this, "variant", {
47
30
  enumerable: true,
48
31
  configurable: true,
49
32
  writable: true,
50
- value: void 0
33
+ value: 'title'
51
34
  });
52
- Object.defineProperty(this, "marginTop", {
35
+ Object.defineProperty(this, "exportHooks", {
53
36
  enumerable: true,
54
37
  configurable: true,
55
38
  writable: true,
56
39
  value: void 0
57
40
  });
58
- Object.defineProperty(this, "marginBottom", {
41
+ Object.defineProperty(this, "textComponent", {
59
42
  enumerable: true,
60
43
  configurable: true,
61
44
  writable: true,
62
45
  value: void 0
63
46
  });
64
- this.display = config.display ?? true;
65
- this.text = config.text;
66
- this.fontSize = config.fontSize ?? 18;
67
- this.fontWeight = config.fontWeight ?? 'bold';
68
- this.fontFamily = config.fontFamily;
69
- this.align = config.align ?? 'center';
70
- this.marginTop = config.marginTop ?? 10;
71
- this.marginBottom = config.marginBottom ?? 15;
47
+ this.textComponent = new Text({
48
+ ...config,
49
+ position: 'top',
50
+ variant: 'title',
51
+ exportHooks: config.exportHooks,
52
+ }, 'title');
53
+ this.display = this.textComponent.display;
54
+ this.text = this.textComponent.text;
72
55
  this.exportHooks = config.exportHooks;
73
56
  }
74
57
  getExportConfig() {
58
+ const config = this.textComponent.getExportConfig();
75
59
  return {
76
- display: this.display,
77
- text: this.text,
78
- fontSize: this.fontSize,
79
- fontWeight: this.fontWeight,
80
- fontFamily: this.fontFamily,
81
- align: this.align,
82
- marginTop: this.marginTop,
83
- marginBottom: this.marginBottom,
60
+ display: config.display,
61
+ text: config.text,
62
+ fontSize: config.fontSize,
63
+ fontWeight: config.fontWeight,
64
+ fontFamily: config.fontFamily,
65
+ align: config.align,
66
+ color: config.color,
67
+ lineHeight: config.lineHeight,
68
+ marginTop: config.marginTop,
69
+ marginBottom: config.marginBottom,
84
70
  };
85
71
  }
86
72
  createExportComponent(override) {
@@ -90,49 +76,10 @@ export class Title {
90
76
  exportHooks: this.exportHooks,
91
77
  });
92
78
  }
93
- /**
94
- * Returns the space required by the title
95
- */
96
- getRequiredSpace() {
97
- if (!this.display) {
98
- return {
99
- width: 0,
100
- height: 0,
101
- position: 'top',
102
- };
103
- }
104
- return {
105
- width: 0, // Title spans full width
106
- height: this.marginTop + this.fontSize + this.marginBottom,
107
- position: 'top',
108
- };
79
+ getRequiredSpace(theme) {
80
+ return this.textComponent.getRequiredSpace(theme);
109
81
  }
110
82
  render(svg, theme, width, x = 0, y = 0) {
111
- if (!this.display) {
112
- return;
113
- }
114
- const titleGroup = svg
115
- .append('g')
116
- .attr('class', 'title')
117
- .attr('transform', `translate(${x}, ${y})`);
118
- let textX = width / 2;
119
- let textAnchor = 'middle';
120
- if (this.align === 'left') {
121
- textX = theme.margins.left;
122
- textAnchor = 'start';
123
- }
124
- else if (this.align === 'right') {
125
- textX = width - theme.margins.right;
126
- textAnchor = 'end';
127
- }
128
- titleGroup
129
- .append('text')
130
- .attr('x', textX)
131
- .attr('y', this.marginTop + this.fontSize)
132
- .attr('text-anchor', textAnchor)
133
- .attr('font-size', `${this.fontSize}px`)
134
- .attr('font-weight', this.fontWeight)
135
- .attr('font-family', this.fontFamily || theme.fontFamily)
136
- .text(this.text);
83
+ this.textComponent.render(svg, theme, width, x, y);
137
84
  }
138
85
  }
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
  };
@@ -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