Ray1ee01 commited on
Commit
eab7b96
·
verified ·
1 Parent(s): 65b6fe8

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/sankey/alluvial_diagram_01.js ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"], ["numerical"], ["categorical"]],
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
+ // 提取数据
27
+ const jsonData = data;
28
+ const chartData = jsonData.data.data;
29
+ const variables = jsonData.variables;
30
+ const typography = jsonData.typography;
31
+ const colors = jsonData.colors || {};
32
+ const colorResolver = chartUtils.color.resolver(jsonData);
33
+ const dataColumns = chartUtils.schema.columns(jsonData);
34
+ const images = jsonData.images || {};
35
+
36
+ // 清空容器
37
+ d3.select(containerSelector).html("");
38
+
39
+ // 获取字段名
40
+ const sourceChannel = chartUtils.schema.column(dataColumns, 0);
41
+ const valueChannel = chartUtils.schema.column(dataColumns, 1);
42
+ const targetChannel = chartUtils.schema.column(dataColumns, 2);
43
+ const sourceField = sourceChannel.key;
44
+ const valueField = valueChannel.key;
45
+ const targetField = targetChannel.key;
46
+ const sourceLabel = sourceChannel.rawLabel || sourceField;
47
+ const targetLabel = targetChannel.rawLabel || targetField;
48
+
49
+ // 设置尺寸和边距
50
+ const width = variables.width;
51
+ const height = variables.height;
52
+ const margin = { top: 140, right: 150, bottom: 140, left: 150 };
53
+
54
+ // 创建SVG
55
+ const svg = d3.select(containerSelector)
56
+ .append("svg")
57
+ .attr("width", "100%")
58
+ .attr("height", height)
59
+ .attr("viewBox", `0 0 ${width} ${height}`)
60
+ .attr("style", "max-width: 100%; height: auto;")
61
+ .attr("xmlns", "http://www.w3.org/2000/svg")
62
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
63
+
64
+ // 创建图表区域
65
+ const chartWidth = width - margin.left - margin.right;
66
+ const chartHeight = height - margin.top - margin.bottom;
67
+
68
+ const g = svg.append("g")
69
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
70
+
71
+ // 计算每个类别的总值
72
+ const sourceTotals = {};
73
+ const targetTotals = {};
74
+
75
+ chartData.forEach(d => {
76
+ sourceTotals[d[sourceField]] = (sourceTotals[d[sourceField]] || 0) + (+d[valueField]);
77
+ targetTotals[d[targetField]] = (targetTotals[d[targetField]] || 0) + (+d[valueField]);
78
+ });
79
+
80
+ // 获取唯一的源和目标类别
81
+ const sourceCategories = [...new Set(chartData.map(d => d[sourceField]))];
82
+ const targetCategories = [...new Set(chartData.map(d => d[targetField]))];
83
+
84
+ // 获取主色调
85
+ // const primaryColor = colorResolver.other("primary", { fallback: "#1f77b4" }).value;
86
+ // const secondaryColor = colorResolver.other("secondary", { fallback: "#ff7f0e" }).value;
87
+ const primaryColor = "#902d1f"; //"#7293EE"
88
+ const secondaryColor = "#2f6ae4"; //"#211CB7"
89
+
90
+ // 创建渐变色系 - 从浅到深
91
+ const createColorScale = (baseColor, count) => {
92
+ // 创建从浅色到深色的渐变
93
+ const lightColor = chartUtils.color.variant(baseColor, { mode: "brighter", amount: 0.5 });
94
+ const darkColor = chartUtils.color.variant(baseColor, { mode: "darker", amount: 0.5 });
95
+
96
+ return d3.scaleLinear()
97
+ .domain([0, count - 1])
98
+ .range([lightColor, darkColor])
99
+ .interpolate(d3.interpolateHcl);
100
+ };
101
+
102
+ // 创建源和目标的颜色比例尺
103
+ const sourceColorScale = createColorScale(primaryColor, sourceCategories.length);
104
+ const targetColorScale = createColorScale(secondaryColor, targetCategories.length);
105
+
106
+ // 准备 Sankey 数据
107
+ const nodes = [];
108
+ const links = [];
109
+
110
+ // 添加源节点
111
+ sourceCategories.forEach((category, i) => {
112
+ nodes.push({
113
+ id: category,
114
+ name: category,
115
+ type: "source",
116
+ index: i,
117
+ colorIndex: i,
118
+ value: sourceTotals[category]
119
+ });
120
+ });
121
+
122
+ // 添加目标节点
123
+ targetCategories.forEach((category, i) => {
124
+ nodes.push({
125
+ id: `target_${category}`,
126
+ name: category,
127
+ type: "target",
128
+ index: sourceCategories.length + i,
129
+ colorIndex: i,
130
+ value: targetTotals[category]
131
+ });
132
+ });
133
+
134
+ // 添加链接
135
+ chartData.forEach(d => {
136
+ links.push({
137
+ source: d[sourceField],
138
+ target: `target_${d[targetField]}`,
139
+ value: +d[valueField],
140
+ sourceCategory: d[sourceField],
141
+ targetCategory: d[targetField]
142
+ });
143
+ });
144
+
145
+ // 创建 Sankey 生成器
146
+ const sankey = d3.sankey()
147
+ .nodeId(d => d.id)
148
+ .nodeWidth(20)
149
+ .nodePadding(10)
150
+ .extent([[0, 0], [chartWidth, chartHeight]])
151
+ // 使用 nodeSort 方法按值从大到小排序节点
152
+ .nodeSort((a, b) => {
153
+ // 如果节点在同一列(都是源或都是目标)
154
+ if ((a.type === "source" && b.type === "source") ||
155
+ (a.type === "target" && b.type === "target")) {
156
+ // 按值从大到小排序
157
+ return b.value - a.value;
158
+ }
159
+ // 保持不同列的节点顺序不变
160
+ return 0;
161
+ });
162
+
163
+ // 计算 Sankey 布局
164
+ const { nodes: sankeyNodes, links: sankeyLinks } = sankey({
165
+ nodes: nodes.map(d => Object.assign({}, d)),
166
+ links: links.map(d => Object.assign({}, d))
167
+ });
168
+
169
+ // 更新节点的颜色索引,基于排序后的位置
170
+ const sourceNodesSorted = sankeyNodes
171
+ .filter(d => d.type === "source")
172
+ .sort((a, b) => a.y0 - b.y0);
173
+
174
+ const targetNodesSorted = sankeyNodes
175
+ .filter(d => d.type === "target")
176
+ .sort((a, b) => a.y0 - b.y0);
177
+
178
+ sourceNodesSorted.forEach((node, i) => {
179
+ node.colorIndex = i;
180
+ });
181
+
182
+ targetNodesSorted.forEach((node, i) => {
183
+ node.colorIndex = i;
184
+ });
185
+
186
+ // 创建路径生成器
187
+ const linkGenerator = d3.sankeyLinkHorizontal();
188
+
189
+ // 创建渐变定义
190
+ const defs = svg.append("defs");
191
+
192
+ // 为每个链接创建渐变
193
+ sankeyLinks.forEach((link, i) => {
194
+ const sourceNode = sankeyNodes.find(n => n.id === link.source.id);
195
+ const targetNode = sankeyNodes.find(n => n.id === link.target.id);
196
+
197
+ const sourceColor = sourceNode.type === "source"
198
+ ? sourceColorScale(sourceNode.colorIndex)
199
+ : targetColorScale(sourceNode.colorIndex);
200
+
201
+ const targetColor = targetNode.type === "source"
202
+ ? sourceColorScale(targetNode.colorIndex)
203
+ : targetColorScale(targetNode.colorIndex);
204
+
205
+ const gradientId = `link-gradient-${i}`;
206
+
207
+ const gradient = defs.append("linearGradient")
208
+ .attr("id", gradientId)
209
+ .attr("gradientUnits", "userSpaceOnUse")
210
+ .attr("x1", link.source.x1)
211
+ .attr("y1", link.y0)
212
+ .attr("x2", link.target.x0)
213
+ .attr("y2", link.y1);
214
+
215
+ gradient.append("stop")
216
+ .attr("offset", "0%")
217
+ .attr("stop-color", sourceColor);
218
+
219
+ gradient.append("stop")
220
+ .attr("offset", "100%")
221
+ .attr("stop-color", targetColor);
222
+
223
+ link.gradientId = gradientId;
224
+ });
225
+
226
+ // 绘制链接
227
+ const link = g.append("g")
228
+ .attr("class", "links")
229
+ .attr("fill", "none")
230
+ .attr("stroke-opacity", 0.7)
231
+ .selectAll("path")
232
+ .data(sankeyLinks)
233
+ .enter()
234
+ .append("path")
235
+ .attr("d", linkGenerator)
236
+ .attr("stroke", d => `url(#${d.gradientId})`)
237
+ .attr("stroke-width", d => Math.max(1, d.width));
238
+
239
+ // 绘制节点
240
+ const node = g.append("g")
241
+ .attr("class", "nodes")
242
+ .selectAll("g")
243
+ .data(sankeyNodes)
244
+ .enter()
245
+ .append("g");
246
+
247
+ // 添加节点矩形
248
+ node.append("rect")
249
+ .attr("x", d => d.x0)
250
+ .attr("y", d => d.y0)
251
+ .attr("height", d => d.y1 - d.y0)
252
+ .attr("width", d => d.x1 - d.x0)
253
+ .attr("fill", d => d.type === "source"
254
+ ? sourceColorScale(d.colorIndex)
255
+ : targetColorScale(d.colorIndex));
256
+
257
+ // 添加节点标签和值标签
258
+ node.append("text")
259
+ .attr("x", d => d.type === "source" ? d.x0 - 8 : d.x1 + 8)
260
+ .attr("y", d => (d.y0 + d.y1) / 2)
261
+ .attr("dy", "0.35em")
262
+ .attr("text-anchor", d => d.type === "source" ? "end" : "start")
263
+ .attr("font-size", "14px")
264
+ .attr("font-weight", "bold")
265
+ .text(d => `${d.name} (${chartUtils.format.autoText(+(d.value))})`)
266
+ .attr("fill", d => {
267
+ // 使用与节点相同的颜色,但稍微加深以提高可读性
268
+ const baseColor = d.type === "source"
269
+ ? sourceColorScale(d.colorIndex)
270
+ : targetColorScale(d.colorIndex);
271
+ return chartUtils.color.variant(baseColor, { mode: "darker", amount: 0.8 });
272
+ });
273
+
274
+ // 添加源和目标标题
275
+ svg.append("text")
276
+ .attr("x", margin.left)
277
+ .attr("y", margin.top / 2)
278
+ .attr("text-anchor", "middle")
279
+ .attr("font-size", "16px")
280
+ .attr("font-weight", "bold")
281
+ .attr("fill", chartUtils.color.variant(primaryColor, { mode: "darker", amount: 0.5 }))
282
+ .text(sourceLabel);
283
+
284
+ svg.append("text")
285
+ .attr("x", width - margin.right)
286
+ .attr("y", margin.top / 2)
287
+ .attr("text-anchor", "middle")
288
+ .attr("font-size", "16px")
289
+ .attr("font-weight", "bold")
290
+ .attr("fill", chartUtils.color.variant(secondaryColor, { mode: "darker", amount: 0.5 }))
291
+ .text(targetLabel);
292
+
293
+ return svg.node();
294
+ }
modules/chart_engine/template/d3-js/sankey/alluvial_diagram_01_dark.js ADDED
@@ -0,0 +1,722 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Alluvial Diagram",
5
+ "chart_name": "alluvial_diagram_01_dark",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
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": "dark",
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 variables = jsonData.variables || {};
28
+ const typography = jsonData.typography || {};
29
+ const sourceColors = jsonData.colors_dark || jsonData.colors || {};
30
+ const dataColumns = chartUtils.schema.columns(jsonData);
31
+ const chartUtilsFormatSample = chartUtils.format.autoText;
32
+ const chartUtilsTextSample = chartUtils.text.estimate;
33
+ const chartUtilsStandard = {
34
+ schema: chartUtils.schema,
35
+ format: chartUtils.format,
36
+ text: chartUtils.text,
37
+ color: chartUtils.color,
38
+ legendLayout: chartUtils.legend.layout,
39
+ legendDraw: chartUtils.legend.draw,
40
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
41
+ };
42
+ const standardChannels = chartUtils.schema.channels(jsonData, {
43
+ x: { fallbackIndex: 0 },
44
+ y: { fallbackIndex: 1 },
45
+ y2: { fallbackIndex: 2 },
46
+ y3: { fallbackIndex: 3 },
47
+ size: { fallbackIndex: 2 },
48
+ group: { fallbackIndex: 2 },
49
+ group2: { fallbackIndex: 3 },
50
+ group3: { fallbackIndex: 4 }
51
+ });
52
+ const sourceData = jsonData.data?.data || [];
53
+
54
+ d3.select(containerSelector).html("");
55
+
56
+ const sourceColumn = dataColumns[0] || {};
57
+ const valueColumn = dataColumns[1] || {};
58
+ const targetColumn = dataColumns[2] || {};
59
+ const sourceField = sourceColumn.name;
60
+ const valueField = valueColumn.name;
61
+ const targetField = targetColumn.name;
62
+
63
+ const width = Math.max(520, Number(variables.width) || 600);
64
+ const height = Math.max(480, Number(variables.height) || 600);
65
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
66
+ const titleFontFamily = typography.title?.font_family || fontFamily;
67
+ const textColor = sourceColors.text_color || sourceColors.text || "#F7FBFF";
68
+ const mutedText = sourceColors.muted_text || "#9FB5D8";
69
+ const sourceColor = sourceColors.other?.source_accent || sourceColors.source_accent || "#FF6848";
70
+ const targetPrimary = sourceColors.other?.target_accent || sourceColors.target_accent || "#2F80ED";
71
+ const targetSecondary = sourceColors.other?.target_secondary || sourceColors.target_secondary || "#8D62FF";
72
+ const targetPalette = [
73
+ targetPrimary,
74
+ targetSecondary,
75
+ "#2EC4B6",
76
+ "#77D353",
77
+ "#F4B443",
78
+ "#49A6FF",
79
+ "#D66AFF",
80
+ "#41D6A5",
81
+ "#FF8D5C",
82
+ "#7CB3FF"
83
+ ];
84
+ const cardStroke = sourceColors.card_stroke || "#24486F";
85
+ const idSuffix = String(containerSelector || "alluvial")
86
+ .replace(/[^a-zA-Z0-9_-]/g, "")
87
+ .slice(0, 24) || "alluvial";
88
+
89
+ const svg = d3.select(containerSelector)
90
+ .append("svg")
91
+ .attr("width", "100%")
92
+ .attr("height", height)
93
+ .attr("viewBox", `0 0 ${width} ${height}`)
94
+ .attr("style", "max-width: 100%; height: auto;")
95
+ .attr("xmlns", "http://www.w3.org/2000/svg")
96
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
97
+ .attr("class", "alluvial-dark-reference-layout");
98
+
99
+ const addEmptyState = message => {
100
+ svg.append("rect")
101
+ .attr("width", width)
102
+ .attr("height", height)
103
+ .attr("fill", "#05101F");
104
+ svg.append("text")
105
+ .attr("x", width / 2)
106
+ .attr("y", height / 2)
107
+ .attr("text-anchor", "middle")
108
+ .attr("fill", textColor)
109
+ .style("font-family", fontFamily)
110
+ .style("font-size", "16px")
111
+ .text(message);
112
+ return svg.node();
113
+ };
114
+
115
+ if (!sourceField || !valueField || !targetField) {
116
+ return addEmptyState("Missing alluvial fields");
117
+ }
118
+
119
+ const readableName = value => String(value || "")
120
+ .replace(/_/g, " ")
121
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
122
+ .replace(/\s+/g, " ")
123
+ .trim();
124
+
125
+ const shortLabel = (value, maxChars = 18) => {
126
+ const clean = readableName(value);
127
+ if (clean.length <= maxChars) return clean;
128
+ const words = clean.split(/[\s/,-]+/).filter(Boolean);
129
+ if (words.length > 1) {
130
+ const compact = words.map(word => word[0]).join("").toUpperCase();
131
+ if (compact.length >= 2 && compact.length <= maxChars) return compact;
132
+ const firstLast = `${words[0]} ${words[words.length - 1]}`;
133
+ if (firstLast.length <= maxChars) return firstLast;
134
+ }
135
+ return `${clean.slice(0, Math.max(1, maxChars - 1)).trim()}.`;
136
+ };
137
+ const truncateText = (value, maxChars = 48) => {
138
+ const clean = readableName(value);
139
+ if (clean.length <= maxChars) return clean;
140
+ return `${clean.slice(0, Math.max(1, maxChars - 1)).trim()}.`;
141
+ };
142
+ const wrapLabelLines = (value, maxChars = 13, maxLines = 2) => {
143
+ const clean = readableName(value);
144
+ if (clean.length <= maxChars) return [clean];
145
+ const words = clean.split(/\s+/).filter(Boolean);
146
+ const lines = [];
147
+ words.forEach(word => {
148
+ if (!lines.length) {
149
+ lines.push(word);
150
+ return;
151
+ }
152
+ const current = lines[lines.length - 1] || "";
153
+ const candidate = current ? `${current} ${word}` : word;
154
+ if (candidate.length <= maxChars || lines.length >= maxLines) {
155
+ if (current && candidate.length > maxChars && lines.length >= maxLines) {
156
+ lines[lines.length - 1] = truncateText(candidate, maxChars + 1);
157
+ } else {
158
+ lines[lines.length - 1] = candidate;
159
+ }
160
+ } else {
161
+ lines.push(word);
162
+ }
163
+ });
164
+ return lines.slice(0, maxLines).map(line => truncateText(line, maxChars + 1));
165
+ };
166
+
167
+ const valueUnit = (valueColumn.unit || "").trim();
168
+ const visibleValueUnit = /^\$?m$/i.test(valueUnit) ? "" : valueUnit;
169
+ const formatNumber = value => {
170
+ const abs = Math.abs(value);
171
+ const spec = abs >= 1000 ? ",.0f" : abs >= 100 ? ",.0f" : abs >= 10 ? ",.1f" : ",.2f";
172
+ return d3.format(spec)(value)
173
+ .replace(/\.0$/, "")
174
+ .replace(/(\.\d*?)0+$/, "$1")
175
+ .replace(/\.$/, "");
176
+ };
177
+ const formatLocalValue = value => `${formatNumber(value)}${visibleValueUnit ? ` ${visibleValueUnit}` : ""}`;
178
+ const formatCompactValue = value => {
179
+ const abs = Math.abs(value);
180
+ if (abs >= 1e9) return `${d3.format(".2~f")(value / 1e9)}B${visibleValueUnit ? ` ${visibleValueUnit}` : ""}`;
181
+ if (abs >= 1e6) return `${d3.format(".2~f")(value / 1e6)}M${visibleValueUnit ? ` ${visibleValueUnit}` : ""}`;
182
+ if (abs >= 1e3) return `${d3.format(".2~f")(value / 1e3)}K${visibleValueUnit ? ` ${visibleValueUnit}` : ""}`;
183
+ return formatLocalValue(value);
184
+ };
185
+
186
+ const cleanRows = sourceData
187
+ .map((row, index) => ({
188
+ source: String(row[sourceField] ?? "").trim(),
189
+ target: String(row[targetField] ?? "").trim(),
190
+ value: Number(row[valueField]),
191
+ rawSource: row[sourceField],
192
+ rawTarget: row[targetField],
193
+ sourceIndex: index
194
+ }))
195
+ .filter(row => row.source && row.target && Number.isFinite(row.value) && row.value > 0);
196
+
197
+ if (!cleanRows.length) {
198
+ return addEmptyState("No positive alluvial values");
199
+ }
200
+
201
+ const firstIndex = new Map();
202
+ cleanRows.forEach(row => {
203
+ if (!firstIndex.has(`source:${row.source}`)) firstIndex.set(`source:${row.source}`, row.sourceIndex);
204
+ if (!firstIndex.has(`target:${row.target}`)) firstIndex.set(`target:${row.target}`, row.sourceIndex);
205
+ });
206
+
207
+ const rollupTotal = (rows, key) => {
208
+ const totals = new Map();
209
+ rows.forEach(row => totals.set(row[key], (totals.get(row[key]) || 0) + row.value));
210
+ return totals;
211
+ };
212
+
213
+ const sourceTotals = rollupTotal(cleanRows, "source");
214
+ const targetTotals = rollupTotal(cleanRows, "target");
215
+ const sortByTotal = (a, b, totals, prefix) => {
216
+ const delta = (totals.get(b) || 0) - (totals.get(a) || 0);
217
+ if (Math.abs(delta) > 1e-9) return delta;
218
+ return (firstIndex.get(`${prefix}:${a}`) || 0) - (firstIndex.get(`${prefix}:${b}`) || 0);
219
+ };
220
+ const sourceCategories = [...sourceTotals.keys()].sort((a, b) => sortByTotal(a, b, sourceTotals, "source"));
221
+ const targetCategories = [...targetTotals.keys()].sort((a, b) => sortByTotal(a, b, targetTotals, "target"));
222
+
223
+ const totalValue = d3.sum(cleanRows, row => row.value);
224
+ const nodes = [
225
+ ...sourceCategories.map((name, index) => ({
226
+ id: `source:${name}`,
227
+ name,
228
+ type: "source",
229
+ rank: index + 1,
230
+ value: sourceTotals.get(name) || 0,
231
+ color: chartUtils.color.variant(sourceColor, { mode: "brighter", amount: index / Math.max(4, sourceCategories.length) })
232
+ })),
233
+ ...targetCategories.map((name, index) => ({
234
+ id: `target:${name}`,
235
+ name,
236
+ type: "target",
237
+ rank: index + 1,
238
+ value: targetTotals.get(name) || 0,
239
+ color: targetPalette[index % targetPalette.length]
240
+ }))
241
+ ];
242
+
243
+ const links = cleanRows.map(row => ({
244
+ source: `source:${row.source}`,
245
+ target: `target:${row.target}`,
246
+ value: row.value,
247
+ sourceName: row.source,
248
+ targetName: row.target,
249
+ sourceIndex: row.sourceIndex
250
+ }));
251
+
252
+ const defs = svg.append("defs");
253
+ const bgGradient = defs.append("radialGradient")
254
+ .attr("id", `alluvial-bg-${idSuffix}`)
255
+ .attr("cx", "50%")
256
+ .attr("cy", "46%")
257
+ .attr("r", "78%");
258
+ bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#031524");
259
+ bgGradient.append("stop").attr("offset", "62%").attr("stop-color", "#020F1D");
260
+ bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#020A16");
261
+
262
+ const softGlow = defs.append("filter")
263
+ .attr("id", `soft-glow-${idSuffix}`)
264
+ .attr("x", "-30%")
265
+ .attr("y", "-30%")
266
+ .attr("width", "160%")
267
+ .attr("height", "160%");
268
+ softGlow.append("feGaussianBlur").attr("stdDeviation", 1.8).attr("result", "blur");
269
+ const merge = softGlow.append("feMerge");
270
+ merge.append("feMergeNode").attr("in", "blur");
271
+ merge.append("feMergeNode").attr("in", "SourceGraphic");
272
+
273
+ const root = svg.append("g").attr("class", "alluvial-reference-panel");
274
+ root.append("rect")
275
+ .attr("class", "chart-style-panel")
276
+ .attr("x", 0)
277
+ .attr("y", 0)
278
+ .attr("width", width)
279
+ .attr("height", height)
280
+ .attr("rx", 0)
281
+ .attr("fill", `url(#alluvial-bg-${idSuffix})`);
282
+
283
+ const addIconSlot = (group, cx, cy, r, className, fill, glyph, slotMeta = {}) => {
284
+ const slot = group.append("g").attr("class", className);
285
+ if (slotMeta.id) slot.attr("data-reserved-slot", slotMeta.id);
286
+ if (slotMeta.assetSourcePolicy) slot.attr("data-asset-source-policy", slotMeta.assetSourcePolicy);
287
+ if (slotMeta.anchor) slot.attr("data-anchor", slotMeta.anchor);
288
+ if (slotMeta.collisionRule) slot.attr("data-collision-rule", slotMeta.collisionRule);
289
+ slot.append("circle")
290
+ .attr("cx", cx)
291
+ .attr("cy", cy)
292
+ .attr("r", r)
293
+ .attr("fill", fill)
294
+ .attr("opacity", 0.88)
295
+ .attr("stroke", "#7DAEFF")
296
+ .attr("stroke-opacity", 0.35);
297
+ if (glyph !== undefined && glyph !== null && String(glyph).trim()) {
298
+ slot.append("text")
299
+ .attr("x", cx)
300
+ .attr("y", cy + 1)
301
+ .attr("text-anchor", "middle")
302
+ .attr("dominant-baseline", "middle")
303
+ .attr("fill", "#FFFFFF")
304
+ .style("font-family", titleFontFamily)
305
+ .style("font-size", `${Math.max(9, r * 0.78)}px`)
306
+ .style("font-weight", "900")
307
+ .text(glyph);
308
+ }
309
+ return slot;
310
+ };
311
+
312
+ const sourceLabel = shortLabel(sourceColumn.label || sourceField, 18).toUpperCase();
313
+ const targetLabel = shortLabel(targetColumn.label || targetField, 20).toUpperCase();
314
+ const panelPadX = Math.max(22, width * 0.038);
315
+ const footerLegendH = targetCategories.length <= 4 ? Math.max(58, height * 0.095) : 0;
316
+ const bottomPad = footerLegendH ? footerLegendH + Math.max(26, height * 0.045) : Math.max(34, height * 0.065);
317
+ const labelColumnW = Math.min(98, Math.max(78, width * 0.145));
318
+ const targetCardW = targetCategories.length <= 2
319
+ ? Math.min(132, Math.max(118, width * 0.2))
320
+ : Math.min(148, Math.max(132, width * 0.22));
321
+ const targetGap = targetCategories.length <= 2
322
+ ? Math.max(2, width * 0.004)
323
+ : Math.max(8, width * 0.018);
324
+ const plotX = panelPadX + labelColumnW + 4;
325
+ const plotY = Math.max(88, height * 0.15);
326
+ const plotW = Math.max(190, width - panelPadX - targetCardW - targetGap - plotX);
327
+ const plotH = Math.max(320, height - plotY - bottomPad);
328
+ const targetCardX = plotX + plotW + targetGap;
329
+ const headerY = Math.max(28, plotY - 48);
330
+
331
+ const addHeaderPill = (x, y, w, label, color, className) => {
332
+ const pill = root.append("g")
333
+ .attr("class", `header-pill ${className}`)
334
+ .attr("transform", `translate(${x}, ${y})`);
335
+ pill.append("rect")
336
+ .attr("width", w)
337
+ .attr("height", 27)
338
+ .attr("rx", 7)
339
+ .attr("fill", color)
340
+ .attr("fill-opacity", 0.32)
341
+ .attr("stroke", color)
342
+ .attr("stroke-opacity", 0.85);
343
+ pill.append("text")
344
+ .attr("x", w / 2)
345
+ .attr("y", 18)
346
+ .attr("text-anchor", "middle")
347
+ .attr("fill", textColor)
348
+ .style("font-family", titleFontFamily)
349
+ .style("font-size", "11px")
350
+ .style("font-weight", "900")
351
+ .style("letter-spacing", "0px")
352
+ .text(label);
353
+ return pill;
354
+ };
355
+
356
+ addHeaderPill(Math.max(panelPadX, plotX - 102), headerY, 92, sourceLabel, sourceColor, "source-header-pill");
357
+ addHeaderPill(targetCardX, headerY, targetCardW, targetLabel, targetPalette[0], "target-header-pill");
358
+
359
+ const sankey = d3.sankey()
360
+ .nodeId(d => d.id)
361
+ .nodeWidth(12)
362
+ .nodePadding(Math.max(8, Math.min(18, plotH / Math.max(12, sourceCategories.length * 1.45))))
363
+ .extent([[0, 0], [plotW, plotH]])
364
+ .nodeSort((a, b) => {
365
+ if (a.type === b.type) return b.value - a.value || a.rank - b.rank;
366
+ return 0;
367
+ });
368
+
369
+ const sankeyGraph = sankey({
370
+ nodes: nodes.map(node => ({ ...node })),
371
+ links: links.map(link => ({ ...link }))
372
+ });
373
+ const sankeyNodes = sankeyGraph.nodes;
374
+ const sankeyLinks = sankeyGraph.links;
375
+ const sourceNodes = sankeyNodes.filter(node => node.type === "source").sort((a, b) => a.y0 - b.y0);
376
+ const targetNodes = sankeyNodes.filter(node => node.type === "target").sort((a, b) => a.y0 - b.y0);
377
+
378
+ sourceNodes.forEach((node, index) => {
379
+ node.visualRank = index + 1;
380
+ node.color = sourceColor;
381
+ });
382
+ targetNodes.forEach((node, index) => {
383
+ node.visualRank = index + 1;
384
+ node.color = targetPalette[index % targetPalette.length];
385
+ });
386
+
387
+ sankeyLinks.forEach((link, index) => {
388
+ const source = link.source;
389
+ const target = link.target;
390
+ const gradientId = `alluvial-link-gradient-${idSuffix}-${index}`;
391
+ const gradient = defs.append("linearGradient")
392
+ .attr("id", gradientId)
393
+ .attr("gradientUnits", "userSpaceOnUse")
394
+ .attr("x1", source.x1)
395
+ .attr("y1", link.y0)
396
+ .attr("x2", target.x0)
397
+ .attr("y2", link.y1);
398
+ gradient.append("stop")
399
+ .attr("offset", "0%")
400
+ .attr("stop-color", sourceColor)
401
+ .attr("stop-opacity", 0.92);
402
+ gradient.append("stop")
403
+ .attr("offset", "18%")
404
+ .attr("stop-color", d3.interpolateRgb(sourceColor, target.color)(0.22))
405
+ .attr("stop-opacity", 0.80);
406
+ gradient.append("stop")
407
+ .attr("offset", "44%")
408
+ .attr("stop-color", d3.interpolateRgb(sourceColor, target.color)(0.78))
409
+ .attr("stop-opacity", 0.82);
410
+ gradient.append("stop")
411
+ .attr("offset", "66%")
412
+ .attr("stop-color", target.color)
413
+ .attr("stop-opacity", 0.86);
414
+ gradient.append("stop")
415
+ .attr("offset", "100%")
416
+ .attr("stop-color", target.color)
417
+ .attr("stop-opacity", 0.92);
418
+ link.gradientId = gradientId;
419
+ });
420
+
421
+ const plot = root.append("g")
422
+ .attr("class", "alluvial-plot-area")
423
+ .attr("transform", `translate(${plotX}, ${plotY})`);
424
+
425
+ const linkGenerator = d3.sankeyLinkHorizontal();
426
+ plot.append("g")
427
+ .attr("class", "alluvial-links")
428
+ .attr("fill", "none")
429
+ .selectAll("path")
430
+ .data(sankeyLinks)
431
+ .enter()
432
+ .append("path")
433
+ .attr("class", "alluvial-link data-link")
434
+ .attr("d", linkGenerator)
435
+ .attr("stroke", d => `url(#${d.gradientId})`)
436
+ .attr("stroke-width", d => Math.max(1.8, d.width * 1.04))
437
+ .attr("stroke-linecap", "butt")
438
+ .attr("stroke-linejoin", "round")
439
+ .attr("stroke-opacity", 0.82)
440
+ .attr("data-source", d => d.source.name)
441
+ .attr("data-target", d => d.target.name)
442
+ .attr("data-value", d => d.value)
443
+ .attr("data-source-index", d => d.sourceIndex);
444
+
445
+ const node = plot.append("g")
446
+ .attr("class", "alluvial-nodes")
447
+ .selectAll("g")
448
+ .data(sankeyNodes)
449
+ .enter()
450
+ .append("g")
451
+ .attr("class", d => `alluvial-node ${d.type}-node`)
452
+ .attr("data-label", d => d.name)
453
+ .attr("data-value", d => d.value)
454
+ .attr("data-rank", d => d.visualRank || d.rank)
455
+ .attr("data-node-type", d => d.type);
456
+
457
+ node.append("rect")
458
+ .attr("class", "node-bar")
459
+ .attr("x", d => d.x0)
460
+ .attr("y", d => d.y0)
461
+ .attr("width", d => Math.max(10, d.x1 - d.x0))
462
+ .attr("height", d => Math.max(4, d.y1 - d.y0))
463
+ .attr("rx", 4)
464
+ .attr("fill", d => d.color)
465
+ .attr("stroke", d => chartUtils.color.variant(d.color, { mode: "brighter", amount: 0.8 }))
466
+ .attr("stroke-width", 0.7);
467
+
468
+ node.filter(d => d.type === "source")
469
+ .append("circle")
470
+ .attr("class", "source-node-dot")
471
+ .attr("cx", d => d.x0 + 6)
472
+ .attr("cy", d => (d.y0 + d.y1) / 2)
473
+ .attr("r", 5.5)
474
+ .attr("fill", "#FFFFFF")
475
+ .attr("stroke", d => d.color)
476
+ .attr("stroke-width", 3);
477
+
478
+ const labelFont = Math.max(10.8, Math.min(14.2, 16.2 - sourceNodes.length * 0.22));
479
+ plot.append("g")
480
+ .attr("class", "source-labels")
481
+ .selectAll("text")
482
+ .data(sourceNodes)
483
+ .enter()
484
+ .append("text")
485
+ .attr("class", "source-node-label chart-text")
486
+ .attr("x", -22)
487
+ .attr("y", d => (d.y0 + d.y1) / 2)
488
+ .attr("dy", "0.34em")
489
+ .attr("text-anchor", "end")
490
+ .attr("fill", sourceColor)
491
+ .style("font-family", titleFontFamily)
492
+ .style("font-size", `${labelFont}px`)
493
+ .style("font-weight", "900")
494
+ .each(function(d) {
495
+ const label = d3.select(this);
496
+ label.append("tspan")
497
+ .attr("fill", sourceColor)
498
+ .text(shortLabel(d.name, sourceNodes.length > 8 ? 11 : 14));
499
+ label.append("tspan")
500
+ .attr("dx", 4)
501
+ .attr("fill", "#D8E7FF")
502
+ .style("font-size", `${Math.max(8.8, labelFont * 0.72)}px`)
503
+ .style("font-weight", "760")
504
+ .text(`(${formatCompactValue(d.value)})`);
505
+ });
506
+
507
+ const referenceTwoTargetCards = targetNodes.length <= 2;
508
+ const minCardH = referenceTwoTargetCards ? Math.max(126, Math.min(148, plotH * 0.3)) : targetNodes.length <= 4 ? 82 : 48;
509
+ const maxTwoTargetCardH = Math.max(minCardH, Math.min(190, plotH * 0.45));
510
+ const cardGap = referenceTwoTargetCards ? Math.max(8, height * 0.014) : targetNodes.length <= 4 ? 10 : 7;
511
+ const packedCardH = Math.min(minCardH, Math.max(36, (plotH - cardGap * (targetNodes.length - 1)) / Math.max(1, targetNodes.length)));
512
+ const cardLayout = targetNodes.map(nodeDatum => {
513
+ const nodeTop = plotY + nodeDatum.y0;
514
+ const nodeHeight = Math.max(4, nodeDatum.y1 - nodeDatum.y0);
515
+ if (referenceTwoTargetCards) {
516
+ const cardH = Math.min(maxTwoTargetCardH, Math.max(minCardH, nodeHeight));
517
+ return {
518
+ y: nodeTop + nodeHeight / 2 - cardH / 2,
519
+ h: cardH,
520
+ attachedToNode: false,
521
+ relationship: "centerline-aligned"
522
+ };
523
+ }
524
+ return {
525
+ y: plotY + (nodeDatum.y0 + nodeDatum.y1) / 2 - packedCardH / 2,
526
+ h: packedCardH,
527
+ attachedToNode: false
528
+ };
529
+ });
530
+ for (let i = 1; i < cardLayout.length; i++) {
531
+ cardLayout[i].y = Math.max(cardLayout[i].y, cardLayout[i - 1].y + cardLayout[i - 1].h + cardGap);
532
+ }
533
+ const overflow = cardLayout.length
534
+ ? cardLayout[cardLayout.length - 1].y + cardLayout[cardLayout.length - 1].h - (plotY + plotH)
535
+ : 0;
536
+ if (overflow > 0) {
537
+ for (let i = 0; i < cardLayout.length; i++) cardLayout[i].y -= overflow;
538
+ }
539
+ for (let i = cardLayout.length - 2; i >= 0; i--) {
540
+ cardLayout[i].y = Math.min(cardLayout[i].y, cardLayout[i + 1].y - cardLayout[i].h - cardGap);
541
+ }
542
+
543
+ const targetCards = root.append("g")
544
+ .attr("class", "target-summary-cards")
545
+ .selectAll("g")
546
+ .data(targetNodes)
547
+ .enter()
548
+ .append("g")
549
+ .attr("class", "target-card")
550
+ .attr("transform", (d, i) => `translate(${targetCardX}, ${Math.max(plotY, cardLayout[i].y)})`)
551
+ .attr("data-label", d => d.name)
552
+ .attr("data-value", d => d.value)
553
+ .attr("data-rank", d => d.visualRank || d.rank);
554
+
555
+ targetCards.append("rect")
556
+ .attr("width", targetCardW)
557
+ .attr("height", (d, i) => cardLayout[i].h)
558
+ .attr("rx", 10)
559
+ .attr("fill", "#071B33")
560
+ .attr("fill-opacity", 0.92)
561
+ .attr("stroke", cardStroke)
562
+ .attr("stroke-width", 1);
563
+
564
+ targetCards.each(function(d, i) {
565
+ const group = d3.select(this);
566
+ const cardH = cardLayout[i].h;
567
+ const compact = targetNodes.length > 2 || cardH < 96;
568
+ if (compact) {
569
+ addIconSlot(
570
+ group,
571
+ 19,
572
+ cardH / 2,
573
+ 12,
574
+ "target-card-icon-slot target-rank-slot decorative-icon-slot",
575
+ d.color,
576
+ "",
577
+ {
578
+ id: "target_card_icon_slot",
579
+ assetSourcePolicy: "image2-owned-or-neutral-placeholder",
580
+ anchor: "target-card-left-center",
581
+ collisionRule: "inside-target-card-avoid-label-value"
582
+ }
583
+ );
584
+ group.append("text")
585
+ .attr("class", "target-card-label chart-text")
586
+ .attr("x", 38)
587
+ .attr("y", cardH / 2 - 7)
588
+ .attr("text-anchor", "start")
589
+ .attr("fill", textColor)
590
+ .style("font-family", titleFontFamily)
591
+ .style("font-size", "10px")
592
+ .style("font-weight", "900")
593
+ .text(shortLabel(d.name, 15));
594
+ group.append("text")
595
+ .attr("class", "target-card-value chart-text")
596
+ .attr("x", 38)
597
+ .attr("y", cardH / 2 + 16)
598
+ .attr("text-anchor", "start")
599
+ .attr("fill", d.color)
600
+ .style("font-family", titleFontFamily)
601
+ .style("font-size", "14px")
602
+ .style("font-weight", "900")
603
+ .text(formatCompactValue(d.value));
604
+ return;
605
+ }
606
+ const iconR = Math.min(27, cardH * 0.26);
607
+ addIconSlot(
608
+ group,
609
+ targetCardW / 2,
610
+ Math.max(30, iconR + 8),
611
+ iconR,
612
+ "target-card-icon-slot target-rank-slot decorative-icon-slot",
613
+ d.color,
614
+ "",
615
+ {
616
+ id: "target_card_icon_slot",
617
+ assetSourcePolicy: "image2-owned-or-neutral-placeholder",
618
+ anchor: "target-card-top-center",
619
+ collisionRule: "inside-target-card-avoid-label-value"
620
+ }
621
+ );
622
+ const labelLines = wrapLabelLines(d.name, targetCardW < 140 ? 12 : 16, 2);
623
+ const labelY = Math.min(cardH - 50, iconR * 2 + 19) - (labelLines.length - 1) * 7;
624
+ const labelText = group.append("text")
625
+ .attr("class", "target-card-label chart-text")
626
+ .attr("x", targetCardW / 2)
627
+ .attr("y", labelY)
628
+ .attr("text-anchor", "middle")
629
+ .attr("fill", textColor)
630
+ .style("font-family", titleFontFamily)
631
+ .style("font-size", `${targetCardW < 140 ? 12 : 14}px`)
632
+ .style("font-weight", "900");
633
+ labelLines.forEach((line, lineIndex) => {
634
+ labelText.append("tspan")
635
+ .attr("x", targetCardW / 2)
636
+ .attr("dy", lineIndex === 0 ? 0 : 15)
637
+ .text(line);
638
+ });
639
+ group.append("text")
640
+ .attr("class", "target-card-value chart-text")
641
+ .attr("x", targetCardW / 2)
642
+ .attr("y", cardH - 20)
643
+ .attr("text-anchor", "middle")
644
+ .attr("fill", d.color)
645
+ .style("font-family", titleFontFamily)
646
+ .style("font-size", "26px")
647
+ .style("font-weight", "900")
648
+ .text(formatCompactValue(d.value));
649
+ });
650
+
651
+ if (footerLegendH) {
652
+ const footerPadX = panelPadX;
653
+ const footerY = height - footerLegendH - Math.max(18, height * 0.03);
654
+ const footerW = width - footerPadX * 2;
655
+ const footer = root.append("g")
656
+ .attr("class", "footer-legend-strip")
657
+ .attr("transform", `translate(${footerPadX}, ${footerY})`);
658
+ footer.append("rect")
659
+ .attr("width", footerW)
660
+ .attr("height", footerLegendH)
661
+ .attr("rx", 12)
662
+ .attr("fill", "#071B33")
663
+ .attr("fill-opacity", 0.86)
664
+ .attr("stroke", cardStroke)
665
+ .attr("stroke-width", 0.9);
666
+ addIconSlot(
667
+ footer,
668
+ 31,
669
+ footerLegendH / 2,
670
+ Math.min(20, footerLegendH * 0.34),
671
+ "footer-semantic-slot decorative-icon-slot",
672
+ "#183254",
673
+ "",
674
+ {
675
+ id: "footer_semantic_slot",
676
+ assetSourcePolicy: "neutral-placeholder",
677
+ anchor: "footer-left",
678
+ collisionRule: "inside-legend-strip"
679
+ }
680
+ );
681
+
682
+ const legendItems = targetNodes.slice(0, Math.min(4, targetNodes.length));
683
+ const legendX = Math.max(footerW - 178, footerW * 0.58);
684
+ footer.append("line")
685
+ .attr("x1", legendX - 22)
686
+ .attr("x2", legendX - 22)
687
+ .attr("y1", 13)
688
+ .attr("y2", footerLegendH - 13)
689
+ .attr("stroke", cardStroke)
690
+ .attr("stroke-width", 1);
691
+ const legendCols = legendItems.length > 2 ? 2 : 1;
692
+ const legendRows = Math.ceil(legendItems.length / legendCols);
693
+ const legendColW = legendCols > 1 ? Math.max(78, Math.min(96, (footerW - legendX - 8) / 2)) : 0;
694
+ const legendRowH = 18;
695
+ const legendBlockH = (legendRows - 1) * legendRowH + 10;
696
+ const legendStartY = Math.max(12, (footerLegendH - legendBlockH) / 2);
697
+ legendItems.forEach((item, index) => {
698
+ const col = index % legendCols;
699
+ const rowIndex = Math.floor(index / legendCols);
700
+ const row = footer.append("g")
701
+ .attr("class", "legend-row")
702
+ .attr("transform", `translate(${legendX + col * legendColW}, ${legendStartY + rowIndex * legendRowH})`)
703
+ .attr("data-label", item.name)
704
+ .attr("data-value", item.value);
705
+ row.append("rect")
706
+ .attr("width", 10)
707
+ .attr("height", 10)
708
+ .attr("rx", 2)
709
+ .attr("fill", item.color);
710
+ row.append("text")
711
+ .attr("x", 17)
712
+ .attr("y", 9)
713
+ .attr("fill", textColor)
714
+ .style("font-family", fontFamily)
715
+ .style("font-size", "9.5px")
716
+ .style("font-weight", "650")
717
+ .text(shortLabel(item.name, legendCols > 1 ? 11 : 22));
718
+ });
719
+ }
720
+
721
+ return svg.node();
722
+ }
modules/chart_engine/template/d3-js/sankey/alluvial_diagram_02.js ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Alluvial Diagram",
5
+ "chart_name": "alluvial_diagram_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": ["x"],
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 variables = jsonData.variables || {};
28
+ const typography = jsonData.typography || {};
29
+ const sourceColors = jsonData.colors || {};
30
+ const dataColumns = chartUtils.schema.columns(jsonData);
31
+ const colorResolver = chartUtils.color.resolver(jsonData);
32
+ const chartUtilsFormatSample = chartUtils.format.autoText;
33
+ const chartUtilsTextSample = chartUtils.text.estimate;
34
+ const chartUtilsStandard = {
35
+ schema: chartUtils.schema,
36
+ format: chartUtils.format,
37
+ text: chartUtils.text,
38
+ color: chartUtils.color,
39
+ legendLayout: chartUtils.legend.layout,
40
+ legendDraw: chartUtils.legend.draw,
41
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
42
+ };
43
+ const standardChannels = chartUtils.schema.channels(jsonData, {
44
+ x: { fallbackIndex: 0 },
45
+ y: { fallbackIndex: 1 },
46
+ y2: { fallbackIndex: 2 },
47
+ y3: { fallbackIndex: 3 },
48
+ size: { fallbackIndex: 2 },
49
+ group: { fallbackIndex: 2 },
50
+ group2: { fallbackIndex: 3 },
51
+ group3: { fallbackIndex: 4 }
52
+ });
53
+ const sourceData = jsonData.data?.data || [];
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 requestedHeight = Number(variables.height) || 600;
67
+ const height = Math.max(requestedHeight, Math.round(width * 1.34), 780);
68
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
69
+ const titleFontFamily = typography.title?.font_family || fontFamily;
70
+ const textColor = sourceColors.text_color || "#0B2542";
71
+ const mutedText = "#536271";
72
+ const navy = "#082944";
73
+ const border = "#DDE7F2";
74
+ const bg = "#FFFFFF";
75
+ const palette = [
76
+ "#E81E2C",
77
+ "#1267C8",
78
+ "#3D8BFF",
79
+ "#6B48D7",
80
+ "#FF9F1C",
81
+ "#22A699",
82
+ "#6BBE45",
83
+ "#8D5CF6",
84
+ "#EF6F6C",
85
+ "#1785A7"
86
+ ];
87
+
88
+ const svg = d3.select(containerSelector)
89
+ .append("svg")
90
+ .attr("width", "100%")
91
+ .attr("height", height)
92
+ .attr("viewBox", `0 0 ${width} ${height}`)
93
+ .attr("style", "max-width: 100%; height: auto;")
94
+ .attr("xmlns", "http://www.w3.org/2000/svg")
95
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
96
+ .attr("class", "alluvial-02-reference-layout");
97
+
98
+ const addEmptyState = message => {
99
+ svg.append("rect")
100
+ .attr("width", width)
101
+ .attr("height", height)
102
+ .attr("fill", bg);
103
+ svg.append("text")
104
+ .attr("x", width / 2)
105
+ .attr("y", height / 2)
106
+ .attr("text-anchor", "middle")
107
+ .attr("fill", textColor)
108
+ .style("font-family", fontFamily)
109
+ .style("font-size", "16px")
110
+ .text(message);
111
+ return svg.node();
112
+ };
113
+
114
+ if (!categoryField || !valueField || !timeField) {
115
+ return addEmptyState("Missing alluvial comparison fields");
116
+ }
117
+
118
+ const readableName = value => String(value ?? "")
119
+ .replace(/_/g, " ")
120
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
121
+ .replace(/\s+/g, " ")
122
+ .trim();
123
+
124
+ const shortLabel = (value, maxChars = 18) => {
125
+ const clean = readableName(value);
126
+ if (clean.length <= maxChars) return clean;
127
+ const words = clean.split(/[\s/,-]+/).filter(Boolean);
128
+ if (words.length > 1) {
129
+ const firstLast = `${words[0]} ${words[words.length - 1]}`;
130
+ if (firstLast.length <= maxChars) return firstLast;
131
+ }
132
+ return `${clean.slice(0, Math.max(1, maxChars - 1)).trim()}.`;
133
+ };
134
+
135
+ const unit = (valueColumn.unit || "").trim();
136
+ const formatNumber = value => {
137
+ const abs = Math.abs(value);
138
+ const spec = abs >= 1000 ? ",.0f" : abs >= 100 ? ",.0f" : abs >= 10 ? ",.1f" : abs >= 1 ? ",.1f" : ",.2f";
139
+ return d3.format(spec)(value)
140
+ .replace(/\.0$/, "")
141
+ .replace(/(\.\d*?)0+$/, "$1")
142
+ .replace(/\.$/, "");
143
+ };
144
+ const formatLocalValue = value => {
145
+ const number = formatNumber(value);
146
+ if (!unit) return number;
147
+ if (unit === "%") return `${number}%`;
148
+ if (unit.startsWith("$")) return `${unit[0]}${number}${unit.slice(1)}`;
149
+ return `${number} ${unit}`;
150
+ };
151
+ const formatMarkValue = value => {
152
+ const number = formatNumber(value);
153
+ if (!unit) return number;
154
+ if (unit === "%") return `${number}%`;
155
+ if (unit.startsWith("$")) return `${unit[0]}${number}${unit.slice(1)}`;
156
+ if (/million\s+usd/i.test(unit)) return `${number}m`;
157
+ if (/billion\s+usd/i.test(unit)) return `${number}b`;
158
+ if (unit.length > 7 || /[\/\s]/.test(unit)) return number;
159
+ return `${number} ${unit}`;
160
+ };
161
+
162
+ const normalizeTime = value => {
163
+ const raw = String(value ?? "").trim();
164
+ const numeric = Number(raw);
165
+ const sortable = Number.isFinite(numeric) ? numeric : Date.parse(raw);
166
+ return {
167
+ raw,
168
+ sortable: Number.isFinite(sortable) ? sortable : raw
169
+ };
170
+ };
171
+ const compareTime = (a, b) => {
172
+ const ta = normalizeTime(a);
173
+ const tb = normalizeTime(b);
174
+ if (typeof ta.sortable === "number" && typeof tb.sortable === "number") return ta.sortable - tb.sortable;
175
+ return String(a).localeCompare(String(b));
176
+ };
177
+
178
+ const cleanRows = sourceData
179
+ .map((row, index) => ({
180
+ category: String(row[categoryField] ?? "").trim(),
181
+ time: String(row[timeField] ?? "").trim(),
182
+ value: Number(row[valueField]),
183
+ sourceIndex: index
184
+ }))
185
+ .filter(row => row.category && row.time && Number.isFinite(row.value) && row.value >= 0);
186
+
187
+ const timePoints = [...new Set(cleanRows.map(row => row.time))].sort(compareTime);
188
+ if (timePoints.length !== 2) {
189
+ return addEmptyState("Alluvial comparison needs exactly two time points");
190
+ }
191
+
192
+ const firstIndex = new Map();
193
+ cleanRows.forEach(row => {
194
+ if (!firstIndex.has(row.category)) firstIndex.set(row.category, row.sourceIndex);
195
+ });
196
+
197
+ const byCategory = new Map();
198
+ cleanRows.forEach(row => {
199
+ if (!byCategory.has(row.category)) {
200
+ byCategory.set(row.category, {
201
+ category: row.category,
202
+ leftValue: 0,
203
+ rightValue: 0,
204
+ leftSourceIndex: -1,
205
+ rightSourceIndex: -1,
206
+ firstSourceIndex: firstIndex.get(row.category) ?? row.sourceIndex
207
+ });
208
+ }
209
+ const record = byCategory.get(row.category);
210
+ if (row.time === timePoints[0]) {
211
+ record.leftValue += row.value;
212
+ record.leftSourceIndex = row.sourceIndex;
213
+ } else if (row.time === timePoints[1]) {
214
+ record.rightValue += row.value;
215
+ record.rightSourceIndex = row.sourceIndex;
216
+ }
217
+ });
218
+
219
+ const categories = [...byCategory.values()]
220
+ .filter(row => row.leftValue > 0 || row.rightValue > 0)
221
+ .sort((a, b) => b.leftValue - a.leftValue || b.rightValue - a.rightValue || a.firstSourceIndex - b.firstSourceIndex)
222
+ .map((row, index) => ({ ...row, rank: index + 1 }));
223
+
224
+ if (!categories.length) {
225
+ return addEmptyState("No alluvial comparison values");
226
+ }
227
+
228
+ const colorMap = sourceColors.x || {};
229
+ const getColor = (category, index) => colorMap[category] || palette[index % palette.length];
230
+ const getIcon = category => images.field?.[category] || images.fields?.[category] || null;
231
+
232
+ const defs = svg.append("defs");
233
+ const shadow = defs.append("filter")
234
+ .attr("id", "alluvial-02-card-shadow")
235
+ .attr("x", "-15%")
236
+ .attr("y", "-15%")
237
+ .attr("width", "130%")
238
+ .attr("height", "130%");
239
+ shadow.append("feDropShadow")
240
+ .attr("dx", 0)
241
+ .attr("dy", 8)
242
+ .attr("stdDeviation", 12)
243
+ .attr("flood-color", "#A8B8CC")
244
+ .attr("flood-opacity", 0.23);
245
+
246
+ const root = svg.append("g").attr("class", "alluvial-02-reference-panel");
247
+ root.append("rect")
248
+ .attr("class", "chart-style-panel")
249
+ .attr("x", 0)
250
+ .attr("y", 0)
251
+ .attr("width", width)
252
+ .attr("height", height)
253
+ .attr("rx", 0)
254
+ .attr("fill", bg);
255
+
256
+ const panelPad = Math.max(18, width * 0.035);
257
+ const leftIconX = panelPad + 34;
258
+ const leftLabelX = panelPad + 34;
259
+ const leftNodeX = Math.max(130, width * 0.22);
260
+ const nodeWidth = Math.max(13, width * 0.026);
261
+ const rightNodeWidth = Math.max(76, width * 0.135);
262
+ const rightNodeX = Math.min(width - panelPad - rightNodeWidth - 70, width * 0.64);
263
+ const valueBoxW = Math.max(66, Math.min(88, width * 0.13));
264
+ const plotTop = Math.max(104, height * 0.20);
265
+ const plotBottom = height * 0.84;
266
+ const plotH = Math.max(260, plotBottom - plotTop);
267
+ const rowPitch = plotH / categories.length;
268
+ const maxBandH = Math.max(42, Math.min(104, rowPitch * 0.90));
269
+ const minBandH = Math.max(22, Math.min(32, rowPitch * 0.27));
270
+ const maxValue = Math.max(1, d3.max(categories, d => Math.max(d.leftValue, d.rightValue)) || 1);
271
+ const bandHeight = value => value <= 0 ? 0 : Math.max(minBandH, Math.sqrt(value / maxValue) * maxBandH);
272
+
273
+ const yearPillW = Math.max(76, width * 0.14);
274
+ const yearPillH = 31;
275
+ const addYearPill = (x, y, label, className) => {
276
+ const pill = root.append("g")
277
+ .attr("class", `time-pill ${className}`)
278
+ .attr("transform", `translate(${x}, ${y})`);
279
+ pill.append("rect")
280
+ .attr("width", yearPillW)
281
+ .attr("height", yearPillH)
282
+ .attr("rx", 8)
283
+ .attr("fill", navy)
284
+ .attr("filter", "url(#alluvial-02-card-shadow)");
285
+ pill.append("text")
286
+ .attr("x", yearPillW / 2)
287
+ .attr("y", 21)
288
+ .attr("text-anchor", "middle")
289
+ .attr("fill", "#FFFFFF")
290
+ .style("font-family", titleFontFamily)
291
+ .style("font-size", "18px")
292
+ .style("font-weight", "900")
293
+ .text(label);
294
+ return pill;
295
+ };
296
+ addYearPill(leftNodeX, plotTop - 46, timePoints[0], "time-pill-left");
297
+ addYearPill(rightNodeX + rightNodeWidth - yearPillW, plotTop - 46, timePoints[1], "time-pill-right");
298
+
299
+ const addIconSlot = (group, cx, cy, r, className, fill, glyph, href) => {
300
+ const slot = group.append("g")
301
+ .attr("class", className);
302
+ slot.append("circle")
303
+ .attr("cx", cx)
304
+ .attr("cy", cy)
305
+ .attr("r", r)
306
+ .attr("fill", "#FFFFFF")
307
+ .attr("stroke", "#E5ECF4")
308
+ .attr("stroke-width", 1.3)
309
+ .attr("filter", "url(#alluvial-02-card-shadow)");
310
+ if (href) {
311
+ slot.append("image")
312
+ .attr("x", cx - r + 4)
313
+ .attr("y", cy - r + 4)
314
+ .attr("width", (r - 4) * 2)
315
+ .attr("height", (r - 4) * 2)
316
+ .attr("preserveAspectRatio", "xMidYMid meet")
317
+ .attr("href", href);
318
+ } else {
319
+ slot.append("circle")
320
+ .attr("cx", cx)
321
+ .attr("cy", cy)
322
+ .attr("r", r - 5)
323
+ .attr("fill", fill);
324
+ slot.append("text")
325
+ .attr("x", cx)
326
+ .attr("y", cy + 1)
327
+ .attr("text-anchor", "middle")
328
+ .attr("dominant-baseline", "middle")
329
+ .attr("fill", "#FFFFFF")
330
+ .style("font-family", titleFontFamily)
331
+ .style("font-size", `${Math.max(9, r * 0.65)}px`)
332
+ .style("font-weight", "900")
333
+ .text(glyph);
334
+ }
335
+ return slot;
336
+ };
337
+
338
+ const rows = root.append("g")
339
+ .attr("class", "comparison-flow-rows");
340
+
341
+ categories.forEach((row, index) => {
342
+ const color = getColor(row.category, index);
343
+ const centerY = plotTop + rowPitch * (index + 0.5);
344
+ const leftH = bandHeight(row.leftValue);
345
+ const rightH = bandHeight(row.rightValue);
346
+ const leftTop = centerY - leftH / 2;
347
+ const leftBottom = centerY + leftH / 2;
348
+ const rightTop = centerY - rightH / 2;
349
+ const rightBottom = centerY + rightH / 2;
350
+ const midX1 = leftNodeX + nodeWidth + (rightNodeX - leftNodeX - nodeWidth) * 0.34;
351
+ const midX2 = leftNodeX + nodeWidth + (rightNodeX - leftNodeX - nodeWidth) * 0.66;
352
+ const path = [
353
+ `M ${leftNodeX + nodeWidth} ${leftTop}`,
354
+ `C ${midX1} ${leftTop}, ${midX2} ${rightTop}, ${rightNodeX} ${rightTop}`,
355
+ `L ${rightNodeX} ${rightBottom}`,
356
+ `C ${midX2} ${rightBottom}, ${midX1} ${leftBottom}, ${leftNodeX + nodeWidth} ${leftBottom}`,
357
+ "Z"
358
+ ].join(" ");
359
+ const gradientId = `alluvial-02-gradient-${index}`;
360
+ const gradient = defs.append("linearGradient")
361
+ .attr("id", gradientId)
362
+ .attr("gradientUnits", "userSpaceOnUse")
363
+ .attr("x1", leftNodeX)
364
+ .attr("x2", rightNodeX + rightNodeWidth)
365
+ .attr("y1", centerY)
366
+ .attr("y2", centerY);
367
+ gradient.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 0.96);
368
+ gradient.append("stop").attr("offset", "58%").attr("stop-color", chartUtils.color.variant(color, { mode: "brighter", amount: 1.8 })).attr("stop-opacity", 0.72);
369
+ gradient.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0.90);
370
+
371
+ const group = rows.append("g")
372
+ .attr("class", "category-row")
373
+ .attr("data-category", row.category)
374
+ .attr("data-left-time", timePoints[0])
375
+ .attr("data-right-time", timePoints[1])
376
+ .attr("data-left-value", row.leftValue)
377
+ .attr("data-right-value", row.rightValue)
378
+ .attr("data-left-source-index", row.leftSourceIndex)
379
+ .attr("data-right-source-index", row.rightSourceIndex)
380
+ .attr("data-rank", row.rank);
381
+
382
+ addIconSlot(group, leftIconX, centerY - Math.min(9, rowPitch * 0.12), Math.min(23, Math.max(16, rowPitch * 0.23)), "category-icon-slot decorative-icon-slot", color, shortLabel(row.category, 1).slice(0, 1), getIcon(row.category));
383
+ group.append("text")
384
+ .attr("class", "category-label chart-text")
385
+ .attr("x", leftLabelX)
386
+ .attr("y", centerY + Math.min(31, rowPitch * 0.32))
387
+ .attr("text-anchor", "middle")
388
+ .attr("fill", textColor)
389
+ .style("font-family", titleFontFamily)
390
+ .style("font-size", `${Math.max(10, Math.min(15, rowPitch * 0.18))}px`)
391
+ .style("font-weight", "900")
392
+ .text(shortLabel(row.category, 14));
393
+
394
+ group.append("path")
395
+ .attr("class", "alluvial-ribbon data-link")
396
+ .attr("d", path)
397
+ .attr("fill", `url(#${gradientId})`)
398
+ .attr("stroke", "rgba(255,255,255,0.85)")
399
+ .attr("stroke-width", 0.8)
400
+ .attr("data-category", row.category)
401
+ .attr("data-left-value", row.leftValue)
402
+ .attr("data-right-value", row.rightValue);
403
+
404
+ group.append("rect")
405
+ .attr("class", "left-node-bar")
406
+ .attr("x", leftNodeX)
407
+ .attr("y", leftTop)
408
+ .attr("width", nodeWidth)
409
+ .attr("height", Math.max(2, leftH))
410
+ .attr("rx", 3)
411
+ .attr("fill", color)
412
+ .attr("data-category", row.category)
413
+ .attr("data-value", row.leftValue)
414
+ .attr("data-time", timePoints[0]);
415
+
416
+ group.append("rect")
417
+ .attr("class", "right-node-bar")
418
+ .attr("x", rightNodeX)
419
+ .attr("y", rightTop)
420
+ .attr("width", rightNodeWidth)
421
+ .attr("height", Math.max(2, rightH))
422
+ .attr("rx", 3)
423
+ .attr("fill", color)
424
+ .attr("data-category", row.category)
425
+ .attr("data-value", row.rightValue)
426
+ .attr("data-time", timePoints[1]);
427
+
428
+ const minValueBoxH = Math.max(40, Math.min(56, rowPitch * 0.46));
429
+ const leftValueBoxX = leftNodeX + nodeWidth + 12;
430
+ const leftLabelXInBand = leftValueBoxX + valueBoxW / 2;
431
+ const rightLabelXInBand = rightNodeX + rightNodeWidth / 2;
432
+ const rightValueBoxH = Math.max(minValueBoxH, rightH);
433
+ const rightValueBoxY = centerY - rightValueBoxH / 2;
434
+ group.append("rect")
435
+ .attr("class", "right-value-box")
436
+ .attr("x", rightNodeX)
437
+ .attr("y", rightValueBoxY)
438
+ .attr("width", rightNodeWidth)
439
+ .attr("height", rightValueBoxH)
440
+ .attr("rx", 5)
441
+ .attr("fill", chartUtils.color.variant(color, { mode: "darker", amount: 0.15 }))
442
+ .attr("opacity", 0.96)
443
+ .attr("data-category", row.category)
444
+ .attr("data-value", row.rightValue)
445
+ .attr("data-time", timePoints[1]);
446
+ group.append("text")
447
+ .attr("class", "left-value-label chart-text")
448
+ .attr("x", leftNodeX + nodeWidth + Math.max(20, width * 0.045))
449
+ .attr("y", centerY)
450
+ .attr("dy", "0.35em")
451
+ .attr("fill", "#FFFFFF")
452
+ .attr("text-anchor", "start")
453
+ .style("font-family", titleFontFamily)
454
+ .style("font-size", `${Math.max(13, Math.min(19, rowPitch * 0.22))}px`)
455
+ .style("font-weight", "900")
456
+ .text(formatMarkValue(row.leftValue));
457
+
458
+ group.append("text")
459
+ .attr("class", "right-value-label chart-text")
460
+ .attr("x", rightLabelXInBand)
461
+ .attr("y", centerY)
462
+ .attr("dy", "0.35em")
463
+ .attr("fill", "#FFFFFF")
464
+ .attr("text-anchor", "middle")
465
+ .style("font-family", titleFontFamily)
466
+ .style("font-size", `${Math.max(13, Math.min(18, rowPitch * 0.20))}px`)
467
+ .style("font-weight", "900")
468
+ .text(formatMarkValue(row.rightValue));
469
+ });
470
+
471
+ return svg.node();
472
+ }
modules/chart_engine/template/d3-js/sankey/alluvial_diagram_02_dark.js ADDED
@@ -0,0 +1,641 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Alluvial Diagram",
5
+ "chart_name": "alluvial_diagram_02_dark",
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": ["x"],
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": "dark",
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 variables = jsonData.variables || {};
28
+ const typography = jsonData.typography || {};
29
+ const dataColumns = chartUtils.schema.columns(jsonData);
30
+ const colorResolver = chartUtils.color.resolver(jsonData);
31
+ const chartUtilsFormatSample = chartUtils.format.autoText;
32
+ const chartUtilsTextSample = chartUtils.text.estimate;
33
+ const chartUtilsStandard = {
34
+ schema: chartUtils.schema,
35
+ format: chartUtils.format,
36
+ text: chartUtils.text,
37
+ color: chartUtils.color,
38
+ legendLayout: chartUtils.legend.layout,
39
+ legendDraw: chartUtils.legend.draw,
40
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
41
+ };
42
+ const standardChannels = chartUtils.schema.channels(jsonData, {
43
+ x: { fallbackIndex: 0 },
44
+ y: { fallbackIndex: 1 },
45
+ y2: { fallbackIndex: 2 },
46
+ y3: { fallbackIndex: 3 },
47
+ size: { fallbackIndex: 2 },
48
+ group: { fallbackIndex: 2 },
49
+ group2: { fallbackIndex: 3 },
50
+ group3: { fallbackIndex: 4 }
51
+ });
52
+ const sourceData = jsonData.data?.data || [];
53
+ const images = jsonData.images || {};
54
+ const metadata = jsonData.metadata || {};
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(480, 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 = "#F7FBFF";
70
+ const mutedText = "#9CB3C8";
71
+ const accentBlue = "#72B7E6";
72
+ const cardFill = "#061A2D";
73
+ const cardStroke = "#193B5E";
74
+ const palette = [
75
+ "#E33A2E",
76
+ "#E18213",
77
+ "#E5B51E",
78
+ "#4D9D4E",
79
+ "#2C8FD5",
80
+ "#7A5CF0",
81
+ "#28B6A2",
82
+ "#D65D93",
83
+ "#7CB342",
84
+ "#F06242"
85
+ ];
86
+ const idSuffix = String(containerSelector || "alluvial-02-dark")
87
+ .replace(/[^a-zA-Z0-9_-]/g, "")
88
+ .slice(0, 24) || "alluvial";
89
+
90
+ const svg = d3.select(containerSelector)
91
+ .append("svg")
92
+ .attr("width", "100%")
93
+ .attr("height", height)
94
+ .attr("viewBox", `0 0 ${width} ${height}`)
95
+ .attr("style", "max-width: 100%; height: auto;")
96
+ .attr("xmlns", "http://www.w3.org/2000/svg")
97
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
98
+ .attr("class", "alluvial-02-dark-reference-layout");
99
+
100
+ const addEmptyState = message => {
101
+ svg.append("rect")
102
+ .attr("width", width)
103
+ .attr("height", height)
104
+ .attr("fill", "#020A13");
105
+ svg.append("text")
106
+ .attr("x", width / 2)
107
+ .attr("y", height / 2)
108
+ .attr("text-anchor", "middle")
109
+ .attr("fill", textColor)
110
+ .style("font-family", fontFamily)
111
+ .style("font-size", "16px")
112
+ .text(message);
113
+ return svg.node();
114
+ };
115
+
116
+ if (!categoryField || !valueField || !timeField) {
117
+ return addEmptyState("Missing alluvial comparison fields");
118
+ }
119
+
120
+ const readableName = value => String(value ?? "")
121
+ .replace(/_/g, " ")
122
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
123
+ .replace(/\s+/g, " ")
124
+ .trim();
125
+
126
+ const shortLabel = (value, maxChars = 18) => {
127
+ const clean = readableName(value);
128
+ if (clean.length <= maxChars) return clean;
129
+ const withoutParen = clean.replace(/\s*\([^)]*\)\s*/g, " ").replace(/\s+/g, " ").trim();
130
+ if (withoutParen && withoutParen.length <= maxChars) return withoutParen;
131
+ const words = clean
132
+ .replace(/\([^)]*\)/g, " ")
133
+ .split(/[\s/,-]+/)
134
+ .map(word => word.replace(/[^A-Za-z0-9]+/g, ""))
135
+ .filter(Boolean);
136
+ if (words.length > 1) {
137
+ if (words[0] && words[0].length <= maxChars) return words[0];
138
+ const meaningful = words.find(word => !/^(coffee|co|inc|ltd|llc|company|group)$/i.test(word));
139
+ if (meaningful && meaningful.length <= maxChars) return meaningful;
140
+ const parenthetical = (clean.match(/\(([^)]+)\)/) || [])[1];
141
+ if (parenthetical) {
142
+ const compactParen = readableName(parenthetical).replace(/[^A-Za-z0-9]+/g, "");
143
+ if (compactParen.length >= 2 && compactParen.length <= Math.min(maxChars, 8)) return compactParen;
144
+ }
145
+ const compact = words.map(word => word[0]).join("").toUpperCase();
146
+ if (compact.length >= 2 && compact.length <= maxChars) return compact;
147
+ const firstLast = `${words[0]} ${words[words.length - 1]}`;
148
+ if (firstLast.length <= maxChars) return firstLast;
149
+ }
150
+ return `${clean.slice(0, Math.max(1, maxChars - 1)).trim()}.`;
151
+ };
152
+
153
+ const truncateText = (value, maxChars = 60) => {
154
+ const clean = readableName(value);
155
+ if (clean.length <= maxChars) return clean;
156
+ return `${clean.slice(0, Math.max(1, maxChars - 1)).trim()}.`;
157
+ };
158
+
159
+ const appendWrappedText = (selection, text, x, y, maxWidth, fontSize, lineHeight, maxLines) => {
160
+ const words = readableName(text).split(/\s+/).filter(Boolean);
161
+ const approxChars = Math.max(10, Math.floor(maxWidth / Math.max(5.2, fontSize * 0.53)));
162
+ const lines = [];
163
+ let current = "";
164
+ words.forEach(word => {
165
+ const candidate = current ? `${current} ${word}` : word;
166
+ if (candidate.length <= approxChars || !current) {
167
+ current = candidate;
168
+ } else {
169
+ lines.push(current);
170
+ current = word;
171
+ }
172
+ });
173
+ if (current) lines.push(current);
174
+ const visibleLines = lines.slice(0, maxLines);
175
+ if (lines.length > maxLines && visibleLines.length) {
176
+ const last = visibleLines[visibleLines.length - 1];
177
+ visibleLines[visibleLines.length - 1] = last.length > Math.max(8, approxChars - 3)
178
+ ? `${last.slice(0, Math.max(8, approxChars - 3)).trim()}...`
179
+ : `${last}...`;
180
+ }
181
+ visibleLines.forEach((line, index) => {
182
+ selection.append("tspan")
183
+ .attr("x", x)
184
+ .attr("dy", index === 0 ? 0 : lineHeight)
185
+ .text(line);
186
+ });
187
+ selection.attr("x", x).attr("y", y);
188
+ };
189
+
190
+ const unit = (valueColumn.unit || "").trim();
191
+ const unitLine = (() => {
192
+ const description = String(valueColumn.description || "").toLowerCase();
193
+ if (unit === "%" && /gdp/.test(description)) return "of GDP";
194
+ if (unit === "%") return "";
195
+ if (!unit || unit.length > 12) return "";
196
+ return unit;
197
+ })();
198
+ const formatNumber = value => {
199
+ const abs = Math.abs(value);
200
+ const spec = abs >= 1000 ? ",.0f" : abs >= 100 ? ",.0f" : abs >= 10 ? ",.1f" : abs >= 1 ? ",.1f" : ",.2f";
201
+ return d3.format(spec)(abs)
202
+ .replace(/\.0$/, "")
203
+ .replace(/(\.\d*?)0+$/, "$1")
204
+ .replace(/\.$/, "");
205
+ };
206
+ const formatLocalValue = value => {
207
+ const numberValue = Number(value) || 0;
208
+ const sign = numberValue < 0 ? "-" : "";
209
+ const number = formatNumber(numberValue);
210
+ if (!unit) return `${sign}${number}`;
211
+ if (unit === "%") return `${sign}${number}%`;
212
+ if (unit.startsWith("$")) return `${sign}${unit[0]}${number}${unit.slice(1)}`;
213
+ return `${sign}${number} ${unit}`;
214
+ };
215
+ const formatMarkValue = value => {
216
+ const numberValue = Number(value) || 0;
217
+ const sign = numberValue < 0 ? "-" : "";
218
+ if (unit === "%") return `${sign}${d3.format(".1f")(Math.abs(numberValue))}%`;
219
+ const number = formatNumber(numberValue);
220
+ if (!unit) return `${sign}${number}`;
221
+ if (unit.startsWith("$")) return `${sign}${unit[0]}${number}${unit.slice(1)}`;
222
+ if (/million\s+usd/i.test(unit)) return `${sign}${number}m`;
223
+ if (/billion\s+usd/i.test(unit)) return `${sign}${number}b`;
224
+ if (unit.length > 7 || /[\/\s]/.test(unit)) return `${sign}${number}`;
225
+ return `${sign}${number} ${unit}`;
226
+ };
227
+
228
+ const normalizeTime = value => {
229
+ const raw = String(value ?? "").trim();
230
+ const numeric = Number(raw);
231
+ const sortable = Number.isFinite(numeric) ? numeric : Date.parse(raw);
232
+ return {
233
+ raw,
234
+ sortable: Number.isFinite(sortable) ? sortable : raw
235
+ };
236
+ };
237
+ const compareTime = (a, b) => {
238
+ const ta = normalizeTime(a);
239
+ const tb = normalizeTime(b);
240
+ if (typeof ta.sortable === "number" && typeof tb.sortable === "number") return ta.sortable - tb.sortable;
241
+ return String(a).localeCompare(String(b));
242
+ };
243
+
244
+ const cleanRows = sourceData
245
+ .map((row, index) => ({
246
+ category: String(row[categoryField] ?? "").trim(),
247
+ time: String(row[timeField] ?? "").trim(),
248
+ value: Number(row[valueField]),
249
+ sourceIndex: index
250
+ }))
251
+ .filter(row => row.category && row.time && Number.isFinite(row.value) && row.value >= 0);
252
+
253
+ const timePoints = [...new Set(cleanRows.map(row => row.time))].sort(compareTime);
254
+ if (timePoints.length !== 2) {
255
+ return addEmptyState("Alluvial comparison needs exactly two time points");
256
+ }
257
+
258
+ const firstIndex = new Map();
259
+ cleanRows.forEach(row => {
260
+ if (!firstIndex.has(row.category)) firstIndex.set(row.category, row.sourceIndex);
261
+ });
262
+
263
+ const byCategory = new Map();
264
+ cleanRows.forEach(row => {
265
+ if (!byCategory.has(row.category)) {
266
+ byCategory.set(row.category, {
267
+ category: row.category,
268
+ leftValue: 0,
269
+ rightValue: 0,
270
+ leftSourceIndex: -1,
271
+ rightSourceIndex: -1,
272
+ firstSourceIndex: firstIndex.get(row.category) ?? row.sourceIndex
273
+ });
274
+ }
275
+ const record = byCategory.get(row.category);
276
+ if (row.time === timePoints[0]) {
277
+ record.leftValue += row.value;
278
+ record.leftSourceIndex = row.sourceIndex;
279
+ } else if (row.time === timePoints[1]) {
280
+ record.rightValue += row.value;
281
+ record.rightSourceIndex = row.sourceIndex;
282
+ }
283
+ });
284
+
285
+ const categories = [...byCategory.values()]
286
+ .filter(row => row.leftValue > 0 || row.rightValue > 0)
287
+ .sort((a, b) => b.leftValue - a.leftValue || b.rightValue - a.rightValue || a.firstSourceIndex - b.firstSourceIndex)
288
+ .map((row, index) => ({ ...row, rank: index + 1 }));
289
+
290
+ if (!categories.length) {
291
+ return addEmptyState("No alluvial comparison values");
292
+ }
293
+
294
+ const getColor = (category, index) => palette[index % palette.length];
295
+ const getIcon = category => images.field?.[category] || images.fields?.[category] || null;
296
+ const safeColor = color => d3.color(color) || d3.color("#2C8FD5");
297
+
298
+ const defs = svg.append("defs");
299
+ const bgGradient = defs.append("radialGradient")
300
+ .attr("id", `alluvial-02-dark-bg-${idSuffix}`)
301
+ .attr("cx", "50%")
302
+ .attr("cy", "42%")
303
+ .attr("r", "84%");
304
+ bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#0A263E");
305
+ bgGradient.append("stop").attr("offset", "58%").attr("stop-color", "#041524");
306
+ bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#010812");
307
+
308
+ const shadow = defs.append("filter")
309
+ .attr("id", `alluvial-02-dark-shadow-${idSuffix}`)
310
+ .attr("x", "-20%")
311
+ .attr("y", "-20%")
312
+ .attr("width", "140%")
313
+ .attr("height", "140%");
314
+ shadow.append("feDropShadow")
315
+ .attr("dx", 0)
316
+ .attr("dy", 8)
317
+ .attr("stdDeviation", 10)
318
+ .attr("flood-color", "#000000")
319
+ .attr("flood-opacity", 0.38);
320
+
321
+ const glow = defs.append("filter")
322
+ .attr("id", `alluvial-02-dark-glow-${idSuffix}`)
323
+ .attr("x", "-20%")
324
+ .attr("y", "-40%")
325
+ .attr("width", "140%")
326
+ .attr("height", "180%");
327
+ glow.append("feGaussianBlur").attr("stdDeviation", 3).attr("result", "blur");
328
+ const glowMerge = glow.append("feMerge");
329
+ glowMerge.append("feMergeNode").attr("in", "blur");
330
+ glowMerge.append("feMergeNode").attr("in", "SourceGraphic");
331
+
332
+ const root = svg.append("g").attr("class", "alluvial-02-dark-reference-panel");
333
+ root.append("rect")
334
+ .attr("class", "chart-style-panel")
335
+ .attr("x", 0)
336
+ .attr("y", 0)
337
+ .attr("width", width)
338
+ .attr("height", height)
339
+ .attr("rx", 0)
340
+ .attr("fill", `url(#alluvial-02-dark-bg-${idSuffix})`);
341
+
342
+ const plotTop = Math.max(86, height * 0.145);
343
+ const plotBottom = height - Math.max(32, height * 0.075);
344
+ const plotH = Math.max(270, plotBottom - plotTop);
345
+ const rowPitch = plotH / categories.length;
346
+ const rowCardH = Math.max(48, Math.min(92, rowPitch * 0.92));
347
+ const leftIconX = Math.max(54, width * 0.11);
348
+ const leftFlowX = Math.max(104, width * 0.19);
349
+ const rightFlowX = Math.min(width - 76, width * 0.835);
350
+ const rightIconX = Math.min(width - 48, width * 0.92);
351
+ const leftValueX = leftFlowX + Math.max(10, width * 0.018);
352
+ const rightValueX = rightFlowX - Math.max(11, width * 0.025);
353
+ const nodeW = Math.max(4.5, width * 0.009);
354
+ const maxValue = Math.max(1, d3.max(categories, d => Math.max(d.leftValue, d.rightValue)) || 1);
355
+ const maxBandH = Math.max(34, Math.min(rowCardH * 0.94, 86));
356
+ const minBandH = Math.max(13, Math.min(24, rowCardH * 0.30));
357
+ const bandHeight = value => value <= 0 ? 0 : Math.max(minBandH, Math.sqrt(value / maxValue) * maxBandH);
358
+
359
+ const addTimeLabel = (x, y, label, className) => {
360
+ const group = root.append("g")
361
+ .attr("class", `time-label-group ${className}`)
362
+ .attr("transform", `translate(${x}, ${y})`);
363
+ group.append("text")
364
+ .attr("class", "time-label chart-text")
365
+ .attr("x", 0)
366
+ .attr("y", 0)
367
+ .attr("text-anchor", "middle")
368
+ .attr("fill", accentBlue)
369
+ .style("font-family", titleFontFamily)
370
+ .style("font-size", `${Math.max(17, width * 0.035)}px`)
371
+ .style("font-weight", "900")
372
+ .style("letter-spacing", "0px")
373
+ .text(label);
374
+ group.append("line")
375
+ .attr("class", "time-label-rule")
376
+ .attr("x1", -32)
377
+ .attr("x2", 32)
378
+ .attr("y1", 8)
379
+ .attr("y2", 8)
380
+ .attr("stroke", accentBlue)
381
+ .attr("stroke-width", 1)
382
+ .attr("stroke-opacity", 0.35);
383
+ return group;
384
+ };
385
+ addTimeLabel(leftFlowX + Math.max(22, width * 0.045), plotTop - 10, timePoints[0], "time-label-left");
386
+ addTimeLabel(rightFlowX - Math.max(22, width * 0.045), plotTop - 10, timePoints[1], "time-label-right");
387
+
388
+ const addIconSlot = (group, cx, cy, r, className, fill, glyph, href) => {
389
+ const slot = group.append("g")
390
+ .attr("class", className)
391
+ .attr("data-asset-slot", className.includes("right") ? "category_icon_right" : "category_icon_left")
392
+ .attr("data-slot-anchor", "row-centerline")
393
+ .attr("data-slot-collision-rule", "keep inside row card and outside value labels");
394
+ slot.append("circle")
395
+ .attr("cx", cx)
396
+ .attr("cy", cy)
397
+ .attr("r", r)
398
+ .attr("fill", "#F4EFE4")
399
+ .attr("stroke", "#FFFFFF")
400
+ .attr("stroke-width", 1)
401
+ .attr("filter", `url(#alluvial-02-dark-shadow-${idSuffix})`);
402
+ if (href) {
403
+ slot.append("image")
404
+ .attr("x", cx - r + 3)
405
+ .attr("y", cy - r + 3)
406
+ .attr("width", (r - 3) * 2)
407
+ .attr("height", (r - 3) * 2)
408
+ .attr("preserveAspectRatio", "xMidYMid meet")
409
+ .attr("href", href);
410
+ } else {
411
+ slot.append("circle")
412
+ .attr("cx", cx)
413
+ .attr("cy", cy)
414
+ .attr("r", r - 4)
415
+ .attr("fill", fill);
416
+ slot.append("text")
417
+ .attr("x", cx)
418
+ .attr("y", cy + 1)
419
+ .attr("text-anchor", "middle")
420
+ .attr("dominant-baseline", "middle")
421
+ .attr("fill", "#FFFFFF")
422
+ .style("font-family", titleFontFamily)
423
+ .style("font-size", `${Math.max(9, r * 0.70)}px`)
424
+ .style("font-weight", "900")
425
+ .text(glyph);
426
+ }
427
+ return slot;
428
+ };
429
+
430
+ const rows = root.append("g")
431
+ .attr("class", "comparison-flow-rows");
432
+
433
+ categories.forEach((row, index) => {
434
+ const color = getColor(row.category, index);
435
+ const colorObj = safeColor(color);
436
+ const centerY = plotTop + rowPitch * (index + 0.5);
437
+ const leftH = bandHeight(row.leftValue);
438
+ const rightH = bandHeight(row.rightValue);
439
+ const leftTop = centerY - leftH / 2;
440
+ const leftBottom = centerY + leftH / 2;
441
+ const rightTop = centerY - rightH / 2;
442
+ const rightBottom = centerY + rightH / 2;
443
+ const plateauLeftX = leftFlowX + (rightFlowX - leftFlowX) * 0.34;
444
+ const plateauRightX = leftFlowX + (rightFlowX - leftFlowX) * 0.68;
445
+ const midX1 = leftFlowX + (rightFlowX - leftFlowX) * 0.46;
446
+ const midX2 = leftFlowX + (rightFlowX - leftFlowX) * 0.56;
447
+ const neckH = Math.max(8, Math.min(leftH, rightH) * 0.92);
448
+ const path = [
449
+ `M ${leftFlowX} ${leftTop}`,
450
+ `L ${plateauLeftX} ${leftTop}`,
451
+ `C ${midX1} ${leftTop}, ${midX2} ${centerY - neckH / 2}, ${plateauRightX} ${rightTop}`,
452
+ `L ${rightFlowX} ${rightTop}`,
453
+ `L ${rightFlowX} ${rightBottom}`,
454
+ `L ${plateauRightX} ${rightBottom}`,
455
+ `C ${midX2} ${centerY + neckH / 2}, ${midX1} ${leftBottom}, ${plateauLeftX} ${leftBottom}`,
456
+ `L ${leftFlowX} ${leftBottom}`,
457
+ "Z"
458
+ ].join(" ");
459
+ const gradientId = `alluvial-02-dark-gradient-${idSuffix}-${index}`;
460
+ const gradient = defs.append("linearGradient")
461
+ .attr("id", gradientId)
462
+ .attr("gradientUnits", "userSpaceOnUse")
463
+ .attr("x1", leftFlowX)
464
+ .attr("x2", rightFlowX)
465
+ .attr("y1", centerY)
466
+ .attr("y2", centerY);
467
+ gradient.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 0.97);
468
+ gradient.append("stop").attr("offset", "62%").attr("stop-color", chartUtils.color.variant(colorObj, { mode: "brighter", amount: 0.45 })).attr("stop-opacity", 0.88);
469
+ gradient.append("stop").attr("offset", "100%").attr("stop-color", chartUtils.color.variant(colorObj, { mode: "darker", amount: 0.08 })).attr("stop-opacity", 0.95);
470
+
471
+ const group = rows.append("g")
472
+ .attr("class", "category-row")
473
+ .attr("data-category", row.category)
474
+ .attr("data-left-time", timePoints[0])
475
+ .attr("data-right-time", timePoints[1])
476
+ .attr("data-left-value", row.leftValue)
477
+ .attr("data-right-value", row.rightValue)
478
+ .attr("data-left-source-index", row.leftSourceIndex)
479
+ .attr("data-right-source-index", row.rightSourceIndex)
480
+ .attr("data-rank", row.rank);
481
+
482
+ group.append("rect")
483
+ .attr("class", "row-card-background")
484
+ .attr("x", Math.max(26, width * 0.055))
485
+ .attr("y", centerY - rowCardH / 2)
486
+ .attr("width", width - Math.max(26, width * 0.055) * 2)
487
+ .attr("height", rowCardH)
488
+ .attr("rx", 9)
489
+ .attr("fill", cardFill)
490
+ .attr("fill-opacity", 0.78)
491
+ .attr("stroke", "#123354")
492
+ .attr("stroke-opacity", 0.55)
493
+ .attr("filter", `url(#alluvial-02-dark-shadow-${idSuffix})`);
494
+
495
+ const iconR = Math.max(14, Math.min(27, rowCardH * 0.32));
496
+ const iconY = centerY - Math.min(7, rowCardH * 0.08);
497
+ const iconHref = getIcon(row.category);
498
+ addIconSlot(group, leftIconX, iconY, iconR, "category-icon-slot category-icon-slot-left decorative-icon-slot", color, shortLabel(row.category, 1).slice(0, 1), iconHref);
499
+ group.append("text")
500
+ .attr("class", "category-label chart-text")
501
+ .attr("x", leftIconX)
502
+ .attr("y", iconY + iconR + Math.max(9, rowCardH * 0.15))
503
+ .attr("text-anchor", "middle")
504
+ .attr("fill", textColor)
505
+ .style("font-family", titleFontFamily)
506
+ .style("font-size", `${Math.max(9, Math.min(15, rowCardH * 0.22))}px`)
507
+ .style("font-weight", "900")
508
+ .text(shortLabel(row.category, categories.length > 6 ? 11 : 14));
509
+
510
+ group.append("path")
511
+ .attr("class", "alluvial-ribbon data-link")
512
+ .attr("d", path)
513
+ .attr("fill", `url(#${gradientId})`)
514
+ .attr("stroke", chartUtils.color.variant(colorObj, { mode: "brighter", amount: 0.8 }))
515
+ .attr("stroke-opacity", 0.26)
516
+ .attr("stroke-width", 0.8)
517
+ .attr("filter", `url(#alluvial-02-dark-shadow-${idSuffix})`)
518
+ .attr("data-category", row.category)
519
+ .attr("data-left-value", row.leftValue)
520
+ .attr("data-right-value", row.rightValue);
521
+
522
+ group.append("rect")
523
+ .attr("class", "left-node-bar")
524
+ .attr("x", leftFlowX - nodeW)
525
+ .attr("y", leftTop)
526
+ .attr("width", nodeW)
527
+ .attr("height", Math.max(2, leftH))
528
+ .attr("rx", 2.5)
529
+ .attr("fill", chartUtils.color.variant(colorObj, { mode: "brighter", amount: 0.45 }))
530
+ .attr("data-category", row.category)
531
+ .attr("data-value", row.leftValue)
532
+ .attr("data-time", timePoints[0]);
533
+
534
+ group.append("rect")
535
+ .attr("class", "right-node-bar")
536
+ .attr("x", rightFlowX)
537
+ .attr("y", rightTop)
538
+ .attr("width", nodeW)
539
+ .attr("height", Math.max(2, rightH))
540
+ .attr("rx", 2.5)
541
+ .attr("fill", chartUtils.color.variant(colorObj, { mode: "brighter", amount: 0.28 }))
542
+ .attr("data-category", row.category)
543
+ .attr("data-value", row.rightValue)
544
+ .attr("data-time", timePoints[1]);
545
+
546
+ const valueFont = Math.max(14, Math.min(27, rowCardH * 0.36));
547
+ const unitFont = Math.max(8, Math.min(15, rowCardH * 0.18));
548
+ const valueBoxH = Math.max(31, Math.min(48, rowCardH * 0.54));
549
+ const leftBoxW = Math.max(68, Math.min(118, width * 0.17));
550
+ const rightBoxW = Math.max(58, Math.min(98, width * 0.145));
551
+ group.append("rect")
552
+ .attr("class", "left-value-box")
553
+ .attr("x", leftValueX - 4)
554
+ .attr("y", centerY - valueBoxH / 2)
555
+ .attr("width", leftBoxW)
556
+ .attr("height", valueBoxH)
557
+ .attr("rx", 6)
558
+ .attr("fill", "#000000")
559
+ .attr("fill-opacity", 0)
560
+ .attr("data-category", row.category)
561
+ .attr("data-value", row.leftValue)
562
+ .attr("data-time", timePoints[0]);
563
+ group.append("rect")
564
+ .attr("class", "right-value-box")
565
+ .attr("x", rightValueX - rightBoxW + 4)
566
+ .attr("y", centerY - valueBoxH / 2)
567
+ .attr("width", rightBoxW)
568
+ .attr("height", valueBoxH)
569
+ .attr("rx", 6)
570
+ .attr("fill", "#000000")
571
+ .attr("fill-opacity", 0)
572
+ .attr("data-category", row.category)
573
+ .attr("data-value", row.rightValue)
574
+ .attr("data-time", timePoints[1]);
575
+ group.append("text")
576
+ .attr("class", "left-value-label chart-text")
577
+ .attr("x", leftValueX)
578
+ .attr("y", centerY - (unitLine ? Math.max(7, unitFont * 0.55) : 0))
579
+ .attr("fill", "#FFFFFF")
580
+ .attr("text-anchor", "start")
581
+ .attr("paint-order", "stroke")
582
+ .attr("stroke", "rgba(0,0,0,0.38)")
583
+ .attr("stroke-width", 2.2)
584
+ .style("font-family", titleFontFamily)
585
+ .style("font-size", `${valueFont}px`)
586
+ .style("font-weight", "900")
587
+ .style("dominant-baseline", "middle")
588
+ .text(formatMarkValue(row.leftValue));
589
+ if (unitLine) {
590
+ group.append("text")
591
+ .attr("class", "left-value-unit chart-text")
592
+ .attr("x", leftValueX + 1)
593
+ .attr("y", centerY + Math.max(13, valueFont * 0.62))
594
+ .attr("fill", "#FFFFFF")
595
+ .attr("fill-opacity", 0.92)
596
+ .attr("text-anchor", "start")
597
+ .attr("paint-order", "stroke")
598
+ .attr("stroke", "rgba(0,0,0,0.34)")
599
+ .attr("stroke-width", 1.5)
600
+ .style("font-family", fontFamily)
601
+ .style("font-size", `${unitFont}px`)
602
+ .style("font-weight", "500")
603
+ .text(unitLine);
604
+ }
605
+
606
+ group.append("text")
607
+ .attr("class", "right-value-label chart-text")
608
+ .attr("x", rightValueX)
609
+ .attr("y", centerY - (unitLine ? Math.max(7, unitFont * 0.55) : 0))
610
+ .attr("fill", "#FFFFFF")
611
+ .attr("text-anchor", "end")
612
+ .attr("paint-order", "stroke")
613
+ .attr("stroke", "rgba(0,0,0,0.38)")
614
+ .attr("stroke-width", 2.2)
615
+ .style("font-family", titleFontFamily)
616
+ .style("font-size", `${valueFont}px`)
617
+ .style("font-weight", "900")
618
+ .style("dominant-baseline", "middle")
619
+ .text(formatMarkValue(row.rightValue));
620
+ if (unitLine) {
621
+ group.append("text")
622
+ .attr("class", "right-value-unit chart-text")
623
+ .attr("x", rightValueX - 1)
624
+ .attr("y", centerY + Math.max(13, valueFont * 0.62))
625
+ .attr("fill", "#FFFFFF")
626
+ .attr("fill-opacity", 0.92)
627
+ .attr("text-anchor", "end")
628
+ .attr("paint-order", "stroke")
629
+ .attr("stroke", "rgba(0,0,0,0.34)")
630
+ .attr("stroke-width", 1.5)
631
+ .style("font-family", fontFamily)
632
+ .style("font-size", `${unitFont}px`)
633
+ .style("font-weight", "500")
634
+ .text(unitLine);
635
+ }
636
+
637
+ addIconSlot(group, rightIconX, iconY, iconR * 0.92, "category-icon-slot category-icon-slot-right decorative-icon-slot", color, shortLabel(row.category, 1).slice(0, 1), iconHref);
638
+ });
639
+
640
+ return svg.node();
641
+ }