@internetstiftelsen/charts 0.5.1 → 0.6.1

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.
@@ -0,0 +1,19 @@
1
+ import type { ChartData, DataItem, GroupedDataGroup } from './types.js';
2
+ export declare const GROUPED_CATEGORY_ID_KEY = "__iis_grouped_category_id__";
3
+ export declare const GROUPED_CATEGORY_LABEL_KEY = "__iis_grouped_category_label__";
4
+ export declare const GROUPED_GROUP_LABEL_KEY = "__iis_grouped_group_label__";
5
+ export declare const GROUPED_GAP_TICK_PREFIX = "__iis_group_gap__";
6
+ export declare const GROUPED_EMPTY_GROUP_ERROR = "Grouped data requires non-empty group names";
7
+ export type DataSchema = {
8
+ grouped: boolean;
9
+ categoryKey: string;
10
+ metricKeys: string[];
11
+ };
12
+ export type NormalizedChartData = {
13
+ data: DataItem[];
14
+ schema: DataSchema;
15
+ };
16
+ export declare function hasEmptyGroupNames(data: GroupedDataGroup[]): boolean;
17
+ export declare function isGroupedData(data: unknown): data is GroupedDataGroup[];
18
+ export declare function resolveDataSchema(data: ChartData): DataSchema;
19
+ export declare function normalizeChartData(data: ChartData): NormalizedChartData;
@@ -0,0 +1,122 @@
1
+ export const GROUPED_CATEGORY_ID_KEY = '__iis_grouped_category_id__';
2
+ export const GROUPED_CATEGORY_LABEL_KEY = '__iis_grouped_category_label__';
3
+ export const GROUPED_GROUP_LABEL_KEY = '__iis_grouped_group_label__';
4
+ export const GROUPED_GAP_TICK_PREFIX = '__iis_group_gap__';
5
+ export const GROUPED_EMPTY_GROUP_ERROR = 'Grouped data requires non-empty group names';
6
+ function isRecord(value) {
7
+ return typeof value === 'object' && value !== null;
8
+ }
9
+ function hasNonEmptyGroupName(value) {
10
+ return typeof value === 'string' && value.trim().length > 0;
11
+ }
12
+ export function hasEmptyGroupNames(data) {
13
+ return data.some((group) => !hasNonEmptyGroupName(group.group));
14
+ }
15
+ function assertNonEmptyGroupNames(data) {
16
+ if (hasEmptyGroupNames(data)) {
17
+ throw new Error(GROUPED_EMPTY_GROUP_ERROR);
18
+ }
19
+ }
20
+ export function isGroupedData(data) {
21
+ if (!Array.isArray(data) || data.length === 0) {
22
+ return false;
23
+ }
24
+ return data.every((item) => {
25
+ if (!isRecord(item) || !Array.isArray(item['data'])) {
26
+ return false;
27
+ }
28
+ return item['data'].every((row) => {
29
+ return isRecord(row);
30
+ });
31
+ });
32
+ }
33
+ function resolveFlatSchema(data) {
34
+ if (data.length === 0) {
35
+ return {
36
+ grouped: false,
37
+ categoryKey: 'column',
38
+ metricKeys: [],
39
+ };
40
+ }
41
+ const keys = Object.keys(data[0]);
42
+ const categoryKey = keys[0] ?? 'column';
43
+ const metricKeys = [];
44
+ const seen = new Set();
45
+ data.forEach((row) => {
46
+ Object.keys(row).forEach((key) => {
47
+ if (key === categoryKey || seen.has(key)) {
48
+ return;
49
+ }
50
+ seen.add(key);
51
+ metricKeys.push(key);
52
+ });
53
+ });
54
+ return {
55
+ grouped: false,
56
+ categoryKey,
57
+ metricKeys,
58
+ };
59
+ }
60
+ function resolveGroupedSchema(data) {
61
+ let firstRow = null;
62
+ for (const group of data) {
63
+ if (group.data.length > 0) {
64
+ firstRow = group.data[0];
65
+ break;
66
+ }
67
+ }
68
+ const categoryKey = firstRow ? Object.keys(firstRow)[0] ?? 'category' : 'category';
69
+ const metricKeys = [];
70
+ const seen = new Set();
71
+ data.forEach((group) => {
72
+ group.data.forEach((row) => {
73
+ Object.keys(row).forEach((key) => {
74
+ if (key === categoryKey || seen.has(key)) {
75
+ return;
76
+ }
77
+ seen.add(key);
78
+ metricKeys.push(key);
79
+ });
80
+ });
81
+ });
82
+ return {
83
+ grouped: true,
84
+ categoryKey,
85
+ metricKeys,
86
+ };
87
+ }
88
+ export function resolveDataSchema(data) {
89
+ if (isGroupedData(data)) {
90
+ return resolveGroupedSchema(data);
91
+ }
92
+ return resolveFlatSchema(data);
93
+ }
94
+ function normalizeGroupedData(data) {
95
+ assertNonEmptyGroupNames(data);
96
+ const schema = resolveGroupedSchema(data);
97
+ const categoryKey = schema.categoryKey;
98
+ const normalizedRows = data.flatMap((group, groupIndex) => {
99
+ return group.data.map((row, rowIndex) => {
100
+ const categoryLabel = String(row[categoryKey] ?? '');
101
+ return {
102
+ [GROUPED_CATEGORY_ID_KEY]: `g${groupIndex}r${rowIndex}`,
103
+ [GROUPED_CATEGORY_LABEL_KEY]: categoryLabel,
104
+ [GROUPED_GROUP_LABEL_KEY]: String(group.group ?? ''),
105
+ ...row,
106
+ };
107
+ });
108
+ });
109
+ return {
110
+ data: normalizedRows,
111
+ schema,
112
+ };
113
+ }
114
+ export function normalizeChartData(data) {
115
+ if (isGroupedData(data)) {
116
+ return normalizeGroupedData(data);
117
+ }
118
+ return {
119
+ data,
120
+ schema: resolveFlatSchema(data),
121
+ };
122
+ }
@@ -0,0 +1,26 @@
1
+ import type { GroupedDataGroup } from './types.js';
2
+ export type GroupedSheetRow = {
3
+ kind: 'data';
4
+ values: unknown[];
5
+ };
6
+ export type GroupedSheetModel = {
7
+ headers: string[];
8
+ metricKeys: string[];
9
+ categoryKey: string;
10
+ rows: GroupedSheetRow[];
11
+ matrix: unknown[][];
12
+ };
13
+ export type GroupedRebuildResult = {
14
+ ok: true;
15
+ data: GroupedDataGroup[];
16
+ } | {
17
+ ok: false;
18
+ error: 'FIRST_ROW_BLANK_GROUP';
19
+ };
20
+ export type GroupedTabularData = {
21
+ columns: string[];
22
+ rows: string[][];
23
+ };
24
+ export declare function buildGroupedSheetModel(data: GroupedDataGroup[], preferredMetricOrder?: string[]): GroupedSheetModel;
25
+ export declare function rebuildGroupedDataFromSheet(matrix: unknown[][], categoryKey: string, metricKeys: string[]): GroupedRebuildResult;
26
+ export declare function toGroupedTabularData(data: GroupedDataGroup[], preferredMetricOrder?: string[]): GroupedTabularData;
@@ -0,0 +1,149 @@
1
+ import { GROUPED_EMPTY_GROUP_ERROR, hasEmptyGroupNames, resolveDataSchema, } from './grouped-data.js';
2
+ function isBlankValue(value) {
3
+ if (value === null || value === undefined) {
4
+ return true;
5
+ }
6
+ if (typeof value === 'string') {
7
+ return value.trim() === '';
8
+ }
9
+ return false;
10
+ }
11
+ function asString(value) {
12
+ if (value === null || value === undefined) {
13
+ return '';
14
+ }
15
+ return String(value);
16
+ }
17
+ function rowHasMetricContent(values) {
18
+ return values.some((value) => !isBlankValue(value));
19
+ }
20
+ function applyMetricOrder(metricKeys, preferredOrder) {
21
+ if (!preferredOrder || preferredOrder.length === 0) {
22
+ return metricKeys;
23
+ }
24
+ const ordered = [];
25
+ const seen = new Set();
26
+ preferredOrder.forEach((key) => {
27
+ if (metricKeys.includes(key) && !seen.has(key)) {
28
+ seen.add(key);
29
+ ordered.push(key);
30
+ }
31
+ });
32
+ metricKeys.forEach((key) => {
33
+ if (!seen.has(key)) {
34
+ seen.add(key);
35
+ ordered.push(key);
36
+ }
37
+ });
38
+ return ordered;
39
+ }
40
+ export function buildGroupedSheetModel(data, preferredMetricOrder) {
41
+ if (hasEmptyGroupNames(data)) {
42
+ throw new Error(GROUPED_EMPTY_GROUP_ERROR);
43
+ }
44
+ const schema = resolveDataSchema(data);
45
+ const metricKeys = applyMetricOrder(schema.metricKeys, preferredMetricOrder);
46
+ const categoryKey = schema.categoryKey;
47
+ const headers = ['', '', ...metricKeys];
48
+ const rows = [];
49
+ data.forEach((group) => {
50
+ group.data.forEach((row, rowIndex) => {
51
+ const values = [
52
+ rowIndex === 0 ? group.group : '',
53
+ row[categoryKey] ?? '',
54
+ ...metricKeys.map((metricKey) => row[metricKey] ?? ''),
55
+ ];
56
+ rows.push({
57
+ kind: 'data',
58
+ values,
59
+ });
60
+ });
61
+ });
62
+ return {
63
+ headers,
64
+ metricKeys,
65
+ categoryKey,
66
+ rows,
67
+ matrix: rows.map((row) => row.values),
68
+ };
69
+ }
70
+ export function rebuildGroupedDataFromSheet(matrix, categoryKey, metricKeys) {
71
+ const groups = [];
72
+ let currentGroup = '';
73
+ matrix.forEach((row) => {
74
+ const groupCell = row[0];
75
+ const categoryCell = row[1];
76
+ const metricCells = metricKeys.map((_, index) => row[index + 2]);
77
+ const hasCategory = !isBlankValue(categoryCell);
78
+ const hasMetrics = rowHasMetricContent(metricCells);
79
+ const hasGroup = !isBlankValue(groupCell);
80
+ if (!hasCategory && !hasMetrics) {
81
+ return;
82
+ }
83
+ if (hasGroup) {
84
+ currentGroup = asString(groupCell);
85
+ }
86
+ else if (!currentGroup) {
87
+ groups.length = 0;
88
+ return;
89
+ }
90
+ const groupName = currentGroup;
91
+ if (!groupName) {
92
+ return;
93
+ }
94
+ let targetGroup = groups[groups.length - 1];
95
+ if (!targetGroup || targetGroup.group !== groupName) {
96
+ targetGroup = {
97
+ group: groupName,
98
+ data: [],
99
+ };
100
+ groups.push(targetGroup);
101
+ }
102
+ const rebuiltRow = {
103
+ [categoryKey]: categoryCell ?? '',
104
+ };
105
+ metricKeys.forEach((metricKey, index) => {
106
+ rebuiltRow[metricKey] = metricCells[index] ?? '';
107
+ });
108
+ targetGroup.data.push(rebuiltRow);
109
+ });
110
+ const firstDataRow = matrix.find((row) => {
111
+ const categoryCell = row[1];
112
+ const metricCells = metricKeys.map((_, index) => row[index + 2]);
113
+ return !isBlankValue(categoryCell) || rowHasMetricContent(metricCells);
114
+ });
115
+ if (firstDataRow && isBlankValue(firstDataRow[0])) {
116
+ return {
117
+ ok: false,
118
+ error: 'FIRST_ROW_BLANK_GROUP',
119
+ };
120
+ }
121
+ return {
122
+ ok: true,
123
+ data: groups,
124
+ };
125
+ }
126
+ function normalizeTableValue(value) {
127
+ if (value === null || value === undefined) {
128
+ return '';
129
+ }
130
+ if (value instanceof Date) {
131
+ return value.toISOString();
132
+ }
133
+ if (typeof value === 'object') {
134
+ try {
135
+ return JSON.stringify(value);
136
+ }
137
+ catch {
138
+ return String(value);
139
+ }
140
+ }
141
+ return String(value);
142
+ }
143
+ export function toGroupedTabularData(data, preferredMetricOrder) {
144
+ const model = buildGroupedSheetModel(data, preferredMetricOrder);
145
+ return {
146
+ columns: model.headers,
147
+ rows: model.matrix.map((row) => row.map((value) => normalizeTableValue(value))),
148
+ };
149
+ }
package/legend.d.ts CHANGED
@@ -7,18 +7,32 @@ export declare class Legend implements LayoutAwareComponent<LegendConfigBase> {
7
7
  readonly exportHooks?: ExportHooks<LegendConfigBase>;
8
8
  private readonly marginTop;
9
9
  private readonly marginBottom;
10
- private readonly itemHeight;
10
+ private readonly paddingX?;
11
+ private readonly itemSpacingX?;
12
+ private readonly itemSpacingY?;
13
+ private readonly gapBetweenBoxAndText;
11
14
  private visibilityState;
12
15
  private onToggleCallback?;
16
+ private estimatedLayout;
17
+ private estimatedLayoutSignature;
13
18
  constructor(config?: LegendConfig);
14
19
  getExportConfig(): LegendConfigBase;
15
20
  createExportComponent(override?: Partial<LegendConfigBase>): LayoutAwareComponent;
16
21
  setToggleCallback(callback: () => void): void;
17
22
  isSeriesVisible(dataKey: string): boolean;
23
+ estimateLayoutSpace(series: LegendSeries[], theme: ChartTheme, width: number, svg: SVGSVGElement): void;
18
24
  private getCheckmarkPath;
19
25
  /**
20
26
  * Returns the space required by the legend
21
27
  */
22
28
  getRequiredSpace(): ComponentSpace;
23
29
  render(svg: Selection<SVGSVGElement, undefined, null, undefined>, series: LegendSeries[], theme: ChartTheme, width: number, _x?: number, y?: number): void;
30
+ private computeLayout;
31
+ private resolveLayoutSettings;
32
+ private buildLegendItems;
33
+ private measureLegendItemWidths;
34
+ private buildRows;
35
+ private positionRows;
36
+ private getLayoutSignature;
37
+ private getFallbackRowHeight;
24
38
  }
package/legend.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { select } from 'd3';
1
2
  import { getSeriesColor } from './types.js';
2
3
  import { getContrastTextColor, mergeDeep } from './utils.js';
3
4
  export class Legend {
@@ -32,11 +33,29 @@ export class Legend {
32
33
  writable: true,
33
34
  value: void 0
34
35
  });
35
- Object.defineProperty(this, "itemHeight", {
36
+ Object.defineProperty(this, "paddingX", {
36
37
  enumerable: true,
37
38
  configurable: true,
38
39
  writable: true,
39
- value: 15
40
+ value: void 0
41
+ });
42
+ Object.defineProperty(this, "itemSpacingX", {
43
+ enumerable: true,
44
+ configurable: true,
45
+ writable: true,
46
+ value: void 0
47
+ });
48
+ Object.defineProperty(this, "itemSpacingY", {
49
+ enumerable: true,
50
+ configurable: true,
51
+ writable: true,
52
+ value: void 0
53
+ });
54
+ Object.defineProperty(this, "gapBetweenBoxAndText", {
55
+ enumerable: true,
56
+ configurable: true,
57
+ writable: true,
58
+ value: 8
40
59
  });
41
60
  Object.defineProperty(this, "visibilityState", {
42
61
  enumerable: true,
@@ -50,9 +69,24 @@ export class Legend {
50
69
  writable: true,
51
70
  value: void 0
52
71
  });
72
+ Object.defineProperty(this, "estimatedLayout", {
73
+ enumerable: true,
74
+ configurable: true,
75
+ writable: true,
76
+ value: null
77
+ });
78
+ Object.defineProperty(this, "estimatedLayoutSignature", {
79
+ enumerable: true,
80
+ configurable: true,
81
+ writable: true,
82
+ value: null
83
+ });
53
84
  this.position = config?.position || 'bottom';
54
85
  this.marginTop = config?.marginTop ?? 20;
55
86
  this.marginBottom = config?.marginBottom ?? 10;
87
+ this.paddingX = config?.paddingX;
88
+ this.itemSpacingX = config?.itemSpacingX;
89
+ this.itemSpacingY = config?.itemSpacingY;
56
90
  this.exportHooks = config?.exportHooks;
57
91
  }
58
92
  getExportConfig() {
@@ -60,6 +94,9 @@ export class Legend {
60
94
  position: this.position,
61
95
  marginTop: this.marginTop,
62
96
  marginBottom: this.marginBottom,
97
+ paddingX: this.paddingX,
98
+ itemSpacingX: this.itemSpacingX,
99
+ itemSpacingY: this.itemSpacingY,
63
100
  };
64
101
  }
65
102
  createExportComponent(override) {
@@ -77,6 +114,11 @@ export class Legend {
77
114
  isSeriesVisible(dataKey) {
78
115
  return this.visibilityState.get(dataKey) ?? true;
79
116
  }
117
+ estimateLayoutSpace(series, theme, width, svg) {
118
+ const signature = this.getLayoutSignature(series, width, theme);
119
+ this.estimatedLayout = this.computeLayout(series, theme, width, svg);
120
+ this.estimatedLayoutSignature = signature;
121
+ }
80
122
  getCheckmarkPath(size) {
81
123
  const scale = (size / 24) * 0.7;
82
124
  const offsetX = size * 0.15;
@@ -89,56 +131,32 @@ export class Legend {
89
131
  getRequiredSpace() {
90
132
  return {
91
133
  width: 0, // Legend spans full width
92
- height: this.marginTop + this.itemHeight + this.marginBottom,
134
+ height: this.estimatedLayout?.requiredHeight ??
135
+ this.marginTop + this.marginBottom,
93
136
  position: 'bottom',
94
137
  };
95
138
  }
96
139
  render(svg, series, theme, width, _x = 0, y = 0) {
97
- const boxSize = theme.legend.boxSize;
98
- const gapBetweenBoxAndText = 8;
99
- const itemSpacing = 20; // Space between legend items
100
- const legendItems = series.map((s) => ({
101
- label: s.dataKey,
102
- color: getSeriesColor(s),
103
- dataKey: s.dataKey,
104
- }));
105
- legendItems.forEach((item) => {
106
- if (!this.visibilityState.has(item.dataKey)) {
107
- this.visibilityState.set(item.dataKey, true);
108
- }
109
- });
110
- // Create temporary text elements to measure widths
111
- const tempSvg = svg.append('g').style('visibility', 'hidden');
112
- const itemWidths = legendItems.map((item) => {
113
- const textElem = tempSvg
114
- .append('text')
115
- .attr('font-size', `${theme.legend.fontSize}px`)
116
- .attr('font-family', theme.axis.fontFamily)
117
- .text(item.label);
118
- const textWidth = textElem.node()?.getBBox().width || 0;
119
- textElem.remove();
120
- return boxSize + gapBetweenBoxAndText + textWidth;
121
- });
122
- tempSvg.remove();
123
- // Calculate positions for each item
124
- const itemPositions = [];
125
- let currentX = 0;
126
- itemWidths.forEach((itemWidth) => {
127
- itemPositions.push(currentX);
128
- currentX += itemWidth + itemSpacing;
129
- });
130
- const totalLegendWidth = currentX - itemSpacing;
131
- const legendX = (width - totalLegendWidth) / 2;
140
+ const svgNode = svg.node();
141
+ if (!svgNode) {
142
+ return;
143
+ }
144
+ const signature = this.getLayoutSignature(series, width, theme);
145
+ const layout = this.estimatedLayout && this.estimatedLayoutSignature === signature
146
+ ? this.estimatedLayout
147
+ : this.computeLayout(series, theme, width, svgNode);
148
+ this.estimatedLayout = layout;
149
+ this.estimatedLayoutSignature = signature;
132
150
  const legendY = y + this.marginTop;
133
151
  const legend = svg
134
152
  .append('g')
135
153
  .attr('class', 'legend')
136
- .attr('transform', `translate(${legendX}, ${legendY})`);
154
+ .attr('transform', `translate(0, ${legendY})`);
137
155
  const legendGroups = legend
138
156
  .selectAll('g')
139
- .data(legendItems)
157
+ .data(layout.positionedItems)
140
158
  .join('g')
141
- .attr('transform', (_d, i) => `translate(${itemPositions[i]}, 0)`)
159
+ .attr('transform', (d) => `translate(${d.x}, ${d.y})`)
142
160
  .style('cursor', 'pointer')
143
161
  .on('click', (_event, d) => {
144
162
  const currentState = this.visibilityState.get(d.dataKey) ?? true;
@@ -150,8 +168,8 @@ export class Legend {
150
168
  // Add checkbox rect
151
169
  legendGroups
152
170
  .append('rect')
153
- .attr('width', boxSize)
154
- .attr('height', boxSize)
171
+ .attr('width', theme.legend.boxSize)
172
+ .attr('height', theme.legend.boxSize)
155
173
  .attr('fill', (d) => {
156
174
  const isVisible = this.visibilityState.get(d.dataKey) ?? true;
157
175
  return isVisible ? d.color : theme.legend.uncheckedColor;
@@ -160,7 +178,7 @@ export class Legend {
160
178
  // Add checkmark when visible
161
179
  legendGroups
162
180
  .append('path')
163
- .attr('d', this.getCheckmarkPath(boxSize))
181
+ .attr('d', this.getCheckmarkPath(theme.legend.boxSize))
164
182
  .attr('fill', 'none')
165
183
  .attr('stroke', (d) => getContrastTextColor(d.color))
166
184
  .attr('stroke-width', 2)
@@ -173,10 +191,149 @@ export class Legend {
173
191
  // Add label text
174
192
  legendGroups
175
193
  .append('text')
176
- .attr('x', boxSize + gapBetweenBoxAndText)
177
- .attr('y', boxSize / 2 + 4)
194
+ .attr('x', theme.legend.boxSize + this.gapBetweenBoxAndText)
195
+ .attr('y', theme.legend.boxSize / 2 + 4)
178
196
  .attr('font-size', `${theme.legend.fontSize}px`)
179
197
  .attr('font-family', theme.axis.fontFamily)
180
198
  .text((d) => d.label);
181
199
  }
200
+ computeLayout(series, theme, width, svg) {
201
+ const settings = this.resolveLayoutSettings(theme);
202
+ const legendItems = this.buildLegendItems(series);
203
+ legendItems.forEach((item) => {
204
+ if (!this.visibilityState.has(item.dataKey)) {
205
+ this.visibilityState.set(item.dataKey, true);
206
+ }
207
+ });
208
+ const measuredItems = this.measureLegendItemWidths(legendItems, theme, svg);
209
+ const rows = this.buildRows(measuredItems, width, settings);
210
+ const positionedItems = this.positionRows(rows, width, settings);
211
+ const rowCount = Math.max(rows.length, 1);
212
+ const rowsHeight = rows.length > 0
213
+ ? rows.reduce((sum, row) => {
214
+ return sum + row.height;
215
+ }, 0)
216
+ : this.getFallbackRowHeight(theme);
217
+ const requiredHeight = this.marginTop +
218
+ rowsHeight +
219
+ Math.max(0, rowCount - 1) * settings.itemSpacingY +
220
+ this.marginBottom;
221
+ return {
222
+ positionedItems,
223
+ requiredHeight,
224
+ };
225
+ }
226
+ resolveLayoutSettings(theme) {
227
+ return {
228
+ paddingX: this.paddingX ?? theme.legend.paddingX,
229
+ itemSpacingX: this.itemSpacingX ?? theme.legend.itemSpacingX,
230
+ itemSpacingY: this.itemSpacingY ?? theme.legend.itemSpacingY,
231
+ };
232
+ }
233
+ buildLegendItems(series) {
234
+ return series.map((item) => ({
235
+ label: item.dataKey,
236
+ color: getSeriesColor(item),
237
+ dataKey: item.dataKey,
238
+ width: 0,
239
+ height: 0,
240
+ }));
241
+ }
242
+ measureLegendItemWidths(legendItems, theme, svg) {
243
+ const tempSvg = select(svg).append('g').style('visibility', 'hidden');
244
+ const measuredItems = legendItems.map((item) => {
245
+ const textElem = tempSvg
246
+ .append('text')
247
+ .attr('font-size', `${theme.legend.fontSize}px`)
248
+ .attr('font-family', theme.axis.fontFamily)
249
+ .text(item.label);
250
+ const textBBox = textElem.node()?.getBBox();
251
+ const textWidth = textBBox?.width || 0;
252
+ const textHeight = textBBox?.height || theme.legend.fontSize;
253
+ textElem.remove();
254
+ return {
255
+ ...item,
256
+ width: theme.legend.boxSize +
257
+ this.gapBetweenBoxAndText +
258
+ textWidth,
259
+ height: Math.max(theme.legend.boxSize, textHeight),
260
+ };
261
+ });
262
+ tempSvg.remove();
263
+ return measuredItems;
264
+ }
265
+ buildRows(legendItems, width, settings) {
266
+ const rows = [];
267
+ const availableWidth = Math.max(0, width - settings.paddingX * 2);
268
+ let currentRow = {
269
+ items: [],
270
+ width: 0,
271
+ height: 0,
272
+ };
273
+ legendItems.forEach((item) => {
274
+ const nextWidth = currentRow.items.length === 0
275
+ ? item.width
276
+ : currentRow.width + settings.itemSpacingX + item.width;
277
+ if (currentRow.items.length > 0 &&
278
+ nextWidth > availableWidth) {
279
+ rows.push(currentRow);
280
+ currentRow = {
281
+ items: [item],
282
+ width: item.width,
283
+ height: item.height,
284
+ };
285
+ return;
286
+ }
287
+ currentRow.items.push(item);
288
+ currentRow.width = nextWidth;
289
+ currentRow.height = Math.max(currentRow.height, item.height);
290
+ });
291
+ if (currentRow.items.length > 0) {
292
+ rows.push(currentRow);
293
+ }
294
+ return rows;
295
+ }
296
+ positionRows(rows, width, settings) {
297
+ const positionedItems = [];
298
+ const availableWidth = Math.max(0, width - settings.paddingX * 2);
299
+ let accumulatedRowHeight = 0;
300
+ rows.forEach((row, rowIndex) => {
301
+ const rowX = settings.paddingX +
302
+ Math.max(0, (availableWidth - row.width) / 2);
303
+ const rowY = accumulatedRowHeight + rowIndex * settings.itemSpacingY;
304
+ let currentX = rowX;
305
+ row.items.forEach((item, itemIndex) => {
306
+ const centeredY = rowY + (row.height - item.height) / 2;
307
+ positionedItems.push({
308
+ ...item,
309
+ x: currentX,
310
+ y: centeredY,
311
+ });
312
+ currentX += item.width;
313
+ if (itemIndex < row.items.length - 1) {
314
+ currentX += settings.itemSpacingX;
315
+ }
316
+ });
317
+ accumulatedRowHeight += row.height;
318
+ });
319
+ return positionedItems;
320
+ }
321
+ getLayoutSignature(series, width, theme) {
322
+ return [
323
+ width,
324
+ theme.legend.boxSize,
325
+ theme.legend.fontSize,
326
+ theme.axis.fontFamily,
327
+ theme.legend.paddingX,
328
+ theme.legend.itemSpacingX,
329
+ theme.legend.itemSpacingY,
330
+ this.paddingX ?? '',
331
+ this.itemSpacingX ?? '',
332
+ this.itemSpacingY ?? '',
333
+ series.map((item) => item.dataKey).join('|'),
334
+ ].join(':');
335
+ }
336
+ getFallbackRowHeight(theme) {
337
+ return Math.max(theme.legend.boxSize, theme.legend.fontSize);
338
+ }
182
339
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.5.1",
2
+ "version": "0.6.1",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,
@@ -18,6 +18,7 @@
18
18
  "dev": "vite",
19
19
  "build": "tsc -b && vite build",
20
20
  "lint": "eslint .",
21
+ "format": "prettier --write ./src",
21
22
  "preview": "vite preview",
22
23
  "test": "vitest",
23
24
  "test:run": "vitest run",