@twinfinity/printing 6.0.1-ci.28952-beta → 6.0.2-beta
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 +3 -3
- package/dist/BuildInfo.js +3 -3
- package/dist/BuildInfo.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout/analyzeTemplate.js.map +1 -1
- package/dist/layout/clipUtils.js.map +1 -1
- package/dist/layout/colorConstants.js.map +1 -1
- package/dist/layout/elementKinds.js.map +1 -1
- package/dist/layout/exportDrawing.js.map +1 -1
- package/dist/layout/filters.js.map +1 -1
- package/dist/layout/geometryConstants.js.map +1 -1
- package/dist/layout/index.js.map +1 -1
- package/dist/layout/itemBuilders.js.map +1 -1
- package/dist/layout/labelsRender.js.map +1 -1
- package/dist/layout/legendConstants.js.map +1 -1
- package/dist/layout/legendRender.js.map +1 -1
- package/dist/layout/legendRows.js.map +1 -1
- package/dist/layout/legendUtils.js.map +1 -1
- package/dist/layout/metadata.js.map +1 -1
- package/dist/layout/model.js.map +1 -1
- package/dist/layout/options.js.map +1 -1
- package/dist/layout/presets.js.map +1 -1
- package/dist/layout/qrRender.js.map +1 -1
- package/dist/layout/renderDrawing.js.map +1 -1
- package/dist/layout/renderUtils.js.map +1 -1
- package/dist/layout/sectionValidation.js.map +1 -1
- package/dist/layout/templateVariables.js.map +1 -1
- package/dist/layout/typeGuards.js.map +1 -1
- package/dist/layout/types.js.map +1 -1
- package/dist/layout/utils.js.map +1 -1
- package/dist/layout/validation.js.map +1 -1
- package/dist/layout/validationHelpers.js.map +1 -1
- package/dist/layout/validationUtils.js.map +1 -1
- package/dist/layout/viewportPrep.js.map +1 -1
- package/dist/layout/viewportRender.js.map +1 -1
- package/dist/layout/viewportUtils.js.map +1 -1
- package/dist/layout/warnings.js.map +1 -1
- package/dist/printToPdf.js.map +1 -1
- package/dist/printToSvg.js.map +1 -1
- package/dist/sections.js.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/BuildInfo.ts +41 -41
- package/src/index.ts +10 -10
- package/src/layout/analyzeTemplate.ts +218 -218
- package/src/layout/clipUtils.ts +193 -193
- package/src/layout/colorConstants.ts +16 -16
- package/src/layout/elementKinds.ts +35 -35
- package/src/layout/exportDrawing.ts +206 -206
- package/src/layout/filters.ts +80 -80
- package/src/layout/geometryConstants.ts +11 -11
- package/src/layout/index.ts +671 -671
- package/src/layout/itemBuilders.ts +159 -159
- package/src/layout/labelsRender.ts +170 -170
- package/src/layout/legendConstants.ts +11 -11
- package/src/layout/legendRender.ts +543 -543
- package/src/layout/legendRows.ts +62 -62
- package/src/layout/legendUtils.ts +144 -144
- package/src/layout/metadata.ts +210 -210
- package/src/layout/model.ts +372 -372
- package/src/layout/options.ts +17 -17
- package/src/layout/presets.ts +51 -51
- package/src/layout/qrRender.ts +126 -126
- package/src/layout/qrcode.d.ts +2 -2
- package/src/layout/renderDrawing.ts +710 -710
- package/src/layout/renderUtils.ts +138 -138
- package/src/layout/sectionValidation.ts +2 -2
- package/src/layout/templateVariables.ts +317 -317
- package/src/layout/typeGuards.ts +9 -9
- package/src/layout/types.ts +835 -835
- package/src/layout/utils.ts +85 -85
- package/src/layout/validation.ts +392 -392
- package/src/layout/validationHelpers.ts +59 -59
- package/src/layout/validationUtils.ts +6 -6
- package/src/layout/viewportPrep.ts +351 -351
- package/src/layout/viewportRender.ts +442 -442
- package/src/layout/viewportUtils.ts +50 -50
- package/src/layout/warnings.ts +3 -3
- package/src/printToPdf.ts +108 -108
- package/src/printToSvg.ts +188 -188
- package/src/sections.ts +1019 -1019
- package/src/types.ts +49 -49
|
@@ -1,543 +1,543 @@
|
|
|
1
|
-
import { LegendElement, PageSpec, StyleMap } from './types';
|
|
2
|
-
import {
|
|
3
|
-
DEFAULT_LEGEND_SWATCH_WIDTH_MM,
|
|
4
|
-
DEFAULT_LEGEND_CELL_PADDING_MM,
|
|
5
|
-
DEFAULT_LEGEND_BORDER_WIDTH_MM,
|
|
6
|
-
DEFAULT_LEGEND_GRID_WIDTH_MM,
|
|
7
|
-
DEFAULT_LEGEND_HEADER_WIDTH_MM,
|
|
8
|
-
DEFAULT_LEGEND_OFFSET_MM,
|
|
9
|
-
MAX_LEGEND_ROWS
|
|
10
|
-
} from './legendConstants';
|
|
11
|
-
import {
|
|
12
|
-
computeLegendColumnWidths,
|
|
13
|
-
computeLegendTextYOffset,
|
|
14
|
-
parseLegendColor,
|
|
15
|
-
formatLegendValue
|
|
16
|
-
} from './legendUtils';
|
|
17
|
-
import { resolvePath } from './renderUtils';
|
|
18
|
-
import { matchesV2Filter } from './filters';
|
|
19
|
-
import { pointInPolygon } from './clipUtils';
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Convert HSL to RGB for PDF compatibility.
|
|
23
|
-
* H: 0-360, S: 0-100, L: 0-100
|
|
24
|
-
* Returns [R, G, B] where each component is 0-255
|
|
25
|
-
*/
|
|
26
|
-
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
|
27
|
-
h = h / 360;
|
|
28
|
-
s = s / 100;
|
|
29
|
-
l = l / 100;
|
|
30
|
-
|
|
31
|
-
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
32
|
-
const p = 2 * l - q;
|
|
33
|
-
|
|
34
|
-
const hueToRgb = (t: number): number => {
|
|
35
|
-
if (t < 0) t += 1;
|
|
36
|
-
if (t > 1) t -= 1;
|
|
37
|
-
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
38
|
-
if (t < 1 / 2) return q;
|
|
39
|
-
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
40
|
-
return p;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const r = Math.round(hueToRgb(h + 1 / 3) * 255);
|
|
44
|
-
const g = Math.round(hueToRgb(h) * 255);
|
|
45
|
-
const b = Math.round(hueToRgb(h - 1 / 3) * 255);
|
|
46
|
-
|
|
47
|
-
return [r, g, b];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Generate distinct colors for legend rows.
|
|
52
|
-
* Uses a predefined palette or auto-generates RGB colors (for PDF compatibility).
|
|
53
|
-
*/
|
|
54
|
-
function generateLegendColors(count: number, palette?: 'auto' | string[]): string[] {
|
|
55
|
-
if (Array.isArray(palette)) {
|
|
56
|
-
// Use provided palette, cycling if needed
|
|
57
|
-
const colors: string[] = [];
|
|
58
|
-
for (let i = 0; i < count; i++) {
|
|
59
|
-
colors.push(palette[i % palette.length]);
|
|
60
|
-
}
|
|
61
|
-
return colors;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Auto-generate colors using HSL then convert to RGB for PDF compatibility
|
|
65
|
-
const colors: string[] = [];
|
|
66
|
-
for (let i = 0; i < count; i++) {
|
|
67
|
-
const hue = (i * 360) / count;
|
|
68
|
-
const saturation = 65 + (i % 3) * 10;
|
|
69
|
-
const lightness = 50 + (i % 2) * 10;
|
|
70
|
-
|
|
71
|
-
// Convert to RGB for jsPDF compatibility
|
|
72
|
-
const [r, g, b] = hslToRgb(hue, saturation, lightness);
|
|
73
|
-
colors.push(`rgb(${r},${g},${b})`);
|
|
74
|
-
}
|
|
75
|
-
return colors;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
import { isRecord, getProp } from './typeGuards';
|
|
79
|
-
|
|
80
|
-
export const applyLegendFilter = (
|
|
81
|
-
legendData: unknown[],
|
|
82
|
-
legend: LegendElement,
|
|
83
|
-
viewports: unknown[],
|
|
84
|
-
viewportClipPolygonsLocal: Record<string, Array<Array<{ x: number; y: number }>>>,
|
|
85
|
-
viewportRenderInfo: Record<string, { transform?: unknown }>
|
|
86
|
-
): unknown[] => {
|
|
87
|
-
// Filter by class + where
|
|
88
|
-
let rows = Array.isArray(legendData)
|
|
89
|
-
? legendData.filter((row) => matchesV2Filter(row, legend.class, legend.where))
|
|
90
|
-
: [];
|
|
91
|
-
if (!rows.length) return rows;
|
|
92
|
-
|
|
93
|
-
if (legend.clipToViewport) {
|
|
94
|
-
const targetViewportId = legend.viewport;
|
|
95
|
-
const polys = targetViewportId ? viewportClipPolygonsLocal[targetViewportId] : null;
|
|
96
|
-
const vpInfo = targetViewportId ? viewportRenderInfo[targetViewportId] : null;
|
|
97
|
-
const t = isRecord(vpInfo) ? getProp(vpInfo, 'transform') : null;
|
|
98
|
-
if (polys && polys.length && isRecord(t)) {
|
|
99
|
-
const viewMinX = getProp(t, 'viewMinX');
|
|
100
|
-
const viewMaxZ = getProp(t, 'viewMaxZ');
|
|
101
|
-
if (typeof viewMinX === 'number' && typeof viewMaxZ === 'number') {
|
|
102
|
-
rows = rows.filter((row) => {
|
|
103
|
-
const rowX = getProp(row, 'x');
|
|
104
|
-
const rowZ = getProp(row, 'z');
|
|
105
|
-
if (!Number.isFinite(rowX) || !Number.isFinite(rowZ)) return false;
|
|
106
|
-
const viewX = Number(rowX) - viewMinX;
|
|
107
|
-
const viewY = viewMaxZ - Number(rowZ);
|
|
108
|
-
return polys.some((poly) => pointInPolygon(poly, viewX, viewY));
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return rows;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
export interface LegendResult {
|
|
118
|
-
svg: string;
|
|
119
|
-
colorMap?: Map<string, string>; // Maps property value to color
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export const buildLegendGroup = (
|
|
123
|
-
legend: LegendElement,
|
|
124
|
-
styles: StyleMap | undefined,
|
|
125
|
-
page: PageSpec,
|
|
126
|
-
marginMm: number | undefined,
|
|
127
|
-
legendData: unknown[],
|
|
128
|
-
fillOpacity?: number
|
|
129
|
-
): LegendResult | null => {
|
|
130
|
-
if (!legend || !legendData || !legendData.length) return null;
|
|
131
|
-
|
|
132
|
-
// Debug: Log raw input data
|
|
133
|
-
// console.log(`[LEGEND INPUT] Received ${legendData.length} items for legend "${legend.id}"`);
|
|
134
|
-
// if (legend.groupBy) {
|
|
135
|
-
// // Count duplicates by groupBy property
|
|
136
|
-
// const counts = new Map<string, number>();
|
|
137
|
-
// for (const item of legendData) {
|
|
138
|
-
// const key = String(resolvePath(item, legend.groupBy) ?? '');
|
|
139
|
-
// counts.set(key, (counts.get(key) || 0) + 1);
|
|
140
|
-
// }
|
|
141
|
-
// console.log('[LEGEND INPUT] Items per group (before grouping):', Array.from(counts.entries()).map(([k, v]) => `${k}: ${v}`).join(', '));
|
|
142
|
-
// }
|
|
143
|
-
|
|
144
|
-
const pageMargin = typeof marginMm === 'number' ? marginMm : 0;
|
|
145
|
-
const fontStyle = (legend.textStyle && styles?.[legend.textStyle]) || styles?.['body'];
|
|
146
|
-
const fontSizeMm = typeof fontStyle?.fontSizeMm === 'number' ? fontStyle.fontSizeMm : 5;
|
|
147
|
-
const fontFamily = fontStyle?.fontFamily ?? 'Arial, sans-serif';
|
|
148
|
-
const fontColor = isRecord(fontStyle) ? (getProp(fontStyle, 'color') ?? '#000') : '#000';
|
|
149
|
-
|
|
150
|
-
// Show color column if enabled (but swatches will be empty if no fills configured)
|
|
151
|
-
const showColorColumn = legend.showColorColumn !== false;
|
|
152
|
-
const colorColumnWidthMm = showColorColumn ? (legend.colorColumnWidthMm ?? DEFAULT_LEGEND_SWATCH_WIDTH_MM) : 0;
|
|
153
|
-
const hasFills = fillOpacity !== undefined;
|
|
154
|
-
// console.log(`[LEGEND RENDER] fillOpacity=${fillOpacity}, hasFills=${hasFills}, showColorColumn=${showColorColumn}`);
|
|
155
|
-
const cellPaddingMm = legend.cellPaddingMm ?? DEFAULT_LEGEND_CELL_PADDING_MM;
|
|
156
|
-
const borderStrokeWidthMm = legend.borderStrokeWidthMm ?? DEFAULT_LEGEND_BORDER_WIDTH_MM;
|
|
157
|
-
const borderStrokeColor = legend.borderStrokeColor ?? '#000000';
|
|
158
|
-
const gridStrokeWidthMm = legend.gridStrokeWidthMm ?? DEFAULT_LEGEND_GRID_WIDTH_MM;
|
|
159
|
-
const gridStrokeColor = legend.gridStrokeColor ?? '#000000';
|
|
160
|
-
const headerStrokeWidthMm = legend.headerStrokeWidthMm ?? DEFAULT_LEGEND_HEADER_WIDTH_MM;
|
|
161
|
-
const headerStrokeColor = legend.headerStrokeColor ?? '#000000';
|
|
162
|
-
const anchor = legend.anchor ?? 'top-right';
|
|
163
|
-
const legendMarginMm = legend.marginMm ?? DEFAULT_LEGEND_OFFSET_MM;
|
|
164
|
-
|
|
165
|
-
// Group rows if groupBy is specified
|
|
166
|
-
let processedData = legendData;
|
|
167
|
-
if (legend.groupBy && typeof legend.groupBy === 'string') {
|
|
168
|
-
const groups = new Map<string, unknown[]>();
|
|
169
|
-
|
|
170
|
-
// Group rows by the groupBy property value
|
|
171
|
-
for (const row of legendData) {
|
|
172
|
-
const groupKey = String(resolvePath(row, legend.groupBy) ?? '');
|
|
173
|
-
if (!groups.has(groupKey)) {
|
|
174
|
-
groups.set(groupKey, []);
|
|
175
|
-
}
|
|
176
|
-
groups.get(groupKey)!.push(row);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// console.log(`[LEGEND GROUPING] Total input rows: ${legendData.length}, Groups created: ${groups.size}`);
|
|
180
|
-
// for (const [key, rows] of groups.entries()) {
|
|
181
|
-
// if (rows.length > 1) {
|
|
182
|
-
// console.log(`[LEGEND GROUPING] Group "${key}": ${rows.length} rows`);
|
|
183
|
-
// }
|
|
184
|
-
// }
|
|
185
|
-
|
|
186
|
-
// Aggregate each group
|
|
187
|
-
processedData = Array.from(groups.values()).map((groupRows) => {
|
|
188
|
-
if (groupRows.length === 0) return groupRows[0];
|
|
189
|
-
if (groupRows.length === 1) return groupRows[0];
|
|
190
|
-
|
|
191
|
-
// Create aggregated row (ensure first row is object-like before spreading)
|
|
192
|
-
const firstRow = groupRows[0];
|
|
193
|
-
const aggregated: any = typeof firstRow === 'object' && firstRow !== null ? { ...(firstRow as any) } : {};
|
|
194
|
-
|
|
195
|
-
// For each column, aggregate values
|
|
196
|
-
for (const col of legend.columns) {
|
|
197
|
-
const values: any[] = [];
|
|
198
|
-
for (const row of groupRows) {
|
|
199
|
-
const val = resolvePath(row, col.property);
|
|
200
|
-
if (val !== undefined && val !== null) {
|
|
201
|
-
values.push(val);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
if (values.length === 0) continue;
|
|
206
|
-
|
|
207
|
-
// Check if all values are numeric - if so, sum them
|
|
208
|
-
const allNumeric = values.every((v) => {
|
|
209
|
-
const num = typeof v === 'number' ? v : parseFloat(String(v));
|
|
210
|
-
return !isNaN(num) && Number.isFinite(num);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
if (allNumeric) {
|
|
214
|
-
// Sum numerical values
|
|
215
|
-
const sum = values.reduce((acc, v) => {
|
|
216
|
-
const num = typeof v === 'number' ? v : parseFloat(String(v));
|
|
217
|
-
return acc + num;
|
|
218
|
-
}, 0);
|
|
219
|
-
|
|
220
|
-
// Debug logging for aggregation
|
|
221
|
-
// if (groupRows.length > 1 && legend.groupBy) {
|
|
222
|
-
// const groupKey = resolvePath(groupRows[0], legend.groupBy);
|
|
223
|
-
// console.log(`[LEGEND AGGREGATION] Group "${groupKey}", Column "${col.property}":`, {
|
|
224
|
-
// itemCount: values.length,
|
|
225
|
-
// values: values,
|
|
226
|
-
// sum: sum
|
|
227
|
-
// });
|
|
228
|
-
// }
|
|
229
|
-
|
|
230
|
-
// Set the aggregated value in the nested property structure
|
|
231
|
-
const pathParts = col.property.split('.');
|
|
232
|
-
let target = aggregated;
|
|
233
|
-
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
234
|
-
if (!target[pathParts[i]]) {
|
|
235
|
-
target[pathParts[i]] = {};
|
|
236
|
-
}
|
|
237
|
-
target = target[pathParts[i]];
|
|
238
|
-
}
|
|
239
|
-
target[pathParts[pathParts.length - 1]] = sum;
|
|
240
|
-
}
|
|
241
|
-
// For non-numeric, keep the first value (already in aggregated from spread)
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return aggregated;
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Generate colors if assignColors is enabled
|
|
249
|
-
let generatedColors: string[] | null = null;
|
|
250
|
-
let colorMap: Map<string, string> | undefined;
|
|
251
|
-
|
|
252
|
-
if (legend.assignColors?.enabled) {
|
|
253
|
-
const palette = legend.assignColors.palette ?? 'auto';
|
|
254
|
-
generatedColors = generateLegendColors(processedData.length, palette);
|
|
255
|
-
// console.log(`[LEGEND COLORS] assignColors enabled, generated ${generatedColors.length} colors`);
|
|
256
|
-
|
|
257
|
-
// Build color map based on groupBy property
|
|
258
|
-
if (legend.groupBy) {
|
|
259
|
-
colorMap = new Map();
|
|
260
|
-
for (let i = 0; i < processedData.length; i++) {
|
|
261
|
-
const row = processedData[i];
|
|
262
|
-
const groupValue = String(resolvePath(row, legend.groupBy) ?? '');
|
|
263
|
-
if (!colorMap.has(groupValue)) {
|
|
264
|
-
colorMap.set(groupValue, generatedColors[i]);
|
|
265
|
-
// console.log(`[LEGEND COLORS] Mapped "${groupValue}" -> ${generatedColors[i]}`);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
// console.log(`[LEGEND COLORS] Generated ${colorMap.size} colors for groupBy="${legend.groupBy}"`);
|
|
269
|
-
} else {
|
|
270
|
-
console.warn(`[LEGEND COLORS] assignColors enabled but no groupBy property set`);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const rowsMapped = processedData.map((row: unknown, index: number) => {
|
|
275
|
-
const values = legend.columns.map((col) => {
|
|
276
|
-
const raw = resolvePath(row, col.property);
|
|
277
|
-
return formatLegendValue(raw, col.format as any);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
// Use generated color if assignColors is enabled, otherwise sample from item.color
|
|
281
|
-
let swatchColor: string;
|
|
282
|
-
if (generatedColors) {
|
|
283
|
-
swatchColor = generatedColors[index];
|
|
284
|
-
} else {
|
|
285
|
-
const colorValue = getProp(row, 'color');
|
|
286
|
-
const rgba = parseLegendColor(colorValue, values[0] ?? JSON.stringify(row));
|
|
287
|
-
swatchColor = `rgb(${rgba[0]},${rgba[1]},${rgba[2]})`;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
return { values, color: swatchColor };
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
const columnLabels = legend.columns.map((c) => ({
|
|
294
|
-
label: c.label,
|
|
295
|
-
minWidthMm: c.minWidthMm,
|
|
296
|
-
maxWidthMm: c.maxWidthMm
|
|
297
|
-
}));
|
|
298
|
-
const { colWidthsMm, columnCount } = computeLegendColumnWidths(
|
|
299
|
-
columnLabels,
|
|
300
|
-
rowsMapped,
|
|
301
|
-
fontSizeMm,
|
|
302
|
-
cellPaddingMm,
|
|
303
|
-
colorColumnWidthMm,
|
|
304
|
-
fontFamily
|
|
305
|
-
);
|
|
306
|
-
|
|
307
|
-
const rowHeightMm = fontSizeMm + cellPaddingMm * 2;
|
|
308
|
-
const headerHeightMm = rowHeightMm;
|
|
309
|
-
const tableWidthMm = colWidthsMm.reduce((sum, w) => sum + w, 0);
|
|
310
|
-
|
|
311
|
-
// Check if summary is enabled
|
|
312
|
-
const summaryEnabled = legend.summary?.enabled === true;
|
|
313
|
-
const summaryLabel = legend.summary?.label ?? 'Total';
|
|
314
|
-
const summaryRowCount = summaryEnabled ? 1 : 0;
|
|
315
|
-
|
|
316
|
-
const tableHeightMm = headerHeightMm + rowsMapped.length * rowHeightMm + summaryRowCount * rowHeightMm;
|
|
317
|
-
|
|
318
|
-
const anchorPos = legend.anchorPositionMm;
|
|
319
|
-
const hasAnchorPos =
|
|
320
|
-
anchorPos &&
|
|
321
|
-
typeof anchorPos.xMm === 'number' &&
|
|
322
|
-
Number.isFinite(anchorPos.xMm) &&
|
|
323
|
-
typeof anchorPos.yMm === 'number' &&
|
|
324
|
-
Number.isFinite(anchorPos.yMm);
|
|
325
|
-
|
|
326
|
-
let originXmm = pageMargin + legendMarginMm;
|
|
327
|
-
let originYmm = pageMargin + legendMarginMm;
|
|
328
|
-
|
|
329
|
-
if (hasAnchorPos) {
|
|
330
|
-
const { xMm, yMm } = anchorPos as { xMm: number; yMm: number };
|
|
331
|
-
switch (anchor) {
|
|
332
|
-
case 'top-left':
|
|
333
|
-
originXmm = xMm;
|
|
334
|
-
originYmm = yMm;
|
|
335
|
-
break;
|
|
336
|
-
case 'top-right':
|
|
337
|
-
originXmm = xMm - tableWidthMm;
|
|
338
|
-
originYmm = yMm;
|
|
339
|
-
break;
|
|
340
|
-
case 'bottom-left':
|
|
341
|
-
originXmm = xMm;
|
|
342
|
-
originYmm = yMm - tableHeightMm;
|
|
343
|
-
break;
|
|
344
|
-
case 'bottom-right':
|
|
345
|
-
default:
|
|
346
|
-
originXmm = xMm - tableWidthMm;
|
|
347
|
-
originYmm = yMm - tableHeightMm;
|
|
348
|
-
break;
|
|
349
|
-
}
|
|
350
|
-
} else {
|
|
351
|
-
if (anchor === 'top-right' || anchor === 'bottom-right') {
|
|
352
|
-
originXmm = page.widthMm - pageMargin - legendMarginMm - tableWidthMm;
|
|
353
|
-
}
|
|
354
|
-
if (anchor === 'bottom-right' || anchor === 'bottom-left') {
|
|
355
|
-
originYmm = page.heightMm - pageMargin - legendMarginMm - tableHeightMm;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
const colXmm: number[] = [];
|
|
360
|
-
let acc = 0;
|
|
361
|
-
colWidthsMm.forEach((w) => {
|
|
362
|
-
colXmm.push(acc);
|
|
363
|
-
acc += w;
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
const rects: string[] = [];
|
|
367
|
-
const texts: string[] = [];
|
|
368
|
-
const lines: string[] = [];
|
|
369
|
-
|
|
370
|
-
const dataColStartIdx = showColorColumn ? 1 : 0;
|
|
371
|
-
const contentOriginXmm = originXmm + colWidthsMm[0];
|
|
372
|
-
const headerBottomYmm = originYmm + headerHeightMm;
|
|
373
|
-
|
|
374
|
-
lines.push(
|
|
375
|
-
`<line x1="${contentOriginXmm}" y1="${originYmm}" x2="${originXmm + tableWidthMm}" y2="${originYmm}" stroke="${borderStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
376
|
-
);
|
|
377
|
-
lines.push(
|
|
378
|
-
`<line x1="${contentOriginXmm}" y1="${originYmm}" x2="${contentOriginXmm}" y2="${headerBottomYmm}" stroke="${borderStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
379
|
-
);
|
|
380
|
-
// Bottom border: if summary enabled, only under numbers (not under label)
|
|
381
|
-
const bottomBorderStartX = summaryEnabled ? contentOriginXmm : originXmm;
|
|
382
|
-
lines.push(
|
|
383
|
-
`<line x1="${bottomBorderStartX}" y1="${originYmm + tableHeightMm}" x2="${originXmm + tableWidthMm}" y2="${originYmm + tableHeightMm}" stroke="${borderStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
384
|
-
);
|
|
385
|
-
lines.push(
|
|
386
|
-
`<line x1="${originXmm + tableWidthMm}" y1="${originYmm}" x2="${originXmm + tableWidthMm}" y2="${originYmm + tableHeightMm}" stroke="${borderStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
387
|
-
);
|
|
388
|
-
// Left edge - stops before summary row if summary enabled
|
|
389
|
-
const leftEdgeEndYmm = summaryEnabled
|
|
390
|
-
? originYmm + headerHeightMm + rowsMapped.length * rowHeightMm
|
|
391
|
-
: originYmm + tableHeightMm;
|
|
392
|
-
lines.push(
|
|
393
|
-
`<line x1="${originXmm}" y1="${headerBottomYmm}" x2="${originXmm}" y2="${leftEdgeEndYmm}" stroke="${borderStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
394
|
-
);
|
|
395
|
-
|
|
396
|
-
// Color column divider - extends all the way down
|
|
397
|
-
lines.push(
|
|
398
|
-
`<line x1="${contentOriginXmm}" y1="${headerBottomYmm}" x2="${contentOriginXmm}" y2="${originYmm + tableHeightMm}" stroke="${borderStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
399
|
-
);
|
|
400
|
-
|
|
401
|
-
lines.push(
|
|
402
|
-
`<line x1="${originXmm}" y1="${originYmm + headerHeightMm}" x2="${originXmm + tableWidthMm}" y2="${originYmm + headerHeightMm}" stroke="${headerStrokeColor}" stroke-width="${headerStrokeWidthMm}" />`
|
|
403
|
-
);
|
|
404
|
-
|
|
405
|
-
for (let i = 2; i < columnCount; i++) {
|
|
406
|
-
const x = originXmm + colXmm[i];
|
|
407
|
-
lines.push(
|
|
408
|
-
`<line x1="${x}" y1="${originYmm}" x2="${x}" y2="${originYmm + tableHeightMm}" stroke="${gridStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
for (let r = 0; r <= rowsMapped.length; r++) {
|
|
413
|
-
// Skip last grid line if summary enabled (we draw a heavier separator line instead)
|
|
414
|
-
if (r === rowsMapped.length && summaryEnabled) continue;
|
|
415
|
-
|
|
416
|
-
const y = originYmm + headerHeightMm + r * rowHeightMm;
|
|
417
|
-
lines.push(
|
|
418
|
-
`<line x1="${originXmm}" y1="${y}" x2="${originXmm + tableWidthMm}" y2="${y}" stroke="${gridStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
419
|
-
);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const textYOffsetMm = computeLegendTextYOffset(fontSizeMm);
|
|
423
|
-
legend.columns.slice(0, columnCount - dataColStartIdx).forEach((col, idx) => {
|
|
424
|
-
const colStart = originXmm + colXmm[idx + dataColStartIdx];
|
|
425
|
-
const colWidth = colWidthsMm[idx + dataColStartIdx];
|
|
426
|
-
const innerPad = cellPaddingMm + gridStrokeWidthMm * 0.5;
|
|
427
|
-
const x =
|
|
428
|
-
col.align === 'center'
|
|
429
|
-
? colStart + colWidth / 2
|
|
430
|
-
: col.align === 'right'
|
|
431
|
-
? colStart + colWidth - innerPad
|
|
432
|
-
: colStart + innerPad;
|
|
433
|
-
const y = originYmm + headerHeightMm / 2 + textYOffsetMm;
|
|
434
|
-
const anchorText = col.align === 'center' ? 'middle' : col.align === 'right' ? 'end' : 'start';
|
|
435
|
-
texts.push(
|
|
436
|
-
`<text x="${x}" y="${y}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" dominant-baseline="middle" text-anchor="${anchorText}">${col.label}</text>`
|
|
437
|
-
);
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
const swatchOpacity = typeof fillOpacity === 'number' ? Math.min(Math.max(fillOpacity, 0), 1) : 1;
|
|
441
|
-
|
|
442
|
-
rowsMapped.slice(0, MAX_LEGEND_ROWS).forEach((row, rIndex) => {
|
|
443
|
-
const yTop = originYmm + headerHeightMm + rIndex * rowHeightMm;
|
|
444
|
-
|
|
445
|
-
if (showColorColumn) {
|
|
446
|
-
// Only show fill colors if the viewport has fills configured
|
|
447
|
-
if (hasFills) {
|
|
448
|
-
// row.color can be either a CSS color string (from generated colors) or RGB string
|
|
449
|
-
const swatchFill = row.color; // Already a CSS color string
|
|
450
|
-
const opacityAttr = swatchOpacity < 1 ? ` fill-opacity="${swatchOpacity.toFixed(3)}"` : '';
|
|
451
|
-
rects.push(
|
|
452
|
-
`<rect x="${originXmm + colXmm[0]}" y="${yTop}" width="${colWidthsMm[0]}" height="${rowHeightMm}" fill="${swatchFill}"${opacityAttr} stroke="none" />`
|
|
453
|
-
);
|
|
454
|
-
} else {
|
|
455
|
-
// Empty swatch when no fills configured - just the border cell
|
|
456
|
-
rects.push(
|
|
457
|
-
`<rect x="${originXmm + colXmm[0]}" y="${yTop}" width="${colWidthsMm[0]}" height="${rowHeightMm}" fill="none" stroke="none" />`
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
legend.columns.slice(0, columnCount - dataColStartIdx).forEach((col, cIdx) => {
|
|
463
|
-
const text = row.values[cIdx] ?? '';
|
|
464
|
-
if (!text) return;
|
|
465
|
-
const colStart = originXmm + colXmm[cIdx + dataColStartIdx];
|
|
466
|
-
const colWidth = colWidthsMm[cIdx + dataColStartIdx];
|
|
467
|
-
const innerPad = cellPaddingMm + gridStrokeWidthMm * 0.5;
|
|
468
|
-
const x =
|
|
469
|
-
col.align === 'center'
|
|
470
|
-
? colStart + colWidth / 2
|
|
471
|
-
: col.align === 'right'
|
|
472
|
-
? colStart + colWidth - innerPad
|
|
473
|
-
: colStart + innerPad;
|
|
474
|
-
const y = yTop + rowHeightMm / 2 + textYOffsetMm;
|
|
475
|
-
const anchorText = col.align === 'center' ? 'middle' : col.align === 'right' ? 'end' : 'start';
|
|
476
|
-
texts.push(
|
|
477
|
-
`<text x="${x}" y="${y}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" dominant-baseline="middle" text-anchor="${anchorText}">${text}</text>`
|
|
478
|
-
);
|
|
479
|
-
});
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
// Render summary row (if enabled)
|
|
483
|
-
if (summaryEnabled) {
|
|
484
|
-
const summaryRowIndex = rowsMapped.length;
|
|
485
|
-
const yTop = originYmm + headerHeightMm + summaryRowIndex * rowHeightMm;
|
|
486
|
-
|
|
487
|
-
// Calculate summary values for columns with summary: true
|
|
488
|
-
const summaryValues = legend.columns.map((col) => {
|
|
489
|
-
if (!col.summary) return '';
|
|
490
|
-
|
|
491
|
-
// Sum all numerical values in this column (use processedData to reflect grouping)
|
|
492
|
-
let sum = 0;
|
|
493
|
-
let hasValues = false;
|
|
494
|
-
for (const dataRow of processedData) {
|
|
495
|
-
const rawValue = resolvePath(dataRow, col.property);
|
|
496
|
-
const numValue = typeof rawValue === 'number' ? rawValue : parseFloat(String(rawValue));
|
|
497
|
-
if (!isNaN(numValue) && Number.isFinite(numValue)) {
|
|
498
|
-
sum += numValue;
|
|
499
|
-
hasValues = true;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// Format the sum using the column's format settings
|
|
504
|
-
return hasValues ? formatLegendValue(sum, col.format as any) : '';
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
// Add heavier border line above summary row
|
|
508
|
-
lines.push(
|
|
509
|
-
`<line x1="${originXmm}" y1="${yTop}" x2="${originXmm + tableWidthMm}" y2="${yTop}" stroke="${headerStrokeColor}" stroke-width="${headerStrokeWidthMm}" />`
|
|
510
|
-
);
|
|
511
|
-
|
|
512
|
-
// Render summary label aligned with first data column
|
|
513
|
-
const innerPad = cellPaddingMm + gridStrokeWidthMm * 0.5;
|
|
514
|
-
const labelX = contentOriginXmm + innerPad;
|
|
515
|
-
const labelY = yTop + rowHeightMm / 2 + textYOffsetMm;
|
|
516
|
-
texts.push(
|
|
517
|
-
`<text x="${labelX}" y="${labelY}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" font-weight="bold" dominant-baseline="middle" text-anchor="start">${summaryLabel}</text>`
|
|
518
|
-
);
|
|
519
|
-
|
|
520
|
-
// Render summary values for other columns
|
|
521
|
-
legend.columns.slice(1, columnCount - dataColStartIdx).forEach((col, cIdx) => {
|
|
522
|
-
const summaryText = summaryValues[cIdx + 1] ?? '';
|
|
523
|
-
if (!summaryText) return;
|
|
524
|
-
|
|
525
|
-
const colStart = originXmm + colXmm[cIdx + dataColStartIdx + 1];
|
|
526
|
-
const colWidth = colWidthsMm[cIdx + dataColStartIdx + 1];
|
|
527
|
-
const x =
|
|
528
|
-
col.align === 'center'
|
|
529
|
-
? colStart + colWidth / 2
|
|
530
|
-
: col.align === 'right'
|
|
531
|
-
? colStart + colWidth - innerPad
|
|
532
|
-
: colStart + innerPad;
|
|
533
|
-
const y = yTop + rowHeightMm / 2 + textYOffsetMm;
|
|
534
|
-
const anchorText = col.align === 'center' ? 'middle' : col.align === 'right' ? 'end' : 'start';
|
|
535
|
-
texts.push(
|
|
536
|
-
`<text x="${x}" y="${y}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" font-weight="bold" dominant-baseline="middle" text-anchor="${anchorText}">${summaryText}</text>`
|
|
537
|
-
);
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
const svg = [`<g id="legend">`, ...rects, ...lines, ...texts, `</g>`].join('');
|
|
542
|
-
return { svg, colorMap };
|
|
543
|
-
};
|
|
1
|
+
import { LegendElement, PageSpec, StyleMap } from './types';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_LEGEND_SWATCH_WIDTH_MM,
|
|
4
|
+
DEFAULT_LEGEND_CELL_PADDING_MM,
|
|
5
|
+
DEFAULT_LEGEND_BORDER_WIDTH_MM,
|
|
6
|
+
DEFAULT_LEGEND_GRID_WIDTH_MM,
|
|
7
|
+
DEFAULT_LEGEND_HEADER_WIDTH_MM,
|
|
8
|
+
DEFAULT_LEGEND_OFFSET_MM,
|
|
9
|
+
MAX_LEGEND_ROWS
|
|
10
|
+
} from './legendConstants';
|
|
11
|
+
import {
|
|
12
|
+
computeLegendColumnWidths,
|
|
13
|
+
computeLegendTextYOffset,
|
|
14
|
+
parseLegendColor,
|
|
15
|
+
formatLegendValue
|
|
16
|
+
} from './legendUtils';
|
|
17
|
+
import { resolvePath } from './renderUtils';
|
|
18
|
+
import { matchesV2Filter } from './filters';
|
|
19
|
+
import { pointInPolygon } from './clipUtils';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Convert HSL to RGB for PDF compatibility.
|
|
23
|
+
* H: 0-360, S: 0-100, L: 0-100
|
|
24
|
+
* Returns [R, G, B] where each component is 0-255
|
|
25
|
+
*/
|
|
26
|
+
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
|
27
|
+
h = h / 360;
|
|
28
|
+
s = s / 100;
|
|
29
|
+
l = l / 100;
|
|
30
|
+
|
|
31
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
32
|
+
const p = 2 * l - q;
|
|
33
|
+
|
|
34
|
+
const hueToRgb = (t: number): number => {
|
|
35
|
+
if (t < 0) t += 1;
|
|
36
|
+
if (t > 1) t -= 1;
|
|
37
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
38
|
+
if (t < 1 / 2) return q;
|
|
39
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
40
|
+
return p;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const r = Math.round(hueToRgb(h + 1 / 3) * 255);
|
|
44
|
+
const g = Math.round(hueToRgb(h) * 255);
|
|
45
|
+
const b = Math.round(hueToRgb(h - 1 / 3) * 255);
|
|
46
|
+
|
|
47
|
+
return [r, g, b];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate distinct colors for legend rows.
|
|
52
|
+
* Uses a predefined palette or auto-generates RGB colors (for PDF compatibility).
|
|
53
|
+
*/
|
|
54
|
+
function generateLegendColors(count: number, palette?: 'auto' | string[]): string[] {
|
|
55
|
+
if (Array.isArray(palette)) {
|
|
56
|
+
// Use provided palette, cycling if needed
|
|
57
|
+
const colors: string[] = [];
|
|
58
|
+
for (let i = 0; i < count; i++) {
|
|
59
|
+
colors.push(palette[i % palette.length]);
|
|
60
|
+
}
|
|
61
|
+
return colors;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Auto-generate colors using HSL then convert to RGB for PDF compatibility
|
|
65
|
+
const colors: string[] = [];
|
|
66
|
+
for (let i = 0; i < count; i++) {
|
|
67
|
+
const hue = (i * 360) / count;
|
|
68
|
+
const saturation = 65 + (i % 3) * 10;
|
|
69
|
+
const lightness = 50 + (i % 2) * 10;
|
|
70
|
+
|
|
71
|
+
// Convert to RGB for jsPDF compatibility
|
|
72
|
+
const [r, g, b] = hslToRgb(hue, saturation, lightness);
|
|
73
|
+
colors.push(`rgb(${r},${g},${b})`);
|
|
74
|
+
}
|
|
75
|
+
return colors;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
import { isRecord, getProp } from './typeGuards';
|
|
79
|
+
|
|
80
|
+
export const applyLegendFilter = (
|
|
81
|
+
legendData: unknown[],
|
|
82
|
+
legend: LegendElement,
|
|
83
|
+
viewports: unknown[],
|
|
84
|
+
viewportClipPolygonsLocal: Record<string, Array<Array<{ x: number; y: number }>>>,
|
|
85
|
+
viewportRenderInfo: Record<string, { transform?: unknown }>
|
|
86
|
+
): unknown[] => {
|
|
87
|
+
// Filter by class + where
|
|
88
|
+
let rows = Array.isArray(legendData)
|
|
89
|
+
? legendData.filter((row) => matchesV2Filter(row, legend.class, legend.where))
|
|
90
|
+
: [];
|
|
91
|
+
if (!rows.length) return rows;
|
|
92
|
+
|
|
93
|
+
if (legend.clipToViewport) {
|
|
94
|
+
const targetViewportId = legend.viewport;
|
|
95
|
+
const polys = targetViewportId ? viewportClipPolygonsLocal[targetViewportId] : null;
|
|
96
|
+
const vpInfo = targetViewportId ? viewportRenderInfo[targetViewportId] : null;
|
|
97
|
+
const t = isRecord(vpInfo) ? getProp(vpInfo, 'transform') : null;
|
|
98
|
+
if (polys && polys.length && isRecord(t)) {
|
|
99
|
+
const viewMinX = getProp(t, 'viewMinX');
|
|
100
|
+
const viewMaxZ = getProp(t, 'viewMaxZ');
|
|
101
|
+
if (typeof viewMinX === 'number' && typeof viewMaxZ === 'number') {
|
|
102
|
+
rows = rows.filter((row) => {
|
|
103
|
+
const rowX = getProp(row, 'x');
|
|
104
|
+
const rowZ = getProp(row, 'z');
|
|
105
|
+
if (!Number.isFinite(rowX) || !Number.isFinite(rowZ)) return false;
|
|
106
|
+
const viewX = Number(rowX) - viewMinX;
|
|
107
|
+
const viewY = viewMaxZ - Number(rowZ);
|
|
108
|
+
return polys.some((poly) => pointInPolygon(poly, viewX, viewY));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return rows;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export interface LegendResult {
|
|
118
|
+
svg: string;
|
|
119
|
+
colorMap?: Map<string, string>; // Maps property value to color
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const buildLegendGroup = (
|
|
123
|
+
legend: LegendElement,
|
|
124
|
+
styles: StyleMap | undefined,
|
|
125
|
+
page: PageSpec,
|
|
126
|
+
marginMm: number | undefined,
|
|
127
|
+
legendData: unknown[],
|
|
128
|
+
fillOpacity?: number
|
|
129
|
+
): LegendResult | null => {
|
|
130
|
+
if (!legend || !legendData || !legendData.length) return null;
|
|
131
|
+
|
|
132
|
+
// Debug: Log raw input data
|
|
133
|
+
// console.log(`[LEGEND INPUT] Received ${legendData.length} items for legend "${legend.id}"`);
|
|
134
|
+
// if (legend.groupBy) {
|
|
135
|
+
// // Count duplicates by groupBy property
|
|
136
|
+
// const counts = new Map<string, number>();
|
|
137
|
+
// for (const item of legendData) {
|
|
138
|
+
// const key = String(resolvePath(item, legend.groupBy) ?? '');
|
|
139
|
+
// counts.set(key, (counts.get(key) || 0) + 1);
|
|
140
|
+
// }
|
|
141
|
+
// console.log('[LEGEND INPUT] Items per group (before grouping):', Array.from(counts.entries()).map(([k, v]) => `${k}: ${v}`).join(', '));
|
|
142
|
+
// }
|
|
143
|
+
|
|
144
|
+
const pageMargin = typeof marginMm === 'number' ? marginMm : 0;
|
|
145
|
+
const fontStyle = (legend.textStyle && styles?.[legend.textStyle]) || styles?.['body'];
|
|
146
|
+
const fontSizeMm = typeof fontStyle?.fontSizeMm === 'number' ? fontStyle.fontSizeMm : 5;
|
|
147
|
+
const fontFamily = fontStyle?.fontFamily ?? 'Arial, sans-serif';
|
|
148
|
+
const fontColor = isRecord(fontStyle) ? (getProp(fontStyle, 'color') ?? '#000') : '#000';
|
|
149
|
+
|
|
150
|
+
// Show color column if enabled (but swatches will be empty if no fills configured)
|
|
151
|
+
const showColorColumn = legend.showColorColumn !== false;
|
|
152
|
+
const colorColumnWidthMm = showColorColumn ? (legend.colorColumnWidthMm ?? DEFAULT_LEGEND_SWATCH_WIDTH_MM) : 0;
|
|
153
|
+
const hasFills = fillOpacity !== undefined;
|
|
154
|
+
// console.log(`[LEGEND RENDER] fillOpacity=${fillOpacity}, hasFills=${hasFills}, showColorColumn=${showColorColumn}`);
|
|
155
|
+
const cellPaddingMm = legend.cellPaddingMm ?? DEFAULT_LEGEND_CELL_PADDING_MM;
|
|
156
|
+
const borderStrokeWidthMm = legend.borderStrokeWidthMm ?? DEFAULT_LEGEND_BORDER_WIDTH_MM;
|
|
157
|
+
const borderStrokeColor = legend.borderStrokeColor ?? '#000000';
|
|
158
|
+
const gridStrokeWidthMm = legend.gridStrokeWidthMm ?? DEFAULT_LEGEND_GRID_WIDTH_MM;
|
|
159
|
+
const gridStrokeColor = legend.gridStrokeColor ?? '#000000';
|
|
160
|
+
const headerStrokeWidthMm = legend.headerStrokeWidthMm ?? DEFAULT_LEGEND_HEADER_WIDTH_MM;
|
|
161
|
+
const headerStrokeColor = legend.headerStrokeColor ?? '#000000';
|
|
162
|
+
const anchor = legend.anchor ?? 'top-right';
|
|
163
|
+
const legendMarginMm = legend.marginMm ?? DEFAULT_LEGEND_OFFSET_MM;
|
|
164
|
+
|
|
165
|
+
// Group rows if groupBy is specified
|
|
166
|
+
let processedData = legendData;
|
|
167
|
+
if (legend.groupBy && typeof legend.groupBy === 'string') {
|
|
168
|
+
const groups = new Map<string, unknown[]>();
|
|
169
|
+
|
|
170
|
+
// Group rows by the groupBy property value
|
|
171
|
+
for (const row of legendData) {
|
|
172
|
+
const groupKey = String(resolvePath(row, legend.groupBy) ?? '');
|
|
173
|
+
if (!groups.has(groupKey)) {
|
|
174
|
+
groups.set(groupKey, []);
|
|
175
|
+
}
|
|
176
|
+
groups.get(groupKey)!.push(row);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// console.log(`[LEGEND GROUPING] Total input rows: ${legendData.length}, Groups created: ${groups.size}`);
|
|
180
|
+
// for (const [key, rows] of groups.entries()) {
|
|
181
|
+
// if (rows.length > 1) {
|
|
182
|
+
// console.log(`[LEGEND GROUPING] Group "${key}": ${rows.length} rows`);
|
|
183
|
+
// }
|
|
184
|
+
// }
|
|
185
|
+
|
|
186
|
+
// Aggregate each group
|
|
187
|
+
processedData = Array.from(groups.values()).map((groupRows) => {
|
|
188
|
+
if (groupRows.length === 0) return groupRows[0];
|
|
189
|
+
if (groupRows.length === 1) return groupRows[0];
|
|
190
|
+
|
|
191
|
+
// Create aggregated row (ensure first row is object-like before spreading)
|
|
192
|
+
const firstRow = groupRows[0];
|
|
193
|
+
const aggregated: any = typeof firstRow === 'object' && firstRow !== null ? { ...(firstRow as any) } : {};
|
|
194
|
+
|
|
195
|
+
// For each column, aggregate values
|
|
196
|
+
for (const col of legend.columns) {
|
|
197
|
+
const values: any[] = [];
|
|
198
|
+
for (const row of groupRows) {
|
|
199
|
+
const val = resolvePath(row, col.property);
|
|
200
|
+
if (val !== undefined && val !== null) {
|
|
201
|
+
values.push(val);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (values.length === 0) continue;
|
|
206
|
+
|
|
207
|
+
// Check if all values are numeric - if so, sum them
|
|
208
|
+
const allNumeric = values.every((v) => {
|
|
209
|
+
const num = typeof v === 'number' ? v : parseFloat(String(v));
|
|
210
|
+
return !isNaN(num) && Number.isFinite(num);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (allNumeric) {
|
|
214
|
+
// Sum numerical values
|
|
215
|
+
const sum = values.reduce((acc, v) => {
|
|
216
|
+
const num = typeof v === 'number' ? v : parseFloat(String(v));
|
|
217
|
+
return acc + num;
|
|
218
|
+
}, 0);
|
|
219
|
+
|
|
220
|
+
// Debug logging for aggregation
|
|
221
|
+
// if (groupRows.length > 1 && legend.groupBy) {
|
|
222
|
+
// const groupKey = resolvePath(groupRows[0], legend.groupBy);
|
|
223
|
+
// console.log(`[LEGEND AGGREGATION] Group "${groupKey}", Column "${col.property}":`, {
|
|
224
|
+
// itemCount: values.length,
|
|
225
|
+
// values: values,
|
|
226
|
+
// sum: sum
|
|
227
|
+
// });
|
|
228
|
+
// }
|
|
229
|
+
|
|
230
|
+
// Set the aggregated value in the nested property structure
|
|
231
|
+
const pathParts = col.property.split('.');
|
|
232
|
+
let target = aggregated;
|
|
233
|
+
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
234
|
+
if (!target[pathParts[i]]) {
|
|
235
|
+
target[pathParts[i]] = {};
|
|
236
|
+
}
|
|
237
|
+
target = target[pathParts[i]];
|
|
238
|
+
}
|
|
239
|
+
target[pathParts[pathParts.length - 1]] = sum;
|
|
240
|
+
}
|
|
241
|
+
// For non-numeric, keep the first value (already in aggregated from spread)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return aggregated;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Generate colors if assignColors is enabled
|
|
249
|
+
let generatedColors: string[] | null = null;
|
|
250
|
+
let colorMap: Map<string, string> | undefined;
|
|
251
|
+
|
|
252
|
+
if (legend.assignColors?.enabled) {
|
|
253
|
+
const palette = legend.assignColors.palette ?? 'auto';
|
|
254
|
+
generatedColors = generateLegendColors(processedData.length, palette);
|
|
255
|
+
// console.log(`[LEGEND COLORS] assignColors enabled, generated ${generatedColors.length} colors`);
|
|
256
|
+
|
|
257
|
+
// Build color map based on groupBy property
|
|
258
|
+
if (legend.groupBy) {
|
|
259
|
+
colorMap = new Map();
|
|
260
|
+
for (let i = 0; i < processedData.length; i++) {
|
|
261
|
+
const row = processedData[i];
|
|
262
|
+
const groupValue = String(resolvePath(row, legend.groupBy) ?? '');
|
|
263
|
+
if (!colorMap.has(groupValue)) {
|
|
264
|
+
colorMap.set(groupValue, generatedColors[i]);
|
|
265
|
+
// console.log(`[LEGEND COLORS] Mapped "${groupValue}" -> ${generatedColors[i]}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// console.log(`[LEGEND COLORS] Generated ${colorMap.size} colors for groupBy="${legend.groupBy}"`);
|
|
269
|
+
} else {
|
|
270
|
+
console.warn(`[LEGEND COLORS] assignColors enabled but no groupBy property set`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const rowsMapped = processedData.map((row: unknown, index: number) => {
|
|
275
|
+
const values = legend.columns.map((col) => {
|
|
276
|
+
const raw = resolvePath(row, col.property);
|
|
277
|
+
return formatLegendValue(raw, col.format as any);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Use generated color if assignColors is enabled, otherwise sample from item.color
|
|
281
|
+
let swatchColor: string;
|
|
282
|
+
if (generatedColors) {
|
|
283
|
+
swatchColor = generatedColors[index];
|
|
284
|
+
} else {
|
|
285
|
+
const colorValue = getProp(row, 'color');
|
|
286
|
+
const rgba = parseLegendColor(colorValue, values[0] ?? JSON.stringify(row));
|
|
287
|
+
swatchColor = `rgb(${rgba[0]},${rgba[1]},${rgba[2]})`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { values, color: swatchColor };
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const columnLabels = legend.columns.map((c) => ({
|
|
294
|
+
label: c.label,
|
|
295
|
+
minWidthMm: c.minWidthMm,
|
|
296
|
+
maxWidthMm: c.maxWidthMm
|
|
297
|
+
}));
|
|
298
|
+
const { colWidthsMm, columnCount } = computeLegendColumnWidths(
|
|
299
|
+
columnLabels,
|
|
300
|
+
rowsMapped,
|
|
301
|
+
fontSizeMm,
|
|
302
|
+
cellPaddingMm,
|
|
303
|
+
colorColumnWidthMm,
|
|
304
|
+
fontFamily
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const rowHeightMm = fontSizeMm + cellPaddingMm * 2;
|
|
308
|
+
const headerHeightMm = rowHeightMm;
|
|
309
|
+
const tableWidthMm = colWidthsMm.reduce((sum, w) => sum + w, 0);
|
|
310
|
+
|
|
311
|
+
// Check if summary is enabled
|
|
312
|
+
const summaryEnabled = legend.summary?.enabled === true;
|
|
313
|
+
const summaryLabel = legend.summary?.label ?? 'Total';
|
|
314
|
+
const summaryRowCount = summaryEnabled ? 1 : 0;
|
|
315
|
+
|
|
316
|
+
const tableHeightMm = headerHeightMm + rowsMapped.length * rowHeightMm + summaryRowCount * rowHeightMm;
|
|
317
|
+
|
|
318
|
+
const anchorPos = legend.anchorPositionMm;
|
|
319
|
+
const hasAnchorPos =
|
|
320
|
+
anchorPos &&
|
|
321
|
+
typeof anchorPos.xMm === 'number' &&
|
|
322
|
+
Number.isFinite(anchorPos.xMm) &&
|
|
323
|
+
typeof anchorPos.yMm === 'number' &&
|
|
324
|
+
Number.isFinite(anchorPos.yMm);
|
|
325
|
+
|
|
326
|
+
let originXmm = pageMargin + legendMarginMm;
|
|
327
|
+
let originYmm = pageMargin + legendMarginMm;
|
|
328
|
+
|
|
329
|
+
if (hasAnchorPos) {
|
|
330
|
+
const { xMm, yMm } = anchorPos as { xMm: number; yMm: number };
|
|
331
|
+
switch (anchor) {
|
|
332
|
+
case 'top-left':
|
|
333
|
+
originXmm = xMm;
|
|
334
|
+
originYmm = yMm;
|
|
335
|
+
break;
|
|
336
|
+
case 'top-right':
|
|
337
|
+
originXmm = xMm - tableWidthMm;
|
|
338
|
+
originYmm = yMm;
|
|
339
|
+
break;
|
|
340
|
+
case 'bottom-left':
|
|
341
|
+
originXmm = xMm;
|
|
342
|
+
originYmm = yMm - tableHeightMm;
|
|
343
|
+
break;
|
|
344
|
+
case 'bottom-right':
|
|
345
|
+
default:
|
|
346
|
+
originXmm = xMm - tableWidthMm;
|
|
347
|
+
originYmm = yMm - tableHeightMm;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
if (anchor === 'top-right' || anchor === 'bottom-right') {
|
|
352
|
+
originXmm = page.widthMm - pageMargin - legendMarginMm - tableWidthMm;
|
|
353
|
+
}
|
|
354
|
+
if (anchor === 'bottom-right' || anchor === 'bottom-left') {
|
|
355
|
+
originYmm = page.heightMm - pageMargin - legendMarginMm - tableHeightMm;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const colXmm: number[] = [];
|
|
360
|
+
let acc = 0;
|
|
361
|
+
colWidthsMm.forEach((w) => {
|
|
362
|
+
colXmm.push(acc);
|
|
363
|
+
acc += w;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const rects: string[] = [];
|
|
367
|
+
const texts: string[] = [];
|
|
368
|
+
const lines: string[] = [];
|
|
369
|
+
|
|
370
|
+
const dataColStartIdx = showColorColumn ? 1 : 0;
|
|
371
|
+
const contentOriginXmm = originXmm + colWidthsMm[0];
|
|
372
|
+
const headerBottomYmm = originYmm + headerHeightMm;
|
|
373
|
+
|
|
374
|
+
lines.push(
|
|
375
|
+
`<line x1="${contentOriginXmm}" y1="${originYmm}" x2="${originXmm + tableWidthMm}" y2="${originYmm}" stroke="${borderStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
376
|
+
);
|
|
377
|
+
lines.push(
|
|
378
|
+
`<line x1="${contentOriginXmm}" y1="${originYmm}" x2="${contentOriginXmm}" y2="${headerBottomYmm}" stroke="${borderStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
379
|
+
);
|
|
380
|
+
// Bottom border: if summary enabled, only under numbers (not under label)
|
|
381
|
+
const bottomBorderStartX = summaryEnabled ? contentOriginXmm : originXmm;
|
|
382
|
+
lines.push(
|
|
383
|
+
`<line x1="${bottomBorderStartX}" y1="${originYmm + tableHeightMm}" x2="${originXmm + tableWidthMm}" y2="${originYmm + tableHeightMm}" stroke="${borderStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
384
|
+
);
|
|
385
|
+
lines.push(
|
|
386
|
+
`<line x1="${originXmm + tableWidthMm}" y1="${originYmm}" x2="${originXmm + tableWidthMm}" y2="${originYmm + tableHeightMm}" stroke="${borderStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
387
|
+
);
|
|
388
|
+
// Left edge - stops before summary row if summary enabled
|
|
389
|
+
const leftEdgeEndYmm = summaryEnabled
|
|
390
|
+
? originYmm + headerHeightMm + rowsMapped.length * rowHeightMm
|
|
391
|
+
: originYmm + tableHeightMm;
|
|
392
|
+
lines.push(
|
|
393
|
+
`<line x1="${originXmm}" y1="${headerBottomYmm}" x2="${originXmm}" y2="${leftEdgeEndYmm}" stroke="${borderStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
// Color column divider - extends all the way down
|
|
397
|
+
lines.push(
|
|
398
|
+
`<line x1="${contentOriginXmm}" y1="${headerBottomYmm}" x2="${contentOriginXmm}" y2="${originYmm + tableHeightMm}" stroke="${borderStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
lines.push(
|
|
402
|
+
`<line x1="${originXmm}" y1="${originYmm + headerHeightMm}" x2="${originXmm + tableWidthMm}" y2="${originYmm + headerHeightMm}" stroke="${headerStrokeColor}" stroke-width="${headerStrokeWidthMm}" />`
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
for (let i = 2; i < columnCount; i++) {
|
|
406
|
+
const x = originXmm + colXmm[i];
|
|
407
|
+
lines.push(
|
|
408
|
+
`<line x1="${x}" y1="${originYmm}" x2="${x}" y2="${originYmm + tableHeightMm}" stroke="${gridStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (let r = 0; r <= rowsMapped.length; r++) {
|
|
413
|
+
// Skip last grid line if summary enabled (we draw a heavier separator line instead)
|
|
414
|
+
if (r === rowsMapped.length && summaryEnabled) continue;
|
|
415
|
+
|
|
416
|
+
const y = originYmm + headerHeightMm + r * rowHeightMm;
|
|
417
|
+
lines.push(
|
|
418
|
+
`<line x1="${originXmm}" y1="${y}" x2="${originXmm + tableWidthMm}" y2="${y}" stroke="${gridStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const textYOffsetMm = computeLegendTextYOffset(fontSizeMm);
|
|
423
|
+
legend.columns.slice(0, columnCount - dataColStartIdx).forEach((col, idx) => {
|
|
424
|
+
const colStart = originXmm + colXmm[idx + dataColStartIdx];
|
|
425
|
+
const colWidth = colWidthsMm[idx + dataColStartIdx];
|
|
426
|
+
const innerPad = cellPaddingMm + gridStrokeWidthMm * 0.5;
|
|
427
|
+
const x =
|
|
428
|
+
col.align === 'center'
|
|
429
|
+
? colStart + colWidth / 2
|
|
430
|
+
: col.align === 'right'
|
|
431
|
+
? colStart + colWidth - innerPad
|
|
432
|
+
: colStart + innerPad;
|
|
433
|
+
const y = originYmm + headerHeightMm / 2 + textYOffsetMm;
|
|
434
|
+
const anchorText = col.align === 'center' ? 'middle' : col.align === 'right' ? 'end' : 'start';
|
|
435
|
+
texts.push(
|
|
436
|
+
`<text x="${x}" y="${y}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" dominant-baseline="middle" text-anchor="${anchorText}">${col.label}</text>`
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const swatchOpacity = typeof fillOpacity === 'number' ? Math.min(Math.max(fillOpacity, 0), 1) : 1;
|
|
441
|
+
|
|
442
|
+
rowsMapped.slice(0, MAX_LEGEND_ROWS).forEach((row, rIndex) => {
|
|
443
|
+
const yTop = originYmm + headerHeightMm + rIndex * rowHeightMm;
|
|
444
|
+
|
|
445
|
+
if (showColorColumn) {
|
|
446
|
+
// Only show fill colors if the viewport has fills configured
|
|
447
|
+
if (hasFills) {
|
|
448
|
+
// row.color can be either a CSS color string (from generated colors) or RGB string
|
|
449
|
+
const swatchFill = row.color; // Already a CSS color string
|
|
450
|
+
const opacityAttr = swatchOpacity < 1 ? ` fill-opacity="${swatchOpacity.toFixed(3)}"` : '';
|
|
451
|
+
rects.push(
|
|
452
|
+
`<rect x="${originXmm + colXmm[0]}" y="${yTop}" width="${colWidthsMm[0]}" height="${rowHeightMm}" fill="${swatchFill}"${opacityAttr} stroke="none" />`
|
|
453
|
+
);
|
|
454
|
+
} else {
|
|
455
|
+
// Empty swatch when no fills configured - just the border cell
|
|
456
|
+
rects.push(
|
|
457
|
+
`<rect x="${originXmm + colXmm[0]}" y="${yTop}" width="${colWidthsMm[0]}" height="${rowHeightMm}" fill="none" stroke="none" />`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
legend.columns.slice(0, columnCount - dataColStartIdx).forEach((col, cIdx) => {
|
|
463
|
+
const text = row.values[cIdx] ?? '';
|
|
464
|
+
if (!text) return;
|
|
465
|
+
const colStart = originXmm + colXmm[cIdx + dataColStartIdx];
|
|
466
|
+
const colWidth = colWidthsMm[cIdx + dataColStartIdx];
|
|
467
|
+
const innerPad = cellPaddingMm + gridStrokeWidthMm * 0.5;
|
|
468
|
+
const x =
|
|
469
|
+
col.align === 'center'
|
|
470
|
+
? colStart + colWidth / 2
|
|
471
|
+
: col.align === 'right'
|
|
472
|
+
? colStart + colWidth - innerPad
|
|
473
|
+
: colStart + innerPad;
|
|
474
|
+
const y = yTop + rowHeightMm / 2 + textYOffsetMm;
|
|
475
|
+
const anchorText = col.align === 'center' ? 'middle' : col.align === 'right' ? 'end' : 'start';
|
|
476
|
+
texts.push(
|
|
477
|
+
`<text x="${x}" y="${y}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" dominant-baseline="middle" text-anchor="${anchorText}">${text}</text>`
|
|
478
|
+
);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Render summary row (if enabled)
|
|
483
|
+
if (summaryEnabled) {
|
|
484
|
+
const summaryRowIndex = rowsMapped.length;
|
|
485
|
+
const yTop = originYmm + headerHeightMm + summaryRowIndex * rowHeightMm;
|
|
486
|
+
|
|
487
|
+
// Calculate summary values for columns with summary: true
|
|
488
|
+
const summaryValues = legend.columns.map((col) => {
|
|
489
|
+
if (!col.summary) return '';
|
|
490
|
+
|
|
491
|
+
// Sum all numerical values in this column (use processedData to reflect grouping)
|
|
492
|
+
let sum = 0;
|
|
493
|
+
let hasValues = false;
|
|
494
|
+
for (const dataRow of processedData) {
|
|
495
|
+
const rawValue = resolvePath(dataRow, col.property);
|
|
496
|
+
const numValue = typeof rawValue === 'number' ? rawValue : parseFloat(String(rawValue));
|
|
497
|
+
if (!isNaN(numValue) && Number.isFinite(numValue)) {
|
|
498
|
+
sum += numValue;
|
|
499
|
+
hasValues = true;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Format the sum using the column's format settings
|
|
504
|
+
return hasValues ? formatLegendValue(sum, col.format as any) : '';
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Add heavier border line above summary row
|
|
508
|
+
lines.push(
|
|
509
|
+
`<line x1="${originXmm}" y1="${yTop}" x2="${originXmm + tableWidthMm}" y2="${yTop}" stroke="${headerStrokeColor}" stroke-width="${headerStrokeWidthMm}" />`
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
// Render summary label aligned with first data column
|
|
513
|
+
const innerPad = cellPaddingMm + gridStrokeWidthMm * 0.5;
|
|
514
|
+
const labelX = contentOriginXmm + innerPad;
|
|
515
|
+
const labelY = yTop + rowHeightMm / 2 + textYOffsetMm;
|
|
516
|
+
texts.push(
|
|
517
|
+
`<text x="${labelX}" y="${labelY}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" font-weight="bold" dominant-baseline="middle" text-anchor="start">${summaryLabel}</text>`
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
// Render summary values for other columns
|
|
521
|
+
legend.columns.slice(1, columnCount - dataColStartIdx).forEach((col, cIdx) => {
|
|
522
|
+
const summaryText = summaryValues[cIdx + 1] ?? '';
|
|
523
|
+
if (!summaryText) return;
|
|
524
|
+
|
|
525
|
+
const colStart = originXmm + colXmm[cIdx + dataColStartIdx + 1];
|
|
526
|
+
const colWidth = colWidthsMm[cIdx + dataColStartIdx + 1];
|
|
527
|
+
const x =
|
|
528
|
+
col.align === 'center'
|
|
529
|
+
? colStart + colWidth / 2
|
|
530
|
+
: col.align === 'right'
|
|
531
|
+
? colStart + colWidth - innerPad
|
|
532
|
+
: colStart + innerPad;
|
|
533
|
+
const y = yTop + rowHeightMm / 2 + textYOffsetMm;
|
|
534
|
+
const anchorText = col.align === 'center' ? 'middle' : col.align === 'right' ? 'end' : 'start';
|
|
535
|
+
texts.push(
|
|
536
|
+
`<text x="${x}" y="${y}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" font-weight="bold" dominant-baseline="middle" text-anchor="${anchorText}">${summaryText}</text>`
|
|
537
|
+
);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const svg = [`<g id="legend">`, ...rects, ...lines, ...texts, `</g>`].join('');
|
|
542
|
+
return { svg, colorMap };
|
|
543
|
+
};
|