@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.
- package/README.md +4 -2
- package/area.d.ts +26 -0
- package/area.js +331 -0
- package/base-chart.d.ts +10 -3
- package/base-chart.js +33 -19
- package/chart-interface.d.ts +1 -1
- package/donut-center-content.d.ts +1 -1
- package/donut-center-content.js +7 -6
- package/donut-chart.d.ts +3 -0
- package/donut-chart.js +23 -6
- package/export-tabular.d.ts +4 -4
- package/export-tabular.js +8 -0
- package/export-xlsx.d.ts +2 -2
- package/gauge-chart.d.ts +140 -0
- package/gauge-chart.js +1051 -0
- package/grouped-data.d.ts +19 -0
- package/grouped-data.js +122 -0
- package/grouped-tabular.d.ts +26 -0
- package/grouped-tabular.js +149 -0
- package/legend.d.ts +15 -1
- package/legend.js +203 -46
- package/package.json +2 -1
- package/pie-chart.d.ts +82 -0
- package/pie-chart.js +675 -0
- package/theme.d.ts +1 -0
- package/theme.js +31 -16
- package/tooltip.d.ts +3 -2
- package/tooltip.js +40 -39
- package/types.d.ts +52 -0
- package/validation.d.ts +4 -0
- package/validation.js +25 -0
- package/x-axis.d.ts +10 -2
- package/x-axis.js +205 -15
- package/xy-chart.d.ts +11 -1
- package/xy-chart.js +310 -93
|
@@ -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;
|
package/grouped-data.js
ADDED
|
@@ -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
|
|
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, "
|
|
36
|
+
Object.defineProperty(this, "paddingX", {
|
|
36
37
|
enumerable: true,
|
|
37
38
|
configurable: true,
|
|
38
39
|
writable: true,
|
|
39
|
-
value:
|
|
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.
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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(
|
|
154
|
+
.attr('transform', `translate(0, ${legendY})`);
|
|
137
155
|
const legendGroups = legend
|
|
138
156
|
.selectAll('g')
|
|
139
|
-
.data(
|
|
157
|
+
.data(layout.positionedItems)
|
|
140
158
|
.join('g')
|
|
141
|
-
.attr('transform', (
|
|
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.
|
|
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",
|