Ray1ee01 commited on
Commit
84df09d
·
verified ·
1 Parent(s): 7fa502a

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type69_alluvial_diagram/alluvial_diagram_plain_chart_01.js ADDED
@@ -0,0 +1,760 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Alluvial Diagram",
5
+ "chart_name": "alluvial_diagram_plain_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["categorical", "temporal"], ["numerical"], ["categorical", "temporal"]],
8
+ "required_fields_range": [[2, 10], [0, "inf"], [2, 10]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary", "secondary"],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 600,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "no",
20
+ "has_y_axis": "no"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ const jsonData = data || {};
27
+ const chartData = jsonData.data?.data || [];
28
+ const dataColumns = chartUtils.schema.columns(jsonData);
29
+ const colorResolver = chartUtils.color.resolver(jsonData);
30
+ const chartUtilsFormatSample = chartUtils.format.autoText;
31
+ const chartUtilsTextSample = chartUtils.text.estimate;
32
+ const chartUtilsStandard = {
33
+ schema: chartUtils.schema,
34
+ format: chartUtils.format,
35
+ text: chartUtils.text,
36
+ color: chartUtils.color,
37
+ legendLayout: chartUtils.legend.layout,
38
+ legendDraw: chartUtils.legend.draw,
39
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
40
+ };
41
+ const standardChannels = chartUtils.schema.channels(jsonData, {
42
+ x: { fallbackIndex: 0 },
43
+ y: { fallbackIndex: 1 },
44
+ y2: { fallbackIndex: 2 },
45
+ y3: { fallbackIndex: 3 },
46
+ size: { fallbackIndex: 2 },
47
+ group: { fallbackIndex: 2 },
48
+ group2: { fallbackIndex: 3 },
49
+ group3: { fallbackIndex: 4 }
50
+ });
51
+ const variables = jsonData.variables || {};
52
+ const typography = jsonData.typography || {};
53
+ const sourceColors = jsonData.colors || {};
54
+ const images = jsonData.images || {};
55
+
56
+ d3.select(containerSelector).html("");
57
+
58
+ const sourceColumn = dataColumns[0] || {};
59
+ const valueColumn = dataColumns[1] || {};
60
+ const targetColumn = dataColumns[2] || {};
61
+ const sourceField = sourceColumn.name;
62
+ const valueField = valueColumn.name;
63
+ const targetField = targetColumn.name;
64
+
65
+ const width = Math.max(520, Number(variables.width) || 600);
66
+ const height = Math.max(520, Number(variables.height) || 600);
67
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
68
+ const titleFontFamily = typography.title?.font_family || fontFamily;
69
+ const primaryColor = sourceColors.other?.primary || "#19A996";
70
+ const secondaryColor = sourceColors.other?.secondary || "#2D7ED4";
71
+ const textColor = sourceColors.text_color || "#17324A";
72
+ const mutedText = "#607284";
73
+ const panelStroke = "#CDE6F0";
74
+ const softBlue = "#EAF7FB";
75
+ const idSuffix = String(containerSelector || "alluvial-plain-01")
76
+ .replace(/[^a-zA-Z0-9_-]/g, "")
77
+ .slice(0, 24) || "alluvial";
78
+
79
+ const svg = d3.select(containerSelector)
80
+ .append("svg")
81
+ .attr("width", "100%")
82
+ .attr("height", height)
83
+ .attr("viewBox", `0 0 ${width} ${height}`)
84
+ .attr("style", "max-width: 100%; height: auto;")
85
+ .attr("xmlns", "http://www.w3.org/2000/svg")
86
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
87
+ .attr("class", "alluvial-plain-reference-layout");
88
+
89
+ const addEmptyState = message => {
90
+ svg.append("rect")
91
+ .attr("width", width)
92
+ .attr("height", height)
93
+ .attr("fill", "#F7FCFF");
94
+ svg.append("text")
95
+ .attr("x", width / 2)
96
+ .attr("y", height / 2)
97
+ .attr("text-anchor", "middle")
98
+ .attr("fill", textColor)
99
+ .style("font-family", fontFamily)
100
+ .style("font-size", "16px")
101
+ .text(message);
102
+ return svg.node();
103
+ };
104
+
105
+ if (!sourceField || !valueField || !targetField) {
106
+ return addEmptyState("Missing alluvial fields");
107
+ }
108
+
109
+ const readableName = value => String(value ?? "")
110
+ .replace(/_/g, " ")
111
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
112
+ .replace(/\s+/g, " ")
113
+ .trim();
114
+
115
+ const shortLabel = (value, maxChars = 18) => {
116
+ const clean = readableName(value);
117
+ if (clean.length <= maxChars) return clean;
118
+ const words = clean.split(/[\s/,-]+/).filter(Boolean);
119
+ if (words.length > 1) {
120
+ const compact = words.map(word => word[0]).join("").toUpperCase();
121
+ if (compact.length >= 2 && compact.length <= maxChars) return compact;
122
+ const firstLast = `${words[0]} ${words[words.length - 1]}`;
123
+ if (firstLast.length <= maxChars) return firstLast;
124
+ }
125
+ return `${clean.slice(0, Math.max(1, maxChars - 1)).trim()}...`;
126
+ };
127
+
128
+ const truncateText = (value, maxChars = 24) => {
129
+ const clean = readableName(value);
130
+ if (clean.length <= maxChars) return clean;
131
+ return `${clean.slice(0, Math.max(1, maxChars - 1)).trim()}...`;
132
+ };
133
+
134
+ const appendWrappedText = (selection, text, x, y, maxWidth, fontSize, lineHeight, maxLines) => {
135
+ const words = readableName(text).split(/\s+/).filter(Boolean);
136
+ const approxChars = Math.max(8, Math.floor(maxWidth / Math.max(5, fontSize * 0.52)));
137
+ const lines = [];
138
+ let current = "";
139
+ words.forEach(word => {
140
+ const candidate = current ? `${current} ${word}` : word;
141
+ if (candidate.length <= approxChars || !current) {
142
+ current = candidate;
143
+ } else {
144
+ lines.push(current);
145
+ current = word;
146
+ }
147
+ });
148
+ if (current) lines.push(current);
149
+ const visible = lines.slice(0, maxLines);
150
+ if (lines.length > maxLines && visible.length) {
151
+ const last = visible[visible.length - 1];
152
+ visible[visible.length - 1] = last.length > Math.max(7, approxChars - 3)
153
+ ? `${last.slice(0, Math.max(7, approxChars - 3)).trim()}...`
154
+ : `${last}...`;
155
+ }
156
+ visible.forEach((line, index) => {
157
+ selection.append("tspan")
158
+ .attr("x", x)
159
+ .attr("dy", index === 0 ? 0 : lineHeight)
160
+ .text(line);
161
+ });
162
+ selection.attr("x", x).attr("y", y);
163
+ };
164
+
165
+ const unit = String(valueColumn.unit || "").trim();
166
+ const formatNumber = value => {
167
+ const abs = Math.abs(Number(value) || 0);
168
+ const spec = abs >= 1000000 ? ",.1f" : abs >= 1000 ? ",.0f" : abs >= 100 ? ",.0f" : abs >= 10 ? ",.1f" : abs >= 1 ? ",.1f" : ",.2f";
169
+ return d3.format(spec)(abs)
170
+ .replace(/\.0$/, "")
171
+ .replace(/(\.\d*?)0+$/, "$1")
172
+ .replace(/\.$/, "");
173
+ };
174
+ const formatLocalValue = value => {
175
+ const numberValue = Number(value) || 0;
176
+ const sign = numberValue < 0 ? "-" : "";
177
+ const number = formatNumber(numberValue);
178
+ const lowerUnit = unit.toLowerCase();
179
+ if (!unit) return `${sign}${number}`;
180
+ if (unit === "%") return `${sign}${number}%`;
181
+ if (unit === "$") return `${sign}$${number}`;
182
+ if (lowerUnit === "usd") return `${sign}$${number}`;
183
+ if (unit.startsWith("$")) return `${sign}$${number}${unit.slice(1)}`;
184
+ return `${sign}${number} ${unit}`;
185
+ };
186
+ const formatValueCompact = value => {
187
+ const numberValue = Number(value) || 0;
188
+ const abs = Math.abs(numberValue);
189
+ const sign = numberValue < 0 ? "-" : "";
190
+ const lowerUnit = unit.toLowerCase();
191
+ if (unit === "%") return formatLocalValue(value);
192
+ const compactNumber = (() => {
193
+ if (abs >= 1000000000) return `${d3.format(".1f")(abs / 1000000000).replace(/\.0$/, "")}B`;
194
+ if (abs >= 1000000) return `${d3.format(".1f")(abs / 1000000).replace(/\.0$/, "")}M`;
195
+ if (abs >= 1000) return `${d3.format(".1f")(abs / 1000).replace(/\.0$/, "")}K`;
196
+ return formatNumber(abs);
197
+ })();
198
+ if (!unit) return `${sign}${compactNumber}`;
199
+ if (unit === "$" || lowerUnit === "usd") return `${sign}$${compactNumber}`;
200
+ if (unit.startsWith("$")) return `${sign}$${compactNumber}${unit.slice(1)}`;
201
+ return `${sign}${compactNumber} ${unit}`;
202
+ };
203
+
204
+ const rawRows = chartData
205
+ .map((row, index) => ({
206
+ source: String(row[sourceField] ?? "").trim(),
207
+ target: String(row[targetField] ?? "").trim(),
208
+ value: Number(row[valueField]),
209
+ sourceIndex: index
210
+ }))
211
+ .filter(row => row.source && row.target && Number.isFinite(row.value) && row.value >= 0);
212
+
213
+ if (!rawRows.length) {
214
+ return addEmptyState("No alluvial values");
215
+ }
216
+
217
+ const linkMap = new Map();
218
+ rawRows.forEach(row => {
219
+ const key = `${row.source}\u0000${row.target}`;
220
+ if (!linkMap.has(key)) {
221
+ linkMap.set(key, {
222
+ source: row.source,
223
+ target: row.target,
224
+ value: 0,
225
+ firstSourceIndex: row.sourceIndex
226
+ });
227
+ }
228
+ const record = linkMap.get(key);
229
+ record.value += row.value;
230
+ record.firstSourceIndex = Math.min(record.firstSourceIndex, row.sourceIndex);
231
+ });
232
+
233
+ const links = [...linkMap.values()].filter(link => link.value > 0);
234
+ const sourceTotals = new Map();
235
+ const targetTotals = new Map();
236
+ const firstSourceIndex = new Map();
237
+ const firstTargetIndex = new Map();
238
+ rawRows.forEach(row => {
239
+ sourceTotals.set(row.source, (sourceTotals.get(row.source) || 0) + row.value);
240
+ targetTotals.set(row.target, (targetTotals.get(row.target) || 0) + row.value);
241
+ if (!firstSourceIndex.has(row.source)) firstSourceIndex.set(row.source, row.sourceIndex);
242
+ if (!firstTargetIndex.has(row.target)) firstTargetIndex.set(row.target, row.sourceIndex);
243
+ });
244
+
245
+ const sourceNames = [...sourceTotals.keys()]
246
+ .sort((a, b) => sourceTotals.get(b) - sourceTotals.get(a) || firstSourceIndex.get(a) - firstSourceIndex.get(b));
247
+ const targetNames = [...targetTotals.keys()]
248
+ .sort((a, b) => targetTotals.get(b) - targetTotals.get(a) || firstTargetIndex.get(a) - firstTargetIndex.get(b));
249
+
250
+ const fieldColors = sourceColors.field || {};
251
+ const palette = [
252
+ primaryColor,
253
+ secondaryColor,
254
+ "#22B7A6",
255
+ "#5AA8E8",
256
+ "#F5B542",
257
+ "#8267E8",
258
+ "#F06F8F",
259
+ "#5DBB63",
260
+ "#F1844B",
261
+ "#2F6BAE"
262
+ ];
263
+ const safeColor = color => d3.color(color) || d3.color("#2D7ED4");
264
+ const targetColor = target => {
265
+ const index = Math.max(0, targetNames.indexOf(target));
266
+ return fieldColors[target] || palette[(index + 1) % palette.length] || secondaryColor;
267
+ };
268
+ const sourceColor = source => {
269
+ const index = Math.max(0, sourceNames.indexOf(source));
270
+ return fieldColors[source] || d3.interpolateRgb(primaryColor, "#7DDDCB")(sourceNames.length <= 1 ? 0.25 : index / Math.max(1, sourceNames.length - 1));
271
+ };
272
+ const targetIcon = target => images.field?.[target] || images.fields?.[target] || null;
273
+
274
+ const panelX = Math.max(22, width * 0.045);
275
+ const panelY = Math.max(18, height * 0.035);
276
+ const panelW = width - panelX * 2;
277
+ const panelH = height - panelY * 2;
278
+ const flowTop = panelY + Math.max(82, panelH * 0.145);
279
+ const flowBottom = panelY + panelH - Math.max(30, panelH * 0.055);
280
+ const nodeW = Math.max(8, Math.min(16, width * 0.02));
281
+ const sourceNodeX = panelX + panelW * 0.275;
282
+ const targetNodeX = panelX + panelW * 0.685;
283
+ const sourceIconX = panelX + panelW * 0.04;
284
+ const sourceLabelX = sourceNodeX - Math.max(18, panelW * 0.035);
285
+ const targetCardX = targetNodeX + nodeW - 1;
286
+ const targetCardW = Math.max(98, panelX + panelW - targetCardX - Math.max(14, panelW * 0.025));
287
+ const flowH = Math.max(120, flowBottom - flowTop);
288
+ const nodePadding = Math.max(5, Math.min(13, flowH / Math.max(sourceNames.length, targetNames.length, 1) * 0.15));
289
+
290
+ const nodes = [
291
+ ...sourceNames.map((name, index) => ({
292
+ id: `source:${name}`,
293
+ name,
294
+ side: "source",
295
+ value: sourceTotals.get(name),
296
+ order: index
297
+ })),
298
+ ...targetNames.map((name, index) => ({
299
+ id: `target:${name}`,
300
+ name,
301
+ side: "target",
302
+ value: targetTotals.get(name),
303
+ order: index
304
+ }))
305
+ ];
306
+ const sankeyLinks = links.map(link => ({
307
+ source: `source:${link.source}`,
308
+ target: `target:${link.target}`,
309
+ value: link.value,
310
+ sourceName: link.source,
311
+ targetName: link.target
312
+ }));
313
+
314
+ const sankey = d3.sankey()
315
+ .nodeId(d => d.id)
316
+ .nodeWidth(nodeW)
317
+ .nodePadding(nodePadding)
318
+ .extent([[sourceNodeX, flowTop], [targetNodeX + nodeW, flowBottom]])
319
+ .nodeSort((a, b) => b.value - a.value || a.order - b.order);
320
+
321
+ let graph;
322
+ try {
323
+ graph = sankey({
324
+ nodes: nodes.map(node => ({ ...node })),
325
+ links: sankeyLinks.map(link => ({ ...link }))
326
+ });
327
+ } catch (error) {
328
+ return addEmptyState("Unable to render alluvial links");
329
+ }
330
+
331
+ const renderedSources = graph.nodes.filter(node => node.side === "source").sort((a, b) => a.y0 - b.y0);
332
+ const renderedTargets = graph.nodes.filter(node => node.side === "target").sort((a, b) => a.y0 - b.y0);
333
+
334
+ const distributeLabelY = (items, top, bottom, desiredGap) => {
335
+ if (!items.length) return new Map();
336
+ const gap = Math.min(desiredGap, items.length > 1 ? (bottom - top) / (items.length - 1) : desiredGap);
337
+ const positions = items.map(item => ({
338
+ id: item.id,
339
+ y: Math.max(top, Math.min(bottom, (item.y0 + item.y1) / 2))
340
+ }));
341
+ positions.forEach((item, index) => {
342
+ const minY = index === 0 ? top : positions[index - 1].y + gap;
343
+ if (item.y < minY) item.y = minY;
344
+ });
345
+ const overflow = positions[positions.length - 1].y - bottom;
346
+ if (overflow > 0) {
347
+ positions.forEach(item => { item.y -= overflow; });
348
+ positions.forEach((item, index) => {
349
+ const minY = index === 0 ? top : positions[index - 1].y + gap;
350
+ if (item.y < minY) item.y = minY;
351
+ });
352
+ }
353
+ return new Map(positions.map(item => [item.id, item.y]));
354
+ };
355
+
356
+ const defs = svg.append("defs");
357
+ const bgGradient = defs.append("linearGradient")
358
+ .attr("id", `alluvial-plain-bg-${idSuffix}`)
359
+ .attr("x1", "0%")
360
+ .attr("x2", "100%")
361
+ .attr("y1", "0%")
362
+ .attr("y2", "100%");
363
+ bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#FFFFFF");
364
+ bgGradient.append("stop").attr("offset", "55%").attr("stop-color", "#F6FCFE");
365
+ bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#EAF7FB");
366
+
367
+ const shadow = defs.append("filter")
368
+ .attr("id", `alluvial-plain-shadow-${idSuffix}`)
369
+ .attr("x", "-20%")
370
+ .attr("y", "-20%")
371
+ .attr("width", "140%")
372
+ .attr("height", "140%");
373
+ shadow.append("feDropShadow")
374
+ .attr("dx", 0)
375
+ .attr("dy", 5)
376
+ .attr("stdDeviation", 7)
377
+ .attr("flood-color", "#5C8FA5")
378
+ .attr("flood-opacity", 0.18);
379
+
380
+ const softShadow = defs.append("filter")
381
+ .attr("id", `alluvial-plain-soft-shadow-${idSuffix}`)
382
+ .attr("x", "-15%")
383
+ .attr("y", "-15%")
384
+ .attr("width", "130%")
385
+ .attr("height", "130%");
386
+ softShadow.append("feDropShadow")
387
+ .attr("dx", 0)
388
+ .attr("dy", 2)
389
+ .attr("stdDeviation", 3)
390
+ .attr("flood-color", "#6B9DB3")
391
+ .attr("flood-opacity", 0.18);
392
+
393
+ graph.links.forEach((link, index) => {
394
+ const source = sourceColor(link.source.name);
395
+ const target = targetColor(link.target.name);
396
+ const gradient = defs.append("linearGradient")
397
+ .attr("id", `alluvial-plain-link-${idSuffix}-${index}`)
398
+ .attr("gradientUnits", "userSpaceOnUse")
399
+ .attr("x1", link.source.x1)
400
+ .attr("x2", link.target.x0)
401
+ .attr("y1", link.y0)
402
+ .attr("y2", link.y1);
403
+ gradient.append("stop").attr("offset", "0%").attr("stop-color", source).attr("stop-opacity", 0.80);
404
+ gradient.append("stop").attr("offset", "52%").attr("stop-color", "#8FE3DF").attr("stop-opacity", 0.56);
405
+ gradient.append("stop").attr("offset", "100%").attr("stop-color", target).attr("stop-opacity", 0.78);
406
+ link.gradientId = `alluvial-plain-link-${idSuffix}-${index}`;
407
+ });
408
+
409
+ const root = svg.append("g").attr("class", "alluvial-plain-reference-panel");
410
+ root.append("rect")
411
+ .attr("class", "chart-style-background")
412
+ .attr("x", 0)
413
+ .attr("y", 0)
414
+ .attr("width", width)
415
+ .attr("height", height)
416
+ .attr("fill", "#F7FCFF");
417
+ root.append("rect")
418
+ .attr("class", "chart-style-panel")
419
+ .attr("x", panelX)
420
+ .attr("y", panelY)
421
+ .attr("width", panelW)
422
+ .attr("height", panelH)
423
+ .attr("rx", 18)
424
+ .attr("fill", `url(#alluvial-plain-bg-${idSuffix})`)
425
+ .attr("stroke", panelStroke)
426
+ .attr("stroke-width", 1.2)
427
+ .attr("filter", `url(#alluvial-plain-shadow-${idSuffix})`);
428
+
429
+ const addPill = (x, y, w, label, fill, className) => {
430
+ const group = root.append("g")
431
+ .attr("class", `field-header ${className}`)
432
+ .attr("transform", `translate(${x}, ${y})`);
433
+ group.append("rect")
434
+ .attr("width", w)
435
+ .attr("height", 24)
436
+ .attr("rx", 7)
437
+ .attr("fill", fill);
438
+ group.append("text")
439
+ .attr("class", "field-header-label chart-text")
440
+ .attr("x", w / 2)
441
+ .attr("y", 16.5)
442
+ .attr("text-anchor", "middle")
443
+ .attr("fill", "#FFFFFF")
444
+ .style("font-family", titleFontFamily)
445
+ .style("font-size", "12px")
446
+ .style("font-weight", "900")
447
+ .style("letter-spacing", "0px")
448
+ .text(shortLabel(label, 18).toUpperCase());
449
+ return group;
450
+ };
451
+ addPill(panelX + panelW * 0.055, panelY + 22, 94, sourceColumn.label || sourceField, primaryColor, "source-field-header");
452
+ addPill(targetCardX + Math.max(0, (targetCardW - 94) / 2), panelY + 22, 94, targetColumn.label || targetField, secondaryColor, "target-field-header");
453
+
454
+ root.append("line")
455
+ .attr("class", "top-decoration-rule decorative-line")
456
+ .attr("x1", panelX + panelW * 0.30)
457
+ .attr("x2", panelX + panelW * 0.54)
458
+ .attr("y1", panelY + 35)
459
+ .attr("y2", panelY + 35)
460
+ .attr("stroke", "#B9DCE8")
461
+ .attr("stroke-width", 1.1);
462
+ root.append("circle")
463
+ .attr("class", "top-decoration-dot decorative-icon-slot")
464
+ .attr("cx", panelX + panelW * 0.57)
465
+ .attr("cy", panelY + 35)
466
+ .attr("r", 4)
467
+ .attr("fill", primaryColor)
468
+ .attr("fill-opacity", 0.75);
469
+
470
+ root.append("g")
471
+ .attr("class", "flow-links")
472
+ .selectAll("path")
473
+ .data(graph.links)
474
+ .enter()
475
+ .append("path")
476
+ .attr("class", "alluvial-ribbon data-link")
477
+ .attr("d", d3.sankeyLinkHorizontal())
478
+ .attr("fill", "none")
479
+ .attr("stroke", d => `url(#${d.gradientId})`)
480
+ .attr("stroke-width", d => Math.max(1.2, d.width))
481
+ .attr("stroke-linecap", "round")
482
+ .attr("stroke-opacity", 0.83)
483
+ .attr("data-source", d => d.source.name)
484
+ .attr("data-target", d => d.target.name)
485
+ .attr("data-value", d => d.value);
486
+
487
+ const addIconSlot = (group, cx, cy, r, className, fill, glyph, href) => {
488
+ const slot = group.append("g")
489
+ .attr("class", className)
490
+ .attr("data-asset-slot", className.includes("target") ? "target_city_icon" : "source_icon")
491
+ .attr("data-slot-anchor", className.includes("target") ? "target-card-centerline" : "source-row-centerline")
492
+ .attr("data-slot-collision-rule", "stay inside owning row/card and avoid text labels");
493
+ slot.append("circle")
494
+ .attr("cx", cx)
495
+ .attr("cy", cy)
496
+ .attr("r", r)
497
+ .attr("fill", "#FFFFFF")
498
+ .attr("stroke", fill)
499
+ .attr("stroke-width", 1.4)
500
+ .attr("filter", `url(#alluvial-plain-soft-shadow-${idSuffix})`);
501
+ if (href) {
502
+ slot.append("image")
503
+ .attr("x", cx - r + 3)
504
+ .attr("y", cy - r + 3)
505
+ .attr("width", (r - 3) * 2)
506
+ .attr("height", (r - 3) * 2)
507
+ .attr("preserveAspectRatio", "xMidYMid meet")
508
+ .attr("href", href);
509
+ } else {
510
+ slot.append("circle")
511
+ .attr("cx", cx)
512
+ .attr("cy", cy)
513
+ .attr("r", r - 4)
514
+ .attr("fill", fill)
515
+ .attr("fill-opacity", 0.95);
516
+ slot.append("text")
517
+ .attr("x", cx)
518
+ .attr("y", cy + 1)
519
+ .attr("text-anchor", "middle")
520
+ .attr("dominant-baseline", "middle")
521
+ .attr("fill", "#FFFFFF")
522
+ .style("font-family", titleFontFamily)
523
+ .style("font-size", `${Math.max(9, r * 0.68)}px`)
524
+ .style("font-weight", "900")
525
+ .text(glyph);
526
+ }
527
+ return slot;
528
+ };
529
+
530
+ const sourceGroup = root.append("g").attr("class", "source-node-list");
531
+ const sourceCenterYs = renderedSources.map(node => (node.y0 + node.y1) / 2).sort((a, b) => a - b);
532
+ const minSourceCenterGap = sourceCenterYs.length > 1
533
+ ? d3.min(d3.pairs(sourceCenterYs), pair => pair[1] - pair[0])
534
+ : flowH;
535
+ const compactSourceRows = renderedSources.length >= 7 || minSourceCenterGap < 28;
536
+ const sourceLabelY = distributeLabelY(
537
+ renderedSources,
538
+ flowTop + (compactSourceRows ? 16 : 8),
539
+ flowBottom - (compactSourceRows ? 11 : 8),
540
+ compactSourceRows ? 29 : 25
541
+ );
542
+ renderedSources.forEach((node, index) => {
543
+ const centerY = (node.y0 + node.y1) / 2;
544
+ const labelY = sourceLabelY.get(node.id) || centerY;
545
+ const color = safeColor(sourceColor(node.name));
546
+ const group = sourceGroup.append("g")
547
+ .attr("class", "source-row")
548
+ .attr("data-source", node.name)
549
+ .attr("data-value", node.value)
550
+ .attr("data-rank", index + 1);
551
+ const compactValue = formatValueCompact(node.value);
552
+ const sourceCardX = panelX + Math.max(10, panelW * 0.018);
553
+ const sourceCardW = Math.max(54, node.x0 - sourceCardX - Math.max(6, panelW * 0.012));
554
+ const sourceCardH = compactSourceRows
555
+ ? Math.max(20, Math.min(27, (node.y1 - node.y0) + 8))
556
+ : Math.max(34, Math.min(48, (node.y1 - node.y0) + 14));
557
+ group.append("rect")
558
+ .attr("class", "source-label-card")
559
+ .attr("x", sourceCardX)
560
+ .attr("y", labelY - sourceCardH / 2)
561
+ .attr("width", sourceCardW)
562
+ .attr("height", sourceCardH)
563
+ .attr("rx", 7)
564
+ .attr("fill", "#FFFFFF")
565
+ .attr("fill-opacity", compactSourceRows ? 0.92 : 0.96)
566
+ .attr("stroke", "#D5ECF4")
567
+ .attr("stroke-width", 0.8)
568
+ .attr("filter", `url(#alluvial-plain-soft-shadow-${idSuffix})`);
569
+ if (!compactSourceRows) {
570
+ addIconSlot(
571
+ group,
572
+ sourceIconX,
573
+ labelY,
574
+ Math.max(10, Math.min(17, (node.y1 - node.y0) * 0.28 + 6)),
575
+ "source-icon-slot decorative-icon-slot",
576
+ color.formatHex(),
577
+ shortLabel(node.name, 1).slice(0, 1).toUpperCase(),
578
+ null
579
+ );
580
+ } else {
581
+ group.append("rect")
582
+ .attr("class", "source-compact-accent")
583
+ .attr("x", sourceCardX)
584
+ .attr("y", labelY - sourceCardH / 2)
585
+ .attr("width", 4)
586
+ .attr("height", sourceCardH)
587
+ .attr("rx", 2)
588
+ .attr("fill", color.formatHex())
589
+ .attr("fill-opacity", 0.86);
590
+ }
591
+ if (Math.abs(labelY - centerY) > 3) {
592
+ group.append("line")
593
+ .attr("class", "source-label-connector decorative-line")
594
+ .attr("x1", sourceLabelX + 5)
595
+ .attr("x2", node.x0 - 5)
596
+ .attr("y1", labelY)
597
+ .attr("y2", centerY)
598
+ .attr("stroke", chartUtils.color.variant(color, { mode: "brighter", amount: 0.8 }))
599
+ .attr("stroke-width", 0.8)
600
+ .attr("stroke-opacity", 0.42);
601
+ }
602
+ group.append("rect")
603
+ .attr("class", "source-node-bar")
604
+ .attr("x", node.x0)
605
+ .attr("y", node.y0)
606
+ .attr("width", node.x1 - node.x0)
607
+ .attr("height", Math.max(2, node.y1 - node.y0))
608
+ .attr("rx", 3)
609
+ .attr("fill", chartUtils.color.variant(color, { mode: "brighter", amount: 0.35 }))
610
+ .attr("data-source", node.name)
611
+ .attr("data-value", node.value);
612
+ const compactTextX = sourceCardX + sourceCardW / 2;
613
+ const compactAvailableChars = Math.max(8, Math.floor((sourceCardW - 12) / 6.4));
614
+ const compactNameChars = Math.max(3, Math.min(11, compactAvailableChars - compactValue.length - 3));
615
+ const label = group.append("text")
616
+ .attr("class", "source-label chart-text")
617
+ .attr("x", compactSourceRows ? compactTextX : sourceLabelX)
618
+ .attr("y", compactSourceRows ? labelY - 2 : labelY - 2)
619
+ .attr("text-anchor", compactSourceRows ? "middle" : "end")
620
+ .attr("fill", chartUtils.color.variant(color, { mode: "darker", amount: 0.8 }))
621
+ .style("font-family", titleFontFamily)
622
+ .style("font-weight", "900")
623
+ .style("font-size", compactSourceRows ? "11px" : "13px")
624
+ .style("letter-spacing", "0px");
625
+ if (compactSourceRows) {
626
+ label.append("tspan")
627
+ .attr("x", compactTextX)
628
+ .attr("dy", 0)
629
+ .text(truncateText(node.name, Math.max(6, compactNameChars)));
630
+ label.append("tspan")
631
+ .attr("x", compactTextX)
632
+ .attr("dy", 11)
633
+ .style("font-size", "9px")
634
+ .style("font-weight", "800")
635
+ .text(compactValue);
636
+ } else {
637
+ label.append("tspan")
638
+ .attr("x", sourceLabelX)
639
+ .attr("dy", 0)
640
+ .text(shortLabel(node.name, 14));
641
+ label.append("tspan")
642
+ .attr("x", sourceLabelX)
643
+ .attr("dy", 14)
644
+ .attr("fill", mutedText)
645
+ .style("font-weight", "700")
646
+ .text(`(${formatLocalValue(node.value)})`);
647
+ }
648
+ });
649
+
650
+ const layoutTargetCards = targetNodes => {
651
+ const cards = targetNodes.map(node => {
652
+ return {
653
+ node,
654
+ h: Math.max(42, node.y1 - node.y0),
655
+ y: node.y0
656
+ };
657
+ });
658
+ return cards;
659
+ };
660
+
661
+ const targetCards = layoutTargetCards(renderedTargets);
662
+ const targetGroup = root.append("g").attr("class", "target-card-list");
663
+ targetCards.forEach(({ node, y, h }, index) => {
664
+ const color = safeColor(targetColor(node.name));
665
+ const centerY = (node.y0 + node.y1) / 2;
666
+ const group = targetGroup.append("g")
667
+ .attr("class", "target-card")
668
+ .attr("data-target", node.name)
669
+ .attr("data-value", node.value)
670
+ .attr("data-rank", index + 1);
671
+ group.append("rect")
672
+ .attr("class", "target-node-bar")
673
+ .attr("x", node.x0)
674
+ .attr("y", node.y0)
675
+ .attr("width", node.x1 - node.x0)
676
+ .attr("height", Math.max(2, node.y1 - node.y0))
677
+ .attr("rx", 4)
678
+ .attr("fill", color.formatHex())
679
+ .attr("data-target", node.name)
680
+ .attr("data-value", node.value);
681
+ group.append("rect")
682
+ .attr("class", "target-card-background")
683
+ .attr("x", targetCardX)
684
+ .attr("y", y)
685
+ .attr("width", targetCardW)
686
+ .attr("height", h)
687
+ .attr("rx", 7)
688
+ .attr("fill", "#FFFFFF")
689
+ .attr("stroke", chartUtils.color.variant(color, { mode: "brighter", amount: 0.8 }))
690
+ .attr("stroke-width", 1.1)
691
+ .attr("filter", `url(#alluvial-plain-soft-shadow-${idSuffix})`);
692
+ group.append("rect")
693
+ .attr("class", "target-card-color-rail")
694
+ .attr("x", targetCardX)
695
+ .attr("y", y)
696
+ .attr("width", 7)
697
+ .attr("height", h)
698
+ .attr("rx", 3.5)
699
+ .attr("fill", color.formatHex());
700
+ const targetCenterX = targetCardX + targetCardW / 2;
701
+ const prominentTargetCard = targetNames.length <= 2 && h >= 95;
702
+ const targetIconR = prominentTargetCard
703
+ ? Math.max(20, Math.min(34, h * 0.15))
704
+ : Math.max(15, Math.min(24, h * 0.25));
705
+ addIconSlot(
706
+ group,
707
+ prominentTargetCard ? targetCenterX : targetCardX + 29,
708
+ prominentTargetCard ? y + h * 0.27 : y + h / 2,
709
+ targetIconR,
710
+ "target-icon-slot decorative-icon-slot",
711
+ color.formatHex(),
712
+ shortLabel(node.name, 1).slice(0, 1).toUpperCase(),
713
+ targetIcon(node.name)
714
+ );
715
+ const title = group.append("text")
716
+ .attr("class", "target-label chart-text")
717
+ .attr("fill", chartUtils.color.variant(color, { mode: "darker", amount: 0.7 }))
718
+ .style("font-family", titleFontFamily)
719
+ .style("font-size", prominentTargetCard ? "24px" : "12px")
720
+ .style("font-weight", "900")
721
+ .style("letter-spacing", "0px");
722
+ if (prominentTargetCard) {
723
+ title.attr("x", targetCenterX)
724
+ .attr("y", y + h * 0.55)
725
+ .attr("text-anchor", "middle")
726
+ .text(shortLabel(node.name, 16).toUpperCase());
727
+ } else {
728
+ appendWrappedText(
729
+ title,
730
+ node.name,
731
+ targetCardX + 58,
732
+ y + Math.max(20, h * 0.36),
733
+ targetCardW - 70,
734
+ 12,
735
+ 13,
736
+ 2
737
+ );
738
+ }
739
+ const targetValue = formatLocalValue(node.value);
740
+ const targetValueFont = Math.max(
741
+ prominentTargetCard ? 26 : 10,
742
+ Math.min(
743
+ prominentTargetCard ? 36 : 13,
744
+ (targetCardW - 74) / Math.max(5.4, targetValue.length * 0.60)
745
+ )
746
+ );
747
+ group.append("text")
748
+ .attr("class", "target-value-label chart-text")
749
+ .attr("x", prominentTargetCard ? targetCenterX : targetCardX + 58)
750
+ .attr("y", prominentTargetCard ? y + h * 0.78 : y + h - Math.max(12, h * 0.18))
751
+ .attr("fill", textColor)
752
+ .attr("text-anchor", prominentTargetCard ? "middle" : "start")
753
+ .style("font-family", titleFontFamily)
754
+ .style("font-size", `${targetValueFont}px`)
755
+ .style("font-weight", "900")
756
+ .text(targetValue);
757
+ });
758
+
759
+ return svg.node();
760
+ }
modules/chart_engine/template/d3-js/type69_alluvial_diagram/alluvial_diagram_plain_chart_02.js ADDED
@@ -0,0 +1,717 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Alluvial Diagram",
5
+ "chart_name": "alluvial_diagram_plain_chart_02",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["categorical"], ["numerical"], ["temporal"]],
8
+ "required_fields_range": [[2, 10], [0, "inf"], [2, 2]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["x"],
12
+ "required_other_colors": ["primary", "secondary"],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 600,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "no",
20
+ "has_y_axis": "no"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ const jsonData = data || {};
27
+ const chartData = jsonData.data?.data || [];
28
+ const dataColumns = chartUtils.schema.columns(jsonData);
29
+ const colorResolver = chartUtils.color.resolver(jsonData);
30
+ const chartUtilsFormatSample = chartUtils.format.autoText;
31
+ const chartUtilsTextSample = chartUtils.text.estimate;
32
+ const chartUtilsStandard = {
33
+ schema: chartUtils.schema,
34
+ format: chartUtils.format,
35
+ text: chartUtils.text,
36
+ color: chartUtils.color,
37
+ legendLayout: chartUtils.legend.layout,
38
+ legendDraw: chartUtils.legend.draw,
39
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
40
+ };
41
+ const standardChannels = chartUtils.schema.channels(jsonData, {
42
+ x: { fallbackIndex: 0 },
43
+ y: { fallbackIndex: 1 },
44
+ y2: { fallbackIndex: 2 },
45
+ y3: { fallbackIndex: 3 },
46
+ size: { fallbackIndex: 2 },
47
+ group: { fallbackIndex: 2 },
48
+ group2: { fallbackIndex: 3 },
49
+ group3: { fallbackIndex: 4 }
50
+ });
51
+ const variables = jsonData.variables || {};
52
+ const typography = jsonData.typography || {};
53
+ const sourceColors = jsonData.colors || {};
54
+ const images = jsonData.images || {};
55
+
56
+ d3.select(containerSelector).html("");
57
+
58
+ const categoryColumn = dataColumns[0] || {};
59
+ const valueColumn = dataColumns[1] || {};
60
+ const timeColumn = dataColumns[2] || {};
61
+ const categoryField = categoryColumn.name;
62
+ const valueField = valueColumn.name;
63
+ const timeField = timeColumn.name;
64
+
65
+ const width = Math.max(520, Number(variables.width) || 600);
66
+ const height = Math.max(520, Number(variables.height) || 600);
67
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
68
+ const titleFontFamily = typography.title?.font_family || fontFamily;
69
+ const textColor = sourceColors.text_color || "#091D3A";
70
+ const mutedText = "#657181";
71
+ const pageFill = "#F6F9FD";
72
+ const border = "#E7EDF5";
73
+ const navy = "#071A48";
74
+ const accent = sourceColors.other?.secondary || "#4C7FE6";
75
+ const idSuffix = String(containerSelector || "alluvial-plain-02")
76
+ .replace(/[^a-zA-Z0-9_-]/g, "")
77
+ .slice(0, 24) || "alluvial02";
78
+
79
+ const svg = d3.select(containerSelector)
80
+ .append("svg")
81
+ .attr("width", "100%")
82
+ .attr("height", height)
83
+ .attr("viewBox", `0 0 ${width} ${height}`)
84
+ .attr("style", "max-width: 100%; height: auto;")
85
+ .attr("xmlns", "http://www.w3.org/2000/svg")
86
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
87
+ .attr("class", "alluvial-plain-02-reference-layout");
88
+
89
+ const addEmptyState = message => {
90
+ svg.append("rect")
91
+ .attr("width", width)
92
+ .attr("height", height)
93
+ .attr("fill", pageFill);
94
+ svg.append("text")
95
+ .attr("x", width / 2)
96
+ .attr("y", height / 2)
97
+ .attr("text-anchor", "middle")
98
+ .attr("fill", textColor)
99
+ .style("font-family", fontFamily)
100
+ .style("font-size", "16px")
101
+ .text(message);
102
+ return svg.node();
103
+ };
104
+
105
+ if (!categoryField || !valueField || !timeField) {
106
+ return addEmptyState("Missing alluvial comparison fields");
107
+ }
108
+
109
+ const readableName = value => String(value ?? "")
110
+ .replace(/_/g, " ")
111
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
112
+ .replace(/\s+/g, " ")
113
+ .trim();
114
+
115
+ const shortLabel = (value, maxChars = 18) => {
116
+ const clean = readableName(value);
117
+ if (clean.length <= maxChars) return clean;
118
+ const words = clean.split(/[\s/,-]+/).filter(Boolean);
119
+ if (words.length > 1 && maxChars <= 15) {
120
+ const firstTwo = words.slice(0, 2).join(" ");
121
+ if (firstTwo.length <= maxChars) return firstTwo;
122
+ const firstLast = `${words[0]} ${words[words.length - 1]}`;
123
+ if (firstLast.length <= maxChars) return firstLast;
124
+ }
125
+ return `${clean.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
126
+ };
127
+
128
+ const truncateText = (value, maxChars = 42) => {
129
+ const clean = readableName(value);
130
+ if (clean.length <= maxChars) return clean;
131
+ return `${clean.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
132
+ };
133
+
134
+ const unit = String(valueColumn.unit || "").trim();
135
+ const formatNumber = value => {
136
+ const abs = Math.abs(Number(value) || 0);
137
+ const spec = abs >= 1000000 ? ",.1f" : abs >= 1000 ? ",.0f" : abs >= 100 ? ",.0f" : abs >= 10 ? ",.1f" : abs >= 1 ? ",.1f" : ",.2f";
138
+ return d3.format(spec)(abs)
139
+ .replace(/\.0$/, "")
140
+ .replace(/(\.\d*?)0+$/, "$1")
141
+ .replace(/\.$/, "");
142
+ };
143
+ const formatLocalValue = value => {
144
+ const numberValue = Number(value) || 0;
145
+ const sign = numberValue < 0 ? "-" : "";
146
+ const number = formatNumber(numberValue);
147
+ const lowerUnit = unit.toLowerCase();
148
+ if (!unit) return `${sign}${number}`;
149
+ if (unit === "%") return `${sign}${number}%`;
150
+ if (unit === "$" || lowerUnit === "usd") return `${sign}$${number}`;
151
+ if (unit.startsWith("$")) return `${sign}$${number}${unit.slice(1)}`;
152
+ return `${sign}${number} ${unit}`;
153
+ };
154
+
155
+ const normalizeTime = value => {
156
+ const raw = String(value ?? "").trim();
157
+ const numeric = Number(raw);
158
+ const parsed = Date.parse(raw);
159
+ return {
160
+ raw,
161
+ sortable: Number.isFinite(numeric) ? numeric : Number.isFinite(parsed) ? parsed : raw
162
+ };
163
+ };
164
+ const compareTime = (a, b) => {
165
+ const ta = normalizeTime(a);
166
+ const tb = normalizeTime(b);
167
+ if (typeof ta.sortable === "number" && typeof tb.sortable === "number") return ta.sortable - tb.sortable;
168
+ return String(a).localeCompare(String(b));
169
+ };
170
+
171
+ const cleanRows = chartData
172
+ .map((row, index) => ({
173
+ category: String(row[categoryField] ?? "").trim(),
174
+ time: String(row[timeField] ?? "").trim(),
175
+ value: Number(row[valueField]),
176
+ sourceIndex: index
177
+ }))
178
+ .filter(row => row.category && row.time && Number.isFinite(row.value) && row.value >= 0);
179
+
180
+ const timePoints = [...new Set(cleanRows.map(row => row.time))].sort(compareTime);
181
+ if (timePoints.length !== 2) {
182
+ return addEmptyState("Alluvial comparison needs exactly two time points");
183
+ }
184
+
185
+ const firstIndex = new Map();
186
+ cleanRows.forEach(row => {
187
+ if (!firstIndex.has(row.category)) firstIndex.set(row.category, row.sourceIndex);
188
+ });
189
+
190
+ const categoryMap = new Map();
191
+ cleanRows.forEach(row => {
192
+ if (!categoryMap.has(row.category)) {
193
+ categoryMap.set(row.category, {
194
+ category: row.category,
195
+ leftValue: 0,
196
+ rightValue: 0,
197
+ leftSourceIndex: -1,
198
+ rightSourceIndex: -1,
199
+ firstSourceIndex: firstIndex.get(row.category) ?? row.sourceIndex
200
+ });
201
+ }
202
+ const record = categoryMap.get(row.category);
203
+ if (row.time === timePoints[0]) {
204
+ record.leftValue += row.value;
205
+ record.leftSourceIndex = record.leftSourceIndex < 0 ? row.sourceIndex : Math.min(record.leftSourceIndex, row.sourceIndex);
206
+ } else if (row.time === timePoints[1]) {
207
+ record.rightValue += row.value;
208
+ record.rightSourceIndex = record.rightSourceIndex < 0 ? row.sourceIndex : Math.min(record.rightSourceIndex, row.sourceIndex);
209
+ }
210
+ });
211
+
212
+ const categories = [...categoryMap.values()]
213
+ .filter(row => row.leftValue > 0 || row.rightValue > 0);
214
+
215
+ if (!categories.length) {
216
+ return addEmptyState("No alluvial comparison values");
217
+ }
218
+
219
+ const leftOrder = [...categories]
220
+ .sort((a, b) => b.leftValue - a.leftValue || b.rightValue - a.rightValue || a.firstSourceIndex - b.firstSourceIndex);
221
+ const rightOrder = [...categories]
222
+ .sort((a, b) => b.rightValue - a.rightValue || b.leftValue - a.leftValue || a.firstSourceIndex - b.firstSourceIndex);
223
+ leftOrder.forEach((row, index) => { row.leftRank = index + 1; });
224
+ rightOrder.forEach((row, index) => { row.rightRank = index + 1; });
225
+
226
+ const fieldColors = sourceColors.field || sourceColors.x || {};
227
+ const palette = [
228
+ "#F28E2B",
229
+ "#2F6BD5",
230
+ "#E84D68",
231
+ "#32AFA8",
232
+ "#59A14F",
233
+ "#EDC949",
234
+ "#8E5AD7",
235
+ "#4E79A7",
236
+ "#FF7F50",
237
+ "#1C9C7A"
238
+ ];
239
+ const safeColor = color => d3.color(color) || d3.color("#2F6BD5");
240
+ const getColor = (category, index) => fieldColors[category] || palette[index % palette.length];
241
+ const getIcon = category => images.field?.[category] || images.fields?.[category] || null;
242
+
243
+ const panelX = -Math.max(82, width * 0.16);
244
+ const panelY = Math.max(6, height * 0.012);
245
+ const panelW = width - panelX * 2;
246
+ const panelH = height - panelY * 2;
247
+ const plotTop = panelY + Math.max(92, panelH * 0.16);
248
+ const plotBottom = panelY + panelH - Math.max(14, panelH * 0.025);
249
+ const plotH = Math.max(220, plotBottom - plotTop);
250
+ const rowPitch = plotH / categories.length;
251
+ const rowCardH = Math.max(34, Math.min(82, rowPitch * 0.82));
252
+ const iconR = Math.max(12, Math.min(28, rowPitch * 0.27));
253
+ const labelFont = Math.max(11, Math.min(18, rowPitch * 0.20));
254
+ const valueFont = Math.max(12, Math.min(23, rowPitch * 0.24));
255
+ const leftIconX = panelX + Math.max(26, panelW * 0.055);
256
+ const leftLabelX = leftIconX + Math.max(25, panelW * 0.048);
257
+ const leftNodeX = panelX + Math.max(145, panelW * 0.265);
258
+ const rightNodeX = panelX + panelW * 0.735;
259
+ const nodeW = Math.max(12, Math.min(18, panelW * 0.027));
260
+ const rowOuterX = panelX + Math.max(10, panelW * 0.018);
261
+ const rowOuterW = panelW - Math.max(20, panelW * 0.036);
262
+ const rowOuterRight = rowOuterX + rowOuterW;
263
+ const rightValueX = rightNodeX + nodeW + Math.max(13, panelW * 0.026);
264
+ const rightColumnDividerX = rowOuterRight - Math.max(26, panelW * 0.048);
265
+ const rightValueMaxW = Math.max(42, rightColumnDividerX - rightValueX - 18);
266
+ const maxValue = Math.max(1, d3.max(categories, d => Math.max(d.leftValue, d.rightValue)) || 1);
267
+ const minBandH = Math.max(8, Math.min(12, rowPitch * 0.20));
268
+ const maxBandH = Math.max(10, Math.min(70, rowPitch * 0.68));
269
+ const bandHeight = value => value <= 0 ? 0 : Math.max(minBandH, (value / maxValue) * maxBandH);
270
+
271
+ leftOrder.forEach((row, index) => {
272
+ row.leftCenterY = plotTop + rowPitch * (index + 0.5);
273
+ });
274
+ rightOrder.forEach((row, index) => {
275
+ row.rightCenterY = plotTop + rowPitch * (index + 0.5);
276
+ });
277
+
278
+ const defs = svg.append("defs");
279
+ const panelShadow = defs.append("filter")
280
+ .attr("id", `alluvial-plain-02-panel-shadow-${idSuffix}`)
281
+ .attr("x", "-12%")
282
+ .attr("y", "-12%")
283
+ .attr("width", "124%")
284
+ .attr("height", "124%");
285
+ panelShadow.append("feDropShadow")
286
+ .attr("dx", 0)
287
+ .attr("dy", 8)
288
+ .attr("stdDeviation", 10)
289
+ .attr("flood-color", "#AAB8CC")
290
+ .attr("flood-opacity", 0.20);
291
+
292
+ const rowShadow = defs.append("filter")
293
+ .attr("id", `alluvial-plain-02-row-shadow-${idSuffix}`)
294
+ .attr("x", "-8%")
295
+ .attr("y", "-20%")
296
+ .attr("width", "116%")
297
+ .attr("height", "140%");
298
+ rowShadow.append("feDropShadow")
299
+ .attr("dx", 0)
300
+ .attr("dy", 3)
301
+ .attr("stdDeviation", 4)
302
+ .attr("flood-color", "#B7C4D7")
303
+ .attr("flood-opacity", 0.16);
304
+
305
+ categories.forEach((row, index) => {
306
+ const color = safeColor(getColor(row.category, index));
307
+ const gradient = defs.append("linearGradient")
308
+ .attr("id", `alluvial-plain-02-link-${idSuffix}-${index}`)
309
+ .attr("gradientUnits", "userSpaceOnUse")
310
+ .attr("x1", leftNodeX)
311
+ .attr("x2", rightNodeX + nodeW)
312
+ .attr("y1", row.leftCenterY)
313
+ .attr("y2", row.rightCenterY);
314
+ gradient.append("stop").attr("offset", "0%").attr("stop-color", color.formatHex()).attr("stop-opacity", 0.96);
315
+ gradient.append("stop").attr("offset", "55%").attr("stop-color", chartUtils.color.variant(color, { mode: "brighter", amount: 1.55 })).attr("stop-opacity", 0.72);
316
+ gradient.append("stop").attr("offset", "100%").attr("stop-color", color.formatHex()).attr("stop-opacity", 0.92);
317
+ row.gradientId = `alluvial-plain-02-link-${idSuffix}-${index}`;
318
+ });
319
+
320
+ const root = svg.append("g").attr("class", "alluvial-plain-02-reference-panel");
321
+ root.append("rect")
322
+ .attr("class", "chart-style-background")
323
+ .attr("width", width)
324
+ .attr("height", height)
325
+ .attr("fill", pageFill)
326
+ .attr("fill-opacity", 0);
327
+ root.append("rect")
328
+ .attr("class", "chart-style-panel")
329
+ .attr("x", panelX)
330
+ .attr("y", panelY)
331
+ .attr("width", panelW)
332
+ .attr("height", panelH)
333
+ .attr("rx", 0)
334
+ .attr("fill", "none")
335
+ .attr("stroke", "none");
336
+
337
+ const headerY = panelY + Math.max(40, panelH * 0.075);
338
+ const addTimeHeader = (x, label, className) => {
339
+ const group = root.append("g")
340
+ .attr("class", `time-header ${className}`)
341
+ .attr("data-time", label);
342
+ group.append("rect")
343
+ .attr("class", "time-label-pill")
344
+ .attr("x", x - 36)
345
+ .attr("y", headerY - 22)
346
+ .attr("width", 72)
347
+ .attr("height", 30)
348
+ .attr("rx", 15)
349
+ .attr("fill", "#1D5C82")
350
+ .attr("filter", `url(#alluvial-plain-02-row-shadow-${idSuffix})`);
351
+ group.append("text")
352
+ .attr("class", "time-label chart-text")
353
+ .attr("x", x)
354
+ .attr("y", headerY - 1)
355
+ .attr("text-anchor", "middle")
356
+ .attr("fill", "#FFFFFF")
357
+ .style("font-family", titleFontFamily)
358
+ .style("font-size", "17px")
359
+ .style("font-weight", "900")
360
+ .text(label);
361
+ return group;
362
+ };
363
+ const leftHeaderX = leftNodeX + nodeW / 2;
364
+ const rightHeaderX = rightValueX + Math.min(38, rightValueMaxW * 0.36);
365
+ addTimeHeader(leftHeaderX, timePoints[0], "time-header-left");
366
+ addTimeHeader(rightHeaderX, timePoints[1], "time-header-right");
367
+ root.append("path")
368
+ .attr("class", "time-header-guide decorative-line")
369
+ .attr("d", [
370
+ `M ${leftHeaderX - 38} ${headerY - 7}`,
371
+ `H ${rowOuterX + Math.max(46, panelW * 0.18)}`,
372
+ `V ${plotTop - rowPitch * 0.26}`,
373
+ `M ${leftHeaderX + 38} ${headerY - 7}`,
374
+ `H ${(leftHeaderX + rightHeaderX) / 2 - 72}`,
375
+ `M ${(leftHeaderX + rightHeaderX) / 2 + 72} ${headerY - 7}`,
376
+ `H ${rightHeaderX - 38}`,
377
+ `M ${rightHeaderX + 38} ${headerY - 7}`,
378
+ `H ${rowOuterRight - Math.max(42, panelW * 0.08)}`,
379
+ `V ${plotTop - rowPitch * 0.26}`
380
+ ].join(" "))
381
+ .attr("fill", "none")
382
+ .attr("stroke", "#2E91C4")
383
+ .attr("stroke-width", 1.2)
384
+ .attr("stroke-dasharray", "2.5 4")
385
+ .attr("stroke-linecap", "round")
386
+ .attr("opacity", 0.86);
387
+ root.append("text")
388
+ .attr("class", "metric-caption chart-text")
389
+ .attr("x", (leftNodeX + rightNodeX + nodeW) / 2)
390
+ .attr("y", headerY - 5)
391
+ .attr("text-anchor", "middle")
392
+ .attr("fill", mutedText)
393
+ .style("font-family", titleFontFamily)
394
+ .style("font-size", "11px")
395
+ .style("font-weight", "900")
396
+ .style("letter-spacing", "0px")
397
+ .text(shortLabel(`${readableName(valueColumn.label || valueField)}${unit ? ` (${unit})` : ""}`, 26).toUpperCase());
398
+
399
+ const addIconSlot = (group, cx, cy, r, className, color, glyph, href) => {
400
+ const slot = group.append("g")
401
+ .attr("class", className)
402
+ .attr("data-asset-slot", "category_icon")
403
+ .attr("data-slot-anchor", "row-centerline")
404
+ .attr("data-slot-collision-rule", "stay inside left category block and avoid value labels");
405
+ slot.append("circle")
406
+ .attr("cx", cx)
407
+ .attr("cy", cy)
408
+ .attr("r", r)
409
+ .attr("fill", "#FFFFFF")
410
+ .attr("stroke", "#DDE5EF")
411
+ .attr("stroke-width", 1.2)
412
+ .attr("filter", `url(#alluvial-plain-02-row-shadow-${idSuffix})`);
413
+ if (href) {
414
+ slot.append("image")
415
+ .attr("x", cx - r + 3)
416
+ .attr("y", cy - r + 3)
417
+ .attr("width", (r - 3) * 2)
418
+ .attr("height", (r - 3) * 2)
419
+ .attr("preserveAspectRatio", "xMidYMid meet")
420
+ .attr("href", href);
421
+ } else {
422
+ slot.append("circle")
423
+ .attr("cx", cx)
424
+ .attr("cy", cy)
425
+ .attr("r", Math.max(3, r - 4))
426
+ .attr("fill", color.formatHex());
427
+ slot.append("text")
428
+ .attr("x", cx)
429
+ .attr("y", cy + 1)
430
+ .attr("text-anchor", "middle")
431
+ .attr("dominant-baseline", "middle")
432
+ .attr("fill", "#FFFFFF")
433
+ .style("font-family", titleFontFamily)
434
+ .style("font-size", `${Math.max(8, r * 0.62)}px`)
435
+ .style("font-weight", "900")
436
+ .text(glyph);
437
+ }
438
+ return slot;
439
+ };
440
+
441
+ const rowLayer = root.append("g").attr("class", "comparison-row-cards");
442
+ leftOrder.forEach(row => {
443
+ const color = safeColor(getColor(row.category, leftOrder.indexOf(row)));
444
+ rowLayer.append("rect")
445
+ .attr("class", "row-card-background")
446
+ .attr("x", rowOuterX)
447
+ .attr("y", row.leftCenterY - rowCardH / 2)
448
+ .attr("width", rowOuterW)
449
+ .attr("height", rowCardH)
450
+ .attr("rx", Math.min(12, rowCardH * 0.16))
451
+ .attr("fill", color.formatHex())
452
+ .attr("fill-opacity", 0.88)
453
+ .attr("stroke", chartUtils.color.variant(color, { mode: "brighter", amount: 0.5 }))
454
+ .attr("stroke-opacity", 0.35)
455
+ .attr("filter", `url(#alluvial-plain-02-row-shadow-${idSuffix})`)
456
+ .attr("data-category", row.category);
457
+ rowLayer.append("rect")
458
+ .attr("class", "row-category-block")
459
+ .attr("x", rowOuterX)
460
+ .attr("y", row.leftCenterY - rowCardH / 2)
461
+ .attr("width", Math.max(118, leftNodeX - rowOuterX))
462
+ .attr("height", rowCardH)
463
+ .attr("rx", Math.min(12, rowCardH * 0.16))
464
+ .attr("fill", color.formatHex())
465
+ .attr("fill-opacity", 0.92)
466
+ .attr("data-category", row.category);
467
+ rowLayer.append("line")
468
+ .attr("class", "row-category-divider decorative-line")
469
+ .attr("x1", leftNodeX)
470
+ .attr("x2", leftNodeX)
471
+ .attr("y1", row.leftCenterY - rowCardH / 2)
472
+ .attr("y2", row.leftCenterY + rowCardH / 2)
473
+ .attr("stroke", "#FFFFFF")
474
+ .attr("stroke-opacity", 0.82)
475
+ .attr("stroke-width", 1.4);
476
+ rowLayer.append("line")
477
+ .attr("class", "row-right-column-divider decorative-line")
478
+ .attr("x1", rightColumnDividerX)
479
+ .attr("x2", rightColumnDividerX)
480
+ .attr("y1", row.leftCenterY - rowCardH / 2)
481
+ .attr("y2", row.leftCenterY + rowCardH / 2)
482
+ .attr("stroke", "#FFFFFF")
483
+ .attr("stroke-opacity", 0.82)
484
+ .attr("stroke-width", 1.4);
485
+ });
486
+
487
+ const ribbons = root.append("g").attr("class", "alluvial-flow-links");
488
+ const rows = root.append("g").attr("class", "comparison-flow-rows");
489
+
490
+ categories.forEach((row, index) => {
491
+ const color = safeColor(getColor(row.category, index));
492
+ const leftH = bandHeight(row.leftValue);
493
+ const rightH = bandHeight(row.rightValue);
494
+ const leftTop = row.leftCenterY - leftH / 2;
495
+ const leftBottom = row.leftCenterY + leftH / 2;
496
+ const rightTop = row.rightCenterY - rightH / 2;
497
+ const rightBottom = row.rightCenterY + rightH / 2;
498
+ const midX1 = leftNodeX + nodeW + (rightNodeX - leftNodeX - nodeW) * 0.33;
499
+ const midX2 = leftNodeX + nodeW + (rightNodeX - leftNodeX - nodeW) * 0.67;
500
+ const path = [
501
+ `M ${leftNodeX + nodeW} ${leftTop}`,
502
+ `C ${midX1} ${leftTop}, ${midX2} ${rightTop}, ${rightNodeX} ${rightTop}`,
503
+ `L ${rightNodeX} ${rightBottom}`,
504
+ `C ${midX2} ${rightBottom}, ${midX1} ${leftBottom}, ${leftNodeX + nodeW} ${leftBottom}`,
505
+ "Z"
506
+ ].join(" ");
507
+
508
+ ribbons.append("path")
509
+ .attr("class", "alluvial-ribbon data-link")
510
+ .attr("d", path)
511
+ .attr("fill", `url(#${row.gradientId})`)
512
+ .attr("stroke", "rgba(255,255,255,0.82)")
513
+ .attr("stroke-width", 0.7)
514
+ .attr("opacity", 0.18)
515
+ .attr("data-category", row.category)
516
+ .attr("data-left-time", timePoints[0])
517
+ .attr("data-right-time", timePoints[1])
518
+ .attr("data-left-value", row.leftValue)
519
+ .attr("data-right-value", row.rightValue)
520
+ .attr("data-left-rank", row.leftRank)
521
+ .attr("data-right-rank", row.rightRank);
522
+
523
+ const group = rows.append("g")
524
+ .attr("class", "category-row")
525
+ .attr("data-category", row.category)
526
+ .attr("data-left-time", timePoints[0])
527
+ .attr("data-right-time", timePoints[1])
528
+ .attr("data-left-value", row.leftValue)
529
+ .attr("data-right-value", row.rightValue)
530
+ .attr("data-left-source-index", row.leftSourceIndex)
531
+ .attr("data-right-source-index", row.rightSourceIndex)
532
+ .attr("data-left-rank", row.leftRank)
533
+ .attr("data-right-rank", row.rightRank);
534
+
535
+ addIconSlot(
536
+ group,
537
+ leftIconX,
538
+ row.leftCenterY,
539
+ iconR,
540
+ "category-icon-slot decorative-icon-slot",
541
+ color,
542
+ shortLabel(row.category, 1).slice(0, 1).toUpperCase(),
543
+ getIcon(row.category)
544
+ );
545
+
546
+ const categoryWords = readableName(row.category).split(/\s+/).filter(Boolean);
547
+ const categoryLine1 = categoryWords.length > 2
548
+ ? categoryWords.slice(0, 2).join(" ")
549
+ : (categoryWords[0] || row.category);
550
+ const categoryLine2 = categoryWords.length > 2
551
+ ? categoryWords.slice(2).join(" ")
552
+ : (categoryWords.length === 2 ? categoryWords[1] : "");
553
+ const categoryText = group.append("text")
554
+ .attr("class", "category-label chart-text")
555
+ .attr("x", leftLabelX)
556
+ .attr("y", row.leftCenterY + (categoryLine2 ? -3 : Math.max(3, labelFont * 0.35)))
557
+ .attr("text-anchor", "start")
558
+ .attr("fill", navy)
559
+ .attr("fill", "#FFFFFF")
560
+ .attr("paint-order", "stroke fill")
561
+ .attr("stroke", chartUtils.color.variant(color, { mode: "darker", amount: 0.35 }))
562
+ .attr("stroke-width", 0.8)
563
+ .style("font-family", titleFontFamily)
564
+ .style("font-size", `${labelFont}px`)
565
+ .style("font-weight", "900")
566
+ .text(shortLabel(categoryLine1, 16));
567
+ if (categoryLine2) {
568
+ categoryText.append("tspan")
569
+ .attr("x", leftLabelX)
570
+ .attr("dy", Math.max(13, labelFont * 0.86))
571
+ .text(shortLabel(categoryLine2, 16));
572
+ }
573
+
574
+ group.append("rect")
575
+ .attr("class", "left-node-bar")
576
+ .attr("x", leftNodeX)
577
+ .attr("y", leftTop)
578
+ .attr("width", nodeW)
579
+ .attr("height", Math.max(2, leftH))
580
+ .attr("rx", 4)
581
+ .attr("fill", color.formatHex())
582
+ .attr("fill-opacity", 0)
583
+ .attr("data-category", row.category)
584
+ .attr("data-value", row.leftValue)
585
+ .attr("data-time", timePoints[0]);
586
+
587
+ group.append("rect")
588
+ .attr("class", "right-node-bar")
589
+ .attr("x", rightNodeX)
590
+ .attr("y", rightTop)
591
+ .attr("width", nodeW)
592
+ .attr("height", Math.max(2, rightH))
593
+ .attr("rx", 4)
594
+ .attr("fill", chartUtils.color.variant(color, { mode: "darker", amount: 0.08 }))
595
+ .attr("fill-opacity", 0)
596
+ .attr("data-category", row.category)
597
+ .attr("data-value", row.rightValue)
598
+ .attr("data-time", timePoints[1]);
599
+
600
+ const leftValue = formatLocalValue(row.leftValue);
601
+ const rightValue = formatLocalValue(row.rightValue);
602
+ const leftValueX = leftNodeX + nodeW + Math.max(12, panelW * 0.024);
603
+ const leftValueFont = Math.max(11, Math.min(valueFont, Math.max(11, rowCardH * 0.34), (rightNodeX - leftValueX - 12) / Math.max(8, leftValue.length * 0.58)));
604
+ const rightValueFont = Math.max(11, Math.min(valueFont, Math.max(11, rowCardH * 0.34), rightValueMaxW / Math.max(5.5, rightValue.length * 0.58)));
605
+ const valueBoxH = Math.max(20, Math.min(rowCardH * 0.72, 38));
606
+ const leftValueBoxW = Math.max(74, Math.min(116, (rightNodeX - leftValueX) * 0.34));
607
+ const rightValueBoxW = Math.max(74, Math.min(116, rightValueMaxW + 18));
608
+ const changeX = (leftNodeX + rightNodeX + nodeW) / 2;
609
+ const delta = row.rightValue - row.leftValue;
610
+ const deltaLabel = `${delta >= 0 ? "+" : "-"}${formatLocalValue(Math.abs(delta))}`;
611
+ const badgeR = Math.max(19, Math.min(34, rowCardH * 0.43));
612
+ const badgeValueFont = Math.max(8.5, Math.min(13, badgeR * 0.39, (badgeR * 1.48) / Math.max(4.5, deltaLabel.length * 0.48)));
613
+
614
+ group.append("rect")
615
+ .attr("class", "left-value-block")
616
+ .attr("x", leftValueX - 8)
617
+ .attr("y", row.leftCenterY - valueBoxH / 2)
618
+ .attr("width", leftValueBoxW)
619
+ .attr("height", valueBoxH)
620
+ .attr("rx", 7)
621
+ .attr("fill", chartUtils.color.variant(color, { mode: "darker", amount: 0.08 }))
622
+ .attr("fill-opacity", 0)
623
+ .attr("data-category", row.category)
624
+ .attr("data-time", timePoints[0]);
625
+ group.append("rect")
626
+ .attr("class", "right-value-block")
627
+ .attr("x", rightValueX - 9)
628
+ .attr("y", row.rightCenterY - valueBoxH / 2)
629
+ .attr("width", rightValueBoxW)
630
+ .attr("height", valueBoxH)
631
+ .attr("rx", 7)
632
+ .attr("fill", "#FFFFFF")
633
+ .attr("fill-opacity", 0)
634
+ .attr("stroke", chartUtils.color.variant(color, { mode: "brighter", amount: 0.55 }))
635
+ .attr("stroke-opacity", 0)
636
+ .attr("stroke-width", 1)
637
+ .attr("data-category", row.category)
638
+ .attr("data-time", timePoints[1]);
639
+ group.append("g")
640
+ .attr("class", "change-badge")
641
+ .attr("data-category", row.category)
642
+ .attr("data-delta", delta)
643
+ .attr("transform", `translate(${changeX}, ${row.leftCenterY})`)
644
+ .call(badge => {
645
+ badge.append("circle")
646
+ .attr("r", badgeR)
647
+ .attr("fill", "#FFFFFF")
648
+ .attr("stroke", "#E6EDF5")
649
+ .attr("stroke-width", 0.9)
650
+ .attr("filter", `url(#alluvial-plain-02-row-shadow-${idSuffix})`);
651
+ badge.append("text")
652
+ .attr("x", 0)
653
+ .attr("y", -badgeR * 0.38)
654
+ .attr("text-anchor", "middle")
655
+ .attr("fill", delta < 0 ? "#E84D68" : "#26A269")
656
+ .style("font-family", titleFontFamily)
657
+ .style("font-size", `${Math.max(10, badgeR * 0.52)}px`)
658
+ .style("font-weight", "900")
659
+ .text(delta < 0 ? "↓" : "↑");
660
+ badge.append("text")
661
+ .attr("x", 0)
662
+ .attr("y", badgeR * 0.10)
663
+ .attr("text-anchor", "middle")
664
+ .attr("fill", delta < 0 ? "#C9333C" : "#1B8C5A")
665
+ .style("font-family", titleFontFamily)
666
+ .style("font-size", `${badgeValueFont}px`)
667
+ .style("font-weight", "900")
668
+ .text(deltaLabel);
669
+ badge.append("text")
670
+ .attr("x", 0)
671
+ .attr("y", badgeR * 0.52)
672
+ .attr("text-anchor", "middle")
673
+ .attr("fill", mutedText)
674
+ .style("font-family", fontFamily)
675
+ .style("font-size", `${Math.max(6, badgeR * 0.26)}px`)
676
+ .style("font-weight", "700")
677
+ .text(delta < 0 ? "decline" : "increase");
678
+ });
679
+
680
+ group.append("text")
681
+ .attr("class", "left-value-label chart-text")
682
+ .attr("x", leftValueX)
683
+ .attr("y", row.leftCenterY + Math.max(3, leftValueFont * 0.34))
684
+ .attr("text-anchor", "start")
685
+ .attr("fill", "#FFFFFF")
686
+ .attr("stroke", chartUtils.color.variant(color, { mode: "darker", amount: 0.65 }))
687
+ .attr("stroke-width", 1.6)
688
+ .attr("stroke-linejoin", "round")
689
+ .attr("paint-order", "stroke fill")
690
+ .style("font-family", titleFontFamily)
691
+ .style("font-size", `${leftValueFont}px`)
692
+ .style("font-weight", "900")
693
+ .attr("data-category", row.category)
694
+ .attr("data-value", row.leftValue)
695
+ .attr("data-time", timePoints[0])
696
+ .text(leftValue);
697
+
698
+ group.append("text")
699
+ .attr("class", "right-value-label chart-text")
700
+ .attr("x", rightValueX)
701
+ .attr("y", row.rightCenterY + Math.max(3, rightValueFont * 0.34))
702
+ .attr("text-anchor", "start")
703
+ .attr("fill", "#FFFFFF")
704
+ .attr("stroke", chartUtils.color.variant(color, { mode: "darker", amount: 0.60 }))
705
+ .attr("stroke-width", 1.3)
706
+ .attr("paint-order", "stroke fill")
707
+ .style("font-family", titleFontFamily)
708
+ .style("font-size", `${rightValueFont}px`)
709
+ .style("font-weight", "900")
710
+ .attr("data-category", row.category)
711
+ .attr("data-value", row.rightValue)
712
+ .attr("data-time", timePoints[1])
713
+ .text(rightValue);
714
+ });
715
+
716
+ return svg.node();
717
+ }