Ray1ee01 commited on
Commit
aafd71e
·
verified ·
1 Parent(s): 77d3d82

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/area_chart/area_chart_01.js ADDED
@@ -0,0 +1,713 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Area Chart",
5
+ "chart_name": "area_chart_01",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["temporal"], ["numerical"]],
8
+ "required_fields_range": [[3, 20], [0, 100]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": [],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
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 chartUtilsTextSample = chartUtils.text.estimate;
31
+ const chartUtilsStandard = {
32
+ schema: chartUtils.schema,
33
+ format: chartUtils.format,
34
+ text: chartUtils.text,
35
+ color: chartUtils.color,
36
+ legendLayout: chartUtils.legend.layout,
37
+ legendDraw: chartUtils.legend.draw,
38
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
39
+ };
40
+ const standardChannels = chartUtils.schema.channels(jsonData, {
41
+ x: { fallbackIndex: 0 },
42
+ y: { fallbackIndex: 1 },
43
+ y2: { fallbackIndex: 2 },
44
+ y3: { fallbackIndex: 3 },
45
+ size: { fallbackIndex: 2 },
46
+ group: { fallbackIndex: 2 },
47
+ group2: { fallbackIndex: 3 },
48
+ group3: { fallbackIndex: 4 }
49
+ });
50
+ const variables = jsonData.variables || {};
51
+ const typography = jsonData.typography || {};
52
+ const sourceColors = jsonData.colors || {};
53
+ const images = jsonData.images || {};
54
+
55
+ d3.select(containerSelector).html("");
56
+
57
+ const xColumn = dataColumns[0] || {};
58
+ const yColumn = dataColumns[1] || {};
59
+ const xField = xColumn.name;
60
+ const yField = yColumn.name;
61
+
62
+ const width = Math.max(640, Number(variables.width) || 800);
63
+ const height = Math.max(950, Number(variables.height) || 600);
64
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
65
+ const titleFontFamily = typography.title?.font_family || fontFamily;
66
+ const textColor = sourceColors.text_color || "#10214A";
67
+ const mutedText = "#5F6B7A";
68
+ const pageFill = "#F5FAFD";
69
+ const panelFill = "#FFFFFF";
70
+ const border = "#E2EAF3";
71
+ const blue = sourceColors.other?.primary || "#1E5CB8";
72
+ const teal = sourceColors.other?.secondary || "#24B897";
73
+ const green = "#2E9346";
74
+ const idSuffix = String(containerSelector || "area-chart-01")
75
+ .replace(/[^a-zA-Z0-9_-]/g, "")
76
+ .slice(0, 24) || "area01";
77
+
78
+ const svg = d3.select(containerSelector)
79
+ .append("svg")
80
+ .attr("width", "100%")
81
+ .attr("height", height)
82
+ .attr("viewBox", `0 0 ${width} ${height}`)
83
+ .attr("style", "max-width: 100%; height: auto;")
84
+ .attr("xmlns", "http://www.w3.org/2000/svg")
85
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
86
+ .attr("class", "area-chart-01-reference-layout");
87
+
88
+ const addEmptyState = message => {
89
+ svg.append("rect")
90
+ .attr("width", width)
91
+ .attr("height", height)
92
+ .attr("fill", pageFill);
93
+ svg.append("text")
94
+ .attr("x", width / 2)
95
+ .attr("y", height / 2)
96
+ .attr("text-anchor", "middle")
97
+ .attr("fill", textColor)
98
+ .style("font-family", fontFamily)
99
+ .style("font-size", "16px")
100
+ .text(message);
101
+ return svg.node();
102
+ };
103
+
104
+ if (!xField || !yField) {
105
+ return addEmptyState("Missing area chart fields");
106
+ }
107
+
108
+ const readableName = value => String(value ?? "")
109
+ .replace(/_/g, " ")
110
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
111
+ .replace(/\s+/g, " ")
112
+ .trim();
113
+
114
+ const shortLabel = (value, maxChars = 28) => {
115
+ const clean = readableName(value);
116
+ if (clean.length <= maxChars) return clean;
117
+ return `${clean.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
118
+ };
119
+ const truncateText = (value, maxChars = 28) => {
120
+ const clean = String(value ?? "").replace(/\s+/g, " ").trim();
121
+ if (clean.length <= maxChars) return clean;
122
+ return `${clean.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
123
+ };
124
+
125
+ const parseTimeValue = value => {
126
+ const raw = String(value ?? "").trim();
127
+ if (!raw) return null;
128
+ if (/^\d{4}$/.test(raw)) return new Date(Number(raw), 0, 1);
129
+ {
130
+ const parsedByHelper = chartUtils.format.parseDate(value);
131
+ if (parsedByHelper instanceof Date && Number.isFinite(parsedByHelper.getTime())) return parsedByHelper;
132
+ }
133
+ const parsed = Date.parse(raw);
134
+ if (Number.isFinite(parsed)) return new Date(parsed);
135
+ const numeric = Number(raw);
136
+ if (Number.isFinite(numeric)) return new Date(numeric, 0, 1);
137
+ return null;
138
+ };
139
+
140
+ const unitRaw = String(yColumn.unit || "").trim();
141
+ const unit = unitRaw.toLowerCase() === "none" ? "" : unitRaw;
142
+ const valueBaseTitle = readableName(yColumn.label || yField);
143
+ const valueTitle = shortLabel(`${valueBaseTitle}${unit ? ` (${unit})` : ""}`, 34);
144
+ const axisTitle = `${shortLabel(valueBaseTitle, unit ? 28 : 34).toUpperCase()}${unit ? ` (${unit})` : ""}`;
145
+ const formatNumber = value => {
146
+ const numberValue = Number(value) || 0;
147
+ const abs = Math.abs(numberValue);
148
+ const spec = abs >= 1000 ? ",.0f" : abs >= 100 ? ",.1f" : abs >= 10 ? ",.1f" : abs >= 1 ? ",.1f" : ",.3f";
149
+ return d3.format(spec)(numberValue)
150
+ .replace(/\.0$/, "")
151
+ .replace(/(\.\d*?)0+$/, "$1")
152
+ .replace(/\.$/, "");
153
+ };
154
+ const formatLocalValue = value => {
155
+ const formatted = formatNumber(value);
156
+ if (!unit) return formatted;
157
+ if (unit === "%") return `${formatted}%`;
158
+ if (unit === "$" || unit.toLowerCase() === "usd") return `$${formatted}`;
159
+ if (unit.startsWith("$")) return `$${formatted}${unit.slice(1)}`;
160
+ return `${formatted} ${unit}`;
161
+ };
162
+ const formatLabelValueParts = value => {
163
+ const formatted = formatNumber(value);
164
+ if (!unit) return { valueText: formatted, unitText: "" };
165
+ if (unit === "%") return { valueText: `${formatted}%`, unitText: "" };
166
+ if (unit === "$" || unit.toLowerCase() === "usd") return { valueText: `$${formatted}`, unitText: "" };
167
+ if (unit.startsWith("$")) return { valueText: `$${formatted}${unit.slice(1)}`, unitText: "" };
168
+ return { valueText: formatted, unitText: unit };
169
+ };
170
+ const formatDateLabel = date => {
171
+ if (!(date instanceof Date) || !Number.isFinite(date.getTime())) return "";
172
+ const year = date.getFullYear();
173
+ return String(year);
174
+ };
175
+
176
+ const rows = chartData
177
+ .map((row, sourceIndex) => {
178
+ const date = parseTimeValue(row[xField]);
179
+ const value = Number(row[yField]);
180
+ return {
181
+ rawX: String(row[xField] ?? ""),
182
+ date,
183
+ value,
184
+ sourceIndex
185
+ };
186
+ })
187
+ .filter(row => row.date && Number.isFinite(row.date.getTime()) && Number.isFinite(row.value))
188
+ .sort((a, b) => a.date - b.date || a.sourceIndex - b.sourceIndex);
189
+
190
+ if (rows.length < 2) {
191
+ return addEmptyState("Area chart needs at least two valid points");
192
+ }
193
+
194
+ const minValue = Math.min(0, d3.min(rows, d => d.value) || 0);
195
+ const maxValue = d3.max(rows, d => d.value) || 1;
196
+ const valueRange = Math.max(0, maxValue - minValue);
197
+ const yPadding = valueRange === 0
198
+ ? Math.max(1, Math.abs(maxValue) * 0.12)
199
+ : Math.max(valueRange * 0.12, Math.abs(maxValue) * 0.04, 0.001);
200
+ const yTop = maxValue + yPadding;
201
+ const firstPoint = rows[0];
202
+ const lastPoint = rows[rows.length - 1];
203
+ const peakPoint = rows.reduce((max, row) => row.value > max.value ? row : max, rows[0]);
204
+ const troughPoint = rows.reduce((min, row) => row.value < min.value ? row : min, rows[0]);
205
+ const delta = lastPoint.value - firstPoint.value;
206
+ const deltaPct = firstPoint.value === 0 ? 0 : (delta / Math.abs(firstPoint.value)) * 100;
207
+ const trendWord = delta < 0 ? "reduction" : delta > 0 ? "increase" : "change";
208
+ const absDeltaPct = Math.abs(deltaPct);
209
+ const deltaText = Math.abs(firstPoint.value) < 1 || absDeltaPct > 999
210
+ ? `${delta >= 0 ? "+" : "-"}${formatLocalValue(Math.abs(delta))} ${trendWord}`
211
+ : `${absDeltaPct.toFixed(1).replace(/\.0$/, "")}% ${trendWord}`;
212
+
213
+ const panelX = -Math.max(104, width * 0.18);
214
+ const panelY = Math.max(18, height * 0.035);
215
+ const panelW = width - panelX * 2;
216
+ const footerH = 0;
217
+ const footerGap = 0;
218
+ const panelH = height - panelY * 2;
219
+
220
+ const chartPadX = Math.max(48, panelW * 0.085);
221
+ const chartPadRight = Math.max(28, panelW * 0.035);
222
+ const plotX = panelX + chartPadX;
223
+ const plotRight = panelX + panelW - chartPadRight;
224
+ const plotBottom = panelY + panelH - Math.max(52, panelH * 0.11);
225
+ const plotTop = panelY + Math.max(76, panelH * 0.17);
226
+ const plotW = Math.max(120, plotRight - plotX);
227
+ const plotH = Math.max(120, plotBottom - plotTop);
228
+ const labelFont = rows.length > 12 ? 10.5 : rows.length > 8 ? 11.5 : 14;
229
+ const valueFont = rows.length > 12 ? 12.5 : rows.length > 8 ? 15.5 : 25;
230
+ const unitFont = rows.length > 12 ? 9.5 : rows.length > 8 ? 10.5 : 14;
231
+ const calloutW = Math.min(260, Math.max(205, panelW * 0.32));
232
+ const calloutH = Math.max(52, Math.min(68, panelH * 0.14));
233
+ const calloutX = panelX + panelW - calloutW - Math.max(20, panelW * 0.03);
234
+ const calloutY = panelY + Math.max(16, panelH * 0.035);
235
+ const calloutAvoidBox = {
236
+ left: calloutX - 8,
237
+ right: calloutX + calloutW + 8,
238
+ top: calloutY - 8,
239
+ bottom: calloutY + calloutH + 10
240
+ };
241
+
242
+ const xScale = d3.scaleTime()
243
+ .domain(d3.extent(rows, d => d.date))
244
+ .range([plotX, plotRight]);
245
+ const yScale = d3.scaleLinear()
246
+ .domain([minValue, yTop])
247
+ .nice(4)
248
+ .range([plotBottom, plotTop]);
249
+
250
+ const defs = svg.append("defs");
251
+ const shadow = defs.append("filter")
252
+ .attr("id", `area-chart-01-shadow-${idSuffix}`)
253
+ .attr("x", "-12%")
254
+ .attr("y", "-12%")
255
+ .attr("width", "124%")
256
+ .attr("height", "124%");
257
+ shadow.append("feDropShadow")
258
+ .attr("dx", 0)
259
+ .attr("dy", 10)
260
+ .attr("stdDeviation", 10)
261
+ .attr("flood-color", "#A8B7C8")
262
+ .attr("flood-opacity", 0.22);
263
+
264
+ const areaGradientId = `area-chart-01-fill-${idSuffix}`;
265
+ const areaGradient = defs.append("linearGradient")
266
+ .attr("id", areaGradientId)
267
+ .attr("gradientUnits", "userSpaceOnUse")
268
+ .attr("x1", plotX)
269
+ .attr("x2", plotRight)
270
+ .attr("y1", plotTop)
271
+ .attr("y2", plotBottom);
272
+ areaGradient.append("stop").attr("offset", "0%").attr("stop-color", blue).attr("stop-opacity", 0.98);
273
+ areaGradient.append("stop").attr("offset", "58%").attr("stop-color", "#218BD2").attr("stop-opacity", 0.90);
274
+ areaGradient.append("stop").attr("offset", "100%").attr("stop-color", teal).attr("stop-opacity", 0.94);
275
+
276
+ const lineGradientId = `area-chart-01-line-${idSuffix}`;
277
+ const lineGradient = defs.append("linearGradient")
278
+ .attr("id", lineGradientId)
279
+ .attr("gradientUnits", "userSpaceOnUse")
280
+ .attr("x1", plotX)
281
+ .attr("x2", plotRight);
282
+ lineGradient.append("stop").attr("offset", "0%").attr("stop-color", "#104CAD");
283
+ lineGradient.append("stop").attr("offset", "100%").attr("stop-color", "#219B66");
284
+
285
+ const dotPatternId = `area-chart-01-dot-pattern-${idSuffix}`;
286
+ const dotPattern = defs.append("pattern")
287
+ .attr("id", dotPatternId)
288
+ .attr("width", 7)
289
+ .attr("height", 7)
290
+ .attr("patternUnits", "userSpaceOnUse");
291
+ dotPattern.append("circle")
292
+ .attr("cx", 2)
293
+ .attr("cy", 2)
294
+ .attr("r", 0.85)
295
+ .attr("fill", "#FFFFFF")
296
+ .attr("opacity", 0.34);
297
+
298
+ const clipId = `area-chart-01-plot-clip-${idSuffix}`;
299
+ defs.append("clipPath")
300
+ .attr("id", clipId)
301
+ .append("rect")
302
+ .attr("x", panelX)
303
+ .attr("y", panelY)
304
+ .attr("width", panelW)
305
+ .attr("height", panelH)
306
+ .attr("rx", 18);
307
+
308
+ const area = d3.area()
309
+ .x(d => xScale(d.date))
310
+ .y0(plotBottom)
311
+ .y1(d => yScale(d.value))
312
+ .curve(d3.curveMonotoneX);
313
+ const line = d3.line()
314
+ .x(d => xScale(d.date))
315
+ .y(d => yScale(d.value))
316
+ .curve(d3.curveMonotoneX);
317
+
318
+ const root = svg.append("g").attr("class", "area-chart-01-reference-panel");
319
+ root.append("rect")
320
+ .attr("class", "chart-style-background")
321
+ .attr("width", width)
322
+ .attr("height", height)
323
+ .attr("fill", pageFill)
324
+ .attr("fill-opacity", 0);
325
+ root.append("rect")
326
+ .attr("class", "chart-style-panel chart-card-panel")
327
+ .attr("x", panelX)
328
+ .attr("y", panelY)
329
+ .attr("width", panelW)
330
+ .attr("height", panelH)
331
+ .attr("rx", 20)
332
+ .attr("fill", panelFill)
333
+ .attr("stroke", border)
334
+ .attr("filter", `url(#area-chart-01-shadow-${idSuffix})`);
335
+
336
+ const yTicks = yScale.ticks(5).filter(tick => tick >= minValue && tick <= yTop);
337
+ const gridLayer = root.append("g").attr("class", "plot-grid-layer");
338
+ yTicks.forEach(tick => {
339
+ const y = yScale(tick);
340
+ gridLayer.append("line")
341
+ .attr("class", "y-grid-line")
342
+ .attr("x1", plotX)
343
+ .attr("x2", plotRight)
344
+ .attr("y1", y)
345
+ .attr("y2", y)
346
+ .attr("stroke", "#E5EDF6")
347
+ .attr("stroke-width", 1);
348
+ gridLayer.append("text")
349
+ .attr("class", "y-axis-tick chart-text")
350
+ .attr("x", plotX - 10)
351
+ .attr("y", y + 4)
352
+ .attr("text-anchor", "end")
353
+ .attr("fill", "#39506B")
354
+ .style("font-family", fontFamily)
355
+ .style("font-size", "10px")
356
+ .text(formatNumber(tick));
357
+ });
358
+ root.append("text")
359
+ .attr("class", "y-axis-title chart-text")
360
+ .attr("transform", `translate(${panelX + 26}, ${plotTop + plotH / 2}) rotate(-90)`)
361
+ .attr("text-anchor", "middle")
362
+ .attr("fill", "#334660")
363
+ .style("font-family", titleFontFamily)
364
+ .style("font-size", "11px")
365
+ .style("font-weight", "900")
366
+ .style("letter-spacing", "0px")
367
+ .text(axisTitle);
368
+
369
+ const guideLayer = root.append("g").attr("class", "point-guide-lines");
370
+ rows.forEach(row => {
371
+ const x = xScale(row.date);
372
+ const y = yScale(row.value);
373
+ guideLayer.append("line")
374
+ .attr("class", "point-guide-line")
375
+ .attr("x1", x)
376
+ .attr("x2", x)
377
+ .attr("y1", y + 6)
378
+ .attr("y2", plotBottom)
379
+ .attr("stroke", "#FFFFFF")
380
+ .attr("stroke-width", 1.1)
381
+ .attr("stroke-dasharray", "4 5")
382
+ .attr("opacity", 0.65)
383
+ .attr("data-source-index", row.sourceIndex)
384
+ .attr("data-x", row.rawX)
385
+ .attr("data-value", row.value);
386
+ });
387
+
388
+ const plotLayer = root.append("g").attr("class", "area-plot-layer").attr("clip-path", `url(#${clipId})`);
389
+ plotLayer.append("path")
390
+ .datum(rows)
391
+ .attr("class", "area-fill data-area")
392
+ .attr("d", area)
393
+ .attr("fill", `url(#${areaGradientId})`)
394
+ .attr("data-point-count", rows.length)
395
+ .attr("data-first-x", firstPoint.rawX)
396
+ .attr("data-first-value", firstPoint.value)
397
+ .attr("data-last-x", lastPoint.rawX)
398
+ .attr("data-last-value", lastPoint.value)
399
+ .attr("data-min-value", troughPoint.value)
400
+ .attr("data-max-value", peakPoint.value);
401
+ plotLayer.append("path")
402
+ .datum(rows)
403
+ .attr("class", "area-dot-pattern decorative-pattern")
404
+ .attr("d", area)
405
+ .attr("fill", `url(#${dotPatternId})`)
406
+ .attr("opacity", 0.9);
407
+ plotLayer.append("path")
408
+ .datum(rows)
409
+ .attr("class", "area-line-underlay")
410
+ .attr("d", line)
411
+ .attr("fill", "none")
412
+ .attr("stroke", "#FFFFFF")
413
+ .attr("stroke-width", 5.2)
414
+ .attr("stroke-linejoin", "round")
415
+ .attr("stroke-linecap", "round")
416
+ .attr("opacity", 0.95);
417
+ plotLayer.append("path")
418
+ .datum(rows)
419
+ .attr("class", "area-line data-line")
420
+ .attr("d", line)
421
+ .attr("fill", "none")
422
+ .attr("stroke", `url(#${lineGradientId})`)
423
+ .attr("stroke-width", 3)
424
+ .attr("stroke-linejoin", "round")
425
+ .attr("stroke-linecap", "round");
426
+
427
+ const pointLayer = root.append("g").attr("class", "area-data-points");
428
+ const labelIndexSet = new Set();
429
+ rows.forEach((_, index) => {
430
+ if (rows.length <= 10 || index === 0 || index === rows.length - 1 || index % Math.ceil(rows.length / 6) === 0) {
431
+ labelIndexSet.add(index);
432
+ }
433
+ });
434
+ labelIndexSet.add(rows.indexOf(peakPoint));
435
+ labelIndexSet.add(rows.indexOf(troughPoint));
436
+ const labelIndices = [...labelIndexSet].sort((a, b) => a - b);
437
+ const labelLayouts = new Map();
438
+ const estimateTextWidth = (text, fontSize, weightFactor = 0.58) => String(text || "").length * fontSize * weightFactor;
439
+ const labelBounds = layout => {
440
+ const bottomLineY = layout.unitText ? layout.unitY : layout.valueY;
441
+ return {
442
+ left: layout.x - layout.width / 2,
443
+ right: layout.x + layout.width / 2,
444
+ top: layout.yearY - labelFont - 2,
445
+ bottom: bottomLineY + 4
446
+ };
447
+ };
448
+ const boundsOverlap = (a, b, pad = 4) => a.left - pad < b.right
449
+ && a.right + pad > b.left
450
+ && a.top - pad < b.bottom
451
+ && a.bottom + pad > b.top;
452
+ const updateLabelLines = layout => {
453
+ layout.valueY = layout.yearY + valueFont + 2;
454
+ layout.unitY = layout.valueY + unitFont + 3;
455
+ layout.bounds = labelBounds(layout);
456
+ };
457
+ const createLabelLayout = index => {
458
+ const row = rows[index];
459
+ const x = xScale(row.date);
460
+ const y = yScale(row.value);
461
+ const { valueText: labelValueText, unitText } = formatLabelValueParts(row.value);
462
+ const yearText = formatDateLabel(row.date);
463
+ const progress = rows.length === 1 ? 0 : index / (rows.length - 1);
464
+ const widthEstimate = Math.max(
465
+ estimateTextWidth(yearText, labelFont, 0.62),
466
+ estimateTextWidth(labelValueText, valueFont, 0.64),
467
+ estimateTextWidth(unitText, unitFont, 0.56)
468
+ ) + 12;
469
+ const nearNeighbor = labelIndices.some(other => {
470
+ if (other === index) return false;
471
+ return Math.abs(xScale(rows[other].date) - x) < Math.max(58, widthEstimate * 0.9);
472
+ });
473
+ const edgeLabel = index === 0 || index === rows.length - 1;
474
+ const bottomReserve = valueFont + (unitText ? unitFont + 5 : 0) + 8;
475
+ const enoughBelow = y < plotBottom - bottomReserve - labelFont - 16;
476
+ let side = !edgeLabel && rows.length > 8 && index % 2 === 0 && nearNeighbor && enoughBelow
477
+ ? "below"
478
+ : "above";
479
+ const minYearY = panelY + labelFont + 18;
480
+ const maxYearY = plotBottom - bottomReserve;
481
+ const aboveClearance = valueFont + (unitText ? unitFont + 5 : 0) + 25;
482
+ let preferredYearY = side === "below"
483
+ ? y + Math.max(28, labelFont + 13)
484
+ : y - Math.max(aboveClearance, panelH * 0.088);
485
+ const layout = {
486
+ index,
487
+ row,
488
+ pointX: x,
489
+ pointY: y,
490
+ x: Math.max(panelX + 38 + widthEstimate / 2, Math.min(panelX + panelW - 38 - widthEstimate / 2, x)),
491
+ yearY: Math.max(minYearY, Math.min(maxYearY, preferredYearY)),
492
+ side,
493
+ width: widthEstimate,
494
+ yearText,
495
+ valueText: labelValueText,
496
+ unitText,
497
+ canMoveBelow: enoughBelow,
498
+ minYearY,
499
+ maxYearY,
500
+ color: d3.interpolateRgb("#124AA5", "#249747")(progress)
501
+ };
502
+ updateLabelLines(layout);
503
+ if (layout.side === "above" && enoughBelow && boundsOverlap(layout.bounds, calloutAvoidBox, 0)) {
504
+ layout.side = "below";
505
+ preferredYearY = y + Math.max(28, labelFont + 13);
506
+ layout.yearY = Math.max(minYearY, Math.min(maxYearY, preferredYearY));
507
+ updateLabelLines(layout);
508
+ }
509
+ return layout;
510
+ };
511
+ const layoutGroups = { above: [], below: [] };
512
+ labelIndices.forEach(index => {
513
+ const layout = createLabelLayout(index);
514
+ labelLayouts.set(index, layout);
515
+ layoutGroups[layout.side].push(layout);
516
+ });
517
+ Object.values(layoutGroups).forEach(group => {
518
+ group.sort((a, b) => a.x - b.x);
519
+ group.forEach((layout, groupIndex) => {
520
+ if (groupIndex === 0) return;
521
+ const previous = group[groupIndex - 1];
522
+ if (!boundsOverlap(layout.bounds, previous.bounds, 5)) return;
523
+
524
+ const targetX = previous.bounds.right + 7 + layout.width / 2;
525
+ const maxX = panelX + panelW - 38 - layout.width / 2;
526
+ if (targetX <= maxX) {
527
+ layout.x = targetX;
528
+ } else {
529
+ const laneShift = Math.max(18, layout.bounds.bottom - layout.bounds.top + 7);
530
+ layout.yearY += layout.side === "above" ? -laneShift : laneShift;
531
+ const bottomReserve = valueFont + (layout.unitText ? unitFont + 5 : 0) + 8;
532
+ layout.yearY = Math.max(panelY + labelFont + 18, Math.min(plotBottom - bottomReserve, layout.yearY));
533
+ }
534
+ updateLabelLines(layout);
535
+ });
536
+ });
537
+ labelLayouts.forEach(layout => {
538
+ if (layout.side !== "above" || !layout.canMoveBelow || !boundsOverlap(layout.bounds, calloutAvoidBox, 0)) return;
539
+ layout.side = "below";
540
+ layout.yearY = Math.max(
541
+ layout.minYearY,
542
+ Math.min(layout.maxYearY, layout.pointY + Math.max(28, labelFont + 13))
543
+ );
544
+ updateLabelLines(layout);
545
+ });
546
+
547
+ rows.forEach((row, index) => {
548
+ const x = xScale(row.date);
549
+ const y = yScale(row.value);
550
+ const progress = rows.length === 1 ? 0 : index / (rows.length - 1);
551
+ const pointColor = d3.interpolateRgb(blue, green)(progress);
552
+ const point = pointLayer.append("g")
553
+ .attr("class", "area-point")
554
+ .attr("data-source-index", row.sourceIndex)
555
+ .attr("data-x", row.rawX)
556
+ .attr("data-value", row.value)
557
+ .attr("transform", `translate(${x}, ${y})`);
558
+ point.append("circle")
559
+ .attr("class", "area-point-outer")
560
+ .attr("r", rows.length <= 7 ? 8.6 : 7.2)
561
+ .attr("fill", "#FFFFFF")
562
+ .attr("stroke", "#DDE8F3")
563
+ .attr("stroke-width", 0.7);
564
+ point.append("circle")
565
+ .attr("class", "area-point-inner")
566
+ .attr("r", rows.length <= 7 ? 6.0 : 5.1)
567
+ .attr("fill", pointColor);
568
+
569
+ if (!labelIndexSet.has(index)) return;
570
+
571
+ const labelLayout = labelLayouts.get(index);
572
+ const labelGroup = root.append("g")
573
+ .attr("class", "point-label-group")
574
+ .attr("data-source-index", row.sourceIndex)
575
+ .attr("data-x", row.rawX)
576
+ .attr("data-value", row.value);
577
+ labelGroup.append("text")
578
+ .attr("class", "point-year-label chart-text")
579
+ .attr("x", labelLayout.x)
580
+ .attr("y", labelLayout.yearY)
581
+ .attr("text-anchor", "middle")
582
+ .attr("fill", labelLayout.color)
583
+ .attr("stroke", "#FFFFFF")
584
+ .attr("stroke-width", 2.2)
585
+ .attr("paint-order", "stroke fill")
586
+ .style("font-family", titleFontFamily)
587
+ .style("font-size", `${labelFont}px`)
588
+ .style("font-weight", "900")
589
+ .text(labelLayout.yearText);
590
+ labelGroup.append("text")
591
+ .attr("class", "point-value-label chart-text")
592
+ .attr("x", labelLayout.x)
593
+ .attr("y", labelLayout.valueY)
594
+ .attr("text-anchor", "middle")
595
+ .attr("fill", labelLayout.color)
596
+ .attr("stroke", "#FFFFFF")
597
+ .attr("stroke-width", 2.4)
598
+ .attr("paint-order", "stroke fill")
599
+ .style("font-family", titleFontFamily)
600
+ .style("font-size", `${valueFont}px`)
601
+ .style("font-weight", "900")
602
+ .attr("data-source-index", row.sourceIndex)
603
+ .attr("data-x", row.rawX)
604
+ .attr("data-value", row.value)
605
+ .text(labelLayout.valueText);
606
+ if (labelLayout.unitText) {
607
+ labelGroup.append("text")
608
+ .attr("class", "point-unit-label chart-text")
609
+ .attr("x", labelLayout.x)
610
+ .attr("y", labelLayout.unitY)
611
+ .attr("text-anchor", "middle")
612
+ .attr("fill", "#26394F")
613
+ .attr("stroke", "#FFFFFF")
614
+ .attr("stroke-width", 2)
615
+ .attr("paint-order", "stroke fill")
616
+ .style("font-family", fontFamily)
617
+ .style("font-size", `${unitFont}px`)
618
+ .style("font-weight", "700")
619
+ .attr("data-source-index", row.sourceIndex)
620
+ .attr("data-x", row.rawX)
621
+ .attr("data-value", row.value)
622
+ .text(labelLayout.unitText);
623
+ }
624
+ });
625
+
626
+ root.append("line")
627
+ .attr("class", "x-axis-line")
628
+ .attr("x1", plotX)
629
+ .attr("x2", plotRight)
630
+ .attr("y1", plotBottom)
631
+ .attr("y2", plotBottom)
632
+ .attr("stroke", "#9CAFC5")
633
+ .attr("stroke-width", 1.5);
634
+ root.append("line")
635
+ .attr("class", "y-axis-line")
636
+ .attr("x1", plotX)
637
+ .attr("x2", plotX)
638
+ .attr("y1", plotTop)
639
+ .attr("y2", plotBottom)
640
+ .attr("stroke", "#B8C7D7")
641
+ .attr("stroke-width", 1.2);
642
+
643
+ const xTickCount = rows.length <= 7 ? rows.length : 6;
644
+ const xTickDates = rows.length <= 7
645
+ ? rows.map(row => row.date)
646
+ : xScale.ticks(xTickCount);
647
+ xTickDates.forEach(tick => {
648
+ const x = xScale(tick);
649
+ root.append("line")
650
+ .attr("class", "x-axis-tick")
651
+ .attr("x1", x)
652
+ .attr("x2", x)
653
+ .attr("y1", plotBottom)
654
+ .attr("y2", plotBottom + 5)
655
+ .attr("stroke", "#9CAFC5")
656
+ .attr("stroke-width", 1);
657
+ root.append("text")
658
+ .attr("class", "x-axis-label chart-text")
659
+ .attr("x", x)
660
+ .attr("y", plotBottom + 22)
661
+ .attr("text-anchor", "middle")
662
+ .attr("fill", "#0E2C6B")
663
+ .style("font-family", titleFontFamily)
664
+ .style("font-size", "11px")
665
+ .style("font-weight", "900")
666
+ .text(formatDateLabel(tick));
667
+ });
668
+
669
+ const callout = root.append("g")
670
+ .attr("class", "insight-callout")
671
+ .attr("transform", `translate(${calloutX}, ${calloutY})`);
672
+ callout.append("rect")
673
+ .attr("class", "insight-callout-card")
674
+ .attr("width", calloutW)
675
+ .attr("height", calloutH)
676
+ .attr("rx", 18)
677
+ .attr("fill", "#FFFFFF")
678
+ .attr("stroke", border)
679
+ .attr("filter", `url(#area-chart-01-shadow-${idSuffix})`);
680
+ const calloutIcon = callout.append("g").attr("class", "callout-icon-slot decorative-icon-slot");
681
+ calloutIcon.append("circle")
682
+ .attr("cx", 30)
683
+ .attr("cy", calloutH / 2)
684
+ .attr("r", 21)
685
+ .attr("fill", delta <= 0 ? green : blue);
686
+ calloutIcon.append("path")
687
+ .attr("d", delta <= 0 ? "M 24 25 L 30 32 L 38 20" : "M 23 34 L 30 25 L 38 31")
688
+ .attr("fill", "none")
689
+ .attr("stroke", "#FFFFFF")
690
+ .attr("stroke-width", 4)
691
+ .attr("stroke-linecap", "round")
692
+ .attr("stroke-linejoin", "round")
693
+ .attr("transform", `translate(0, ${calloutH / 2 - 28})`);
694
+ callout.append("text")
695
+ .attr("class", "callout-title chart-text")
696
+ .attr("x", 62)
697
+ .attr("y", calloutH / 2 - 5)
698
+ .attr("fill", textColor)
699
+ .style("font-family", titleFontFamily)
700
+ .style("font-size", "13px")
701
+ .style("font-weight", "900")
702
+ .text(delta <= 0 ? "Lower trend." : "Rising trend.");
703
+ callout.append("text")
704
+ .attr("class", "callout-subtitle chart-text")
705
+ .attr("x", 62)
706
+ .attr("y", calloutH / 2 + 13)
707
+ .attr("fill", mutedText)
708
+ .style("font-family", fontFamily)
709
+ .style("font-size", "11px")
710
+ .text(truncateText(`${formatLocalValue(firstPoint.value)} to ${formatLocalValue(lastPoint.value)}`, 28));
711
+
712
+ return svg.node();
713
+ }
modules/chart_engine/template/d3-js/area_chart/area_chart_02.js ADDED
@@ -0,0 +1,576 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Area Chart",
5
+ "chart_name": "area_chart_02",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["temporal"], ["numerical"]],
8
+ "required_fields_range": [[5, 30], [-100, 100]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["positive", "negative"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 400,
15
+ "min_width": 800,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes",
21
+ "chart_for": "comparison"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ function makeChart(containerSelector, data) {
27
+ const jsonData = data || {};
28
+ const chartData = jsonData.data?.data || [];
29
+ const dataColumns = chartUtils.schema.columns(jsonData);
30
+ const colorResolver = chartUtils.color.resolver(jsonData);
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
+
55
+ d3.select(containerSelector).html("");
56
+
57
+ const xColumn = dataColumns[0] || {};
58
+ const yColumn = dataColumns[1] || {};
59
+ const xField = xColumn.name;
60
+ const yField = yColumn.name;
61
+
62
+ const width = Math.max(820, Number(variables.width) || 820);
63
+ const height = Math.max(1180, Number(variables.height) || 1180);
64
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
65
+ const titleFontFamily = typography.title?.font_family || fontFamily;
66
+ const textColor = sourceColors.text_color || "#101A33";
67
+ const mutedText = "#34425B";
68
+ const pageFill = "#FFFFFF";
69
+ const panelFill = "#FFFFFF";
70
+ const border = "#D4DCE8";
71
+ const red = sourceColors.other?.primary || sourceColors.other?.positive || "#B3121C";
72
+ const blue = sourceColors.other?.secondary || sourceColors.other?.negative || "#B3121C";
73
+ const neutralLine = "#7F8EA3";
74
+ const idSuffix = String(containerSelector || "area-chart-02")
75
+ .replace(/[^a-zA-Z0-9_-]/g, "")
76
+ .slice(0, 24) || "area02";
77
+
78
+ const svg = d3.select(containerSelector)
79
+ .append("svg")
80
+ .attr("width", "100%")
81
+ .attr("height", height)
82
+ .attr("viewBox", `0 0 ${width} ${height}`)
83
+ .attr("style", "max-width: 100%; height: auto;")
84
+ .attr("xmlns", "http://www.w3.org/2000/svg")
85
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
86
+ .attr("class", "area-chart-02-reference-layout");
87
+
88
+ const addEmptyState = message => {
89
+ svg.append("rect")
90
+ .attr("width", width)
91
+ .attr("height", height)
92
+ .attr("fill", pageFill);
93
+ svg.append("text")
94
+ .attr("x", width / 2)
95
+ .attr("y", height / 2)
96
+ .attr("text-anchor", "middle")
97
+ .attr("fill", textColor)
98
+ .style("font-family", fontFamily)
99
+ .style("font-size", "16px")
100
+ .text(message);
101
+ return svg.node();
102
+ };
103
+
104
+ if (!xField || !yField) {
105
+ return addEmptyState("Missing area chart fields");
106
+ }
107
+
108
+ const readableName = value => String(value ?? "")
109
+ .replace(/_/g, " ")
110
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
111
+ .replace(/\s+/g, " ")
112
+ .trim();
113
+
114
+ const wrapWords = (value, maxChars, maxLines = 2) => {
115
+ const words = String(value ?? "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
116
+ if (!words.length) return [""];
117
+ const lines = [];
118
+ let current = "";
119
+ words.forEach(word => {
120
+ const candidate = current ? `${current} ${word}` : word;
121
+ if (candidate.length <= maxChars || !current) {
122
+ current = candidate;
123
+ } else {
124
+ lines.push(current);
125
+ current = word;
126
+ }
127
+ });
128
+ if (current) lines.push(current);
129
+ if (lines.length <= maxLines) return lines;
130
+ return lines.slice(0, maxLines - 1).concat(lines.slice(maxLines - 1).join(" "));
131
+ };
132
+
133
+ const parseTimeValue = value => {
134
+ const raw = String(value ?? "").trim();
135
+ if (!raw) return null;
136
+ if (/^-?\d{1,6}$/.test(raw)) return new Date(Number(raw), 0, 1);
137
+ {
138
+ const parsedByHelper = chartUtils.format.parseDate(value);
139
+ if (parsedByHelper instanceof Date && Number.isFinite(parsedByHelper.getTime())) return parsedByHelper;
140
+ }
141
+ const parsed = Date.parse(raw);
142
+ if (Number.isFinite(parsed)) return new Date(parsed);
143
+ return null;
144
+ };
145
+
146
+ const unitRaw = String(yColumn.unit || "").trim();
147
+ const unit = unitRaw.toLowerCase() === "none" ? "" : unitRaw;
148
+ const valueBaseTitle = readableName(yColumn.label || yColumn.description || yField);
149
+ const axisDescriptor = readableName(yColumn.description || yColumn.label || yField);
150
+ let axisTitle = `${readableName(yField).toUpperCase()}${unit ? ` (${unit})` : ""}`;
151
+ if (/trust/i.test(axisDescriptor) && /\b0\s*-\s*100\b/.test(axisDescriptor)) {
152
+ axisTitle = "PUBLIC TRUST INDEX (0-100)";
153
+ } else if (axisDescriptor && axisDescriptor.length <= 34) {
154
+ axisTitle = `${axisDescriptor.toUpperCase()}${unit ? ` (${unit})` : ""}`;
155
+ }
156
+ const formatNumber = value => {
157
+ const numberValue = Number(value);
158
+ if (!Number.isFinite(numberValue)) return String(value ?? "");
159
+ const abs = Math.abs(numberValue);
160
+ const spec = abs >= 1000 ? ".0f" : abs >= 100 ? ".1f" : abs >= 10 ? ".1f" : abs >= 1 ? ".2f" : ".3f";
161
+ return d3.format(spec)(numberValue)
162
+ .replace(/\.0$/, "")
163
+ .replace(/(\.\d*?)0+$/, "$1")
164
+ .replace(/\.$/, "");
165
+ };
166
+ const formatLocalValue = value => {
167
+ const formatted = formatNumber(value);
168
+ if (!unit) return formatted;
169
+ if (unit === "%") return `${formatted}%`;
170
+ if (unit === "$" || unit.toLowerCase() === "usd") return `$${formatted}`;
171
+ if (unit.startsWith("$")) return `$${formatted}${unit.slice(1)}`;
172
+ return `${formatted} ${unit}`;
173
+ };
174
+ const formatCalloutValue = value => {
175
+ const numberValue = Number(value);
176
+ if (!Number.isFinite(numberValue)) return String(value ?? "");
177
+ const formatted = d3.format(".2f")(numberValue);
178
+ if (!unit) return formatted;
179
+ if (unit === "%") return `${formatted}%`;
180
+ if (unit === "$" || unit.toLowerCase() === "usd") return `$${formatted}`;
181
+ if (unit.startsWith("$")) return `$${formatted}${unit.slice(1)}`;
182
+ return `${formatted} ${unit}`;
183
+ };
184
+ const formatDateLabel = date => {
185
+ if (!(date instanceof Date) || !Number.isFinite(date.getTime())) return "";
186
+ return String(date.getFullYear());
187
+ };
188
+
189
+ const rows = chartData
190
+ .map((row, sourceIndex) => {
191
+ const date = parseTimeValue(row[xField]);
192
+ const value = Number(row[yField]);
193
+ return {
194
+ rawX: String(row[xField] ?? ""),
195
+ date,
196
+ value,
197
+ sourceIndex
198
+ };
199
+ })
200
+ .filter(row => row.date && Number.isFinite(row.date.getTime()) && Number.isFinite(row.value))
201
+ .sort((a, b) => a.date - b.date || a.sourceIndex - b.sourceIndex);
202
+
203
+ if (rows.length < 2) {
204
+ return addEmptyState("Area chart needs at least two valid points");
205
+ }
206
+
207
+ const processedRows = [];
208
+ rows.forEach((row, index) => {
209
+ processedRows.push(row);
210
+ if (index >= rows.length - 1) return;
211
+ const next = rows[index + 1];
212
+ if (!((row.value >= 0 && next.value < 0) || (row.value < 0 && next.value >= 0))) return;
213
+ const ratio = Math.abs(row.value) / (Math.abs(row.value) + Math.abs(next.value));
214
+ const zeroTime = new Date(row.date.getTime() + ratio * (next.date.getTime() - row.date.getTime()));
215
+ processedRows.push({
216
+ rawX: `${row.rawX}-${next.rawX}`,
217
+ date: zeroTime,
218
+ value: 0,
219
+ sourceIndex: `zero-${index}`,
220
+ isZeroPoint: true
221
+ });
222
+ });
223
+
224
+ const minObserved = d3.min(rows, d => d.value) ?? 0;
225
+ const maxObserved = d3.max(rows, d => d.value) ?? 1;
226
+ const minValue = Math.min(0, minObserved);
227
+ const maxValue = Math.max(0, maxObserved);
228
+ const valueRange = Math.max(1, maxValue - minValue);
229
+ const yPadding = Math.max(valueRange * 0.08, 1);
230
+ const yDomain = [minValue - yPadding, maxValue + yPadding];
231
+ if (minObserved >= 0) yDomain[0] = 0;
232
+ if (maxObserved <= 0) yDomain[1] = 0;
233
+
234
+ const firstPoint = rows[0];
235
+ const lastPoint = rows[rows.length - 1];
236
+ const peakPoint = rows.reduce((max, row) => row.value > max.value ? row : max, rows[0]);
237
+ const troughPoint = rows.reduce((min, row) => row.value < min.value ? row : min, rows[0]);
238
+ const delta = lastPoint.value - firstPoint.value;
239
+ const trendColor = delta < 0 ? red : blue;
240
+ const trendWord = delta < 0 ? "declined" : delta > 0 ? "rebounded" : "held steady";
241
+
242
+ const panelX = Math.max(18, width * 0.018);
243
+ const panelY = Math.max(8, height * 0.012);
244
+ const panelW = width - panelX * 2;
245
+ const panelH = height - panelY * 2;
246
+ const plotX = panelX + Math.max(62, panelW * 0.075);
247
+ const plotRight = panelX + panelW - Math.max(12, panelW * 0.016);
248
+ const plotTop = panelY + Math.max(58, panelH * 0.082);
249
+ const plotBottom = panelY + panelH - Math.max(58, panelH * 0.082);
250
+ const plotW = Math.max(160, plotRight - plotX);
251
+ const plotH = Math.max(160, plotBottom - plotTop);
252
+
253
+ const xScale = d3.scaleTime()
254
+ .domain(d3.extent(rows, d => d.date))
255
+ .range([plotX, plotRight]);
256
+ const yScale = d3.scaleLinear()
257
+ .domain(yDomain)
258
+ .nice(5)
259
+ .range([plotBottom, plotTop]);
260
+ const zeroY = yScale(0);
261
+
262
+ const defs = svg.append("defs");
263
+ const shadow = defs.append("filter")
264
+ .attr("id", `area-chart-02-shadow-${idSuffix}`)
265
+ .attr("x", "-12%")
266
+ .attr("y", "-12%")
267
+ .attr("width", "124%")
268
+ .attr("height", "124%");
269
+ shadow.append("feDropShadow")
270
+ .attr("dx", 0)
271
+ .attr("dy", 8)
272
+ .attr("stdDeviation", 9)
273
+ .attr("flood-color", "#B9C2CD")
274
+ .attr("flood-opacity", 0.20);
275
+
276
+ const positiveGradientId = `area-chart-02-positive-${idSuffix}`;
277
+ const positiveGradient = defs.append("linearGradient")
278
+ .attr("id", positiveGradientId)
279
+ .attr("gradientUnits", "userSpaceOnUse")
280
+ .attr("x1", 0)
281
+ .attr("x2", 0)
282
+ .attr("y1", plotTop)
283
+ .attr("y2", zeroY);
284
+ positiveGradient.append("stop").attr("offset", "0%").attr("stop-color", red).attr("stop-opacity", 0.98);
285
+ positiveGradient.append("stop").attr("offset", "54%").attr("stop-color", red).attr("stop-opacity", 0.74);
286
+ positiveGradient.append("stop").attr("offset", "100%").attr("stop-color", red).attr("stop-opacity", 0.08);
287
+
288
+ const negativeGradientId = `area-chart-02-negative-${idSuffix}`;
289
+ const negativeGradient = defs.append("linearGradient")
290
+ .attr("id", negativeGradientId)
291
+ .attr("gradientUnits", "userSpaceOnUse")
292
+ .attr("x1", 0)
293
+ .attr("x2", 0)
294
+ .attr("y1", zeroY)
295
+ .attr("y2", plotBottom);
296
+ negativeGradient.append("stop").attr("offset", "0%").attr("stop-color", red).attr("stop-opacity", 0.18);
297
+ negativeGradient.append("stop").attr("offset", "100%").attr("stop-color", red).attr("stop-opacity", 0.74);
298
+
299
+ const clipId = `area-chart-02-clip-${idSuffix}`;
300
+ defs.append("clipPath")
301
+ .attr("id", clipId)
302
+ .append("rect")
303
+ .attr("x", plotX)
304
+ .attr("y", plotTop)
305
+ .attr("width", plotW)
306
+ .attr("height", plotH);
307
+
308
+ const redArea = d3.area()
309
+ .x(d => xScale(d.date))
310
+ .y0(zeroY)
311
+ .y1(d => yScale(d.value))
312
+ .curve(d3.curveMonotoneX);
313
+ const dataLine = d3.line()
314
+ .x(d => xScale(d.date))
315
+ .y(d => yScale(d.value))
316
+ .curve(d3.curveMonotoneX);
317
+
318
+ const root = svg.append("g").attr("class", "area-chart-02-reference-panel");
319
+ root.append("rect")
320
+ .attr("class", "chart-style-background")
321
+ .attr("width", width)
322
+ .attr("height", height)
323
+ .attr("fill", pageFill)
324
+ .attr("fill-opacity", 0);
325
+ root.append("rect")
326
+ .attr("class", "chart-style-panel chart-card-panel")
327
+ .attr("x", panelX)
328
+ .attr("y", panelY)
329
+ .attr("width", panelW)
330
+ .attr("height", panelH)
331
+ .attr("rx", 0)
332
+ .attr("fill", panelFill)
333
+ .attr("fill-opacity", 0)
334
+ .attr("stroke", "none");
335
+
336
+ root.append("text")
337
+ .attr("class", "chart-axis-caption chart-text")
338
+ .attr("x", plotX)
339
+ .attr("y", plotTop - 22)
340
+ .attr("fill", mutedText)
341
+ .style("font-family", titleFontFamily)
342
+ .style("font-size", "16px")
343
+ .style("font-weight", "900")
344
+ .style("letter-spacing", "0px")
345
+ .text(axisTitle);
346
+
347
+ const niceYDomain = yScale.domain();
348
+ const yTicks = yScale.ticks(6).filter(tick => tick >= niceYDomain[0] && tick <= niceYDomain[1]);
349
+ const gridLayer = root.append("g").attr("class", "plot-grid-layer");
350
+ yTicks.forEach(tick => {
351
+ const y = yScale(tick);
352
+ gridLayer.append("line")
353
+ .attr("class", "y-grid-line")
354
+ .attr("x1", plotX)
355
+ .attr("x2", plotRight)
356
+ .attr("y1", y)
357
+ .attr("y2", y)
358
+ .attr("stroke", tick === 0 ? "#AEB9C8" : "#C9D0DC")
359
+ .attr("stroke-width", tick === 0 ? 1.2 : 1)
360
+ .attr("stroke-dasharray", tick === 0 ? null : "6 7")
361
+ .attr("opacity", tick === 0 ? 0.9 : 0.72);
362
+ gridLayer.append("text")
363
+ .attr("class", "y-axis-tick chart-text")
364
+ .attr("x", plotX - 22)
365
+ .attr("y", y + 6)
366
+ .attr("text-anchor", "end")
367
+ .attr("fill", "#31405A")
368
+ .style("font-family", fontFamily)
369
+ .style("font-size", "18px")
370
+ .style("font-weight", "700")
371
+ .text(formatNumber(tick));
372
+ });
373
+
374
+ const plotLayer = root.append("g")
375
+ .attr("class", "area-plot-layer")
376
+ .attr("clip-path", `url(#${clipId})`);
377
+ plotLayer.append("path")
378
+ .datum(rows)
379
+ .attr("class", "area-fill data-area")
380
+ .attr("d", redArea)
381
+ .attr("fill", `url(#${positiveGradientId})`)
382
+ .attr("data-point-count", rows.length)
383
+ .attr("data-first-x", firstPoint.rawX)
384
+ .attr("data-first-value", firstPoint.value)
385
+ .attr("data-last-x", lastPoint.rawX)
386
+ .attr("data-last-value", lastPoint.value)
387
+ .attr("data-min-x", troughPoint.rawX)
388
+ .attr("data-min-value", troughPoint.value)
389
+ .attr("data-max-x", peakPoint.rawX)
390
+ .attr("data-max-value", peakPoint.value);
391
+ plotLayer.append("path")
392
+ .datum(rows)
393
+ .attr("class", "area-line-underlay")
394
+ .attr("d", dataLine)
395
+ .attr("fill", "none")
396
+ .attr("stroke", "#FFFFFF")
397
+ .attr("stroke-width", 4.8)
398
+ .attr("stroke-linejoin", "round")
399
+ .attr("stroke-linecap", "round")
400
+ .attr("opacity", 0.94);
401
+ plotLayer.append("path")
402
+ .datum(rows)
403
+ .attr("class", "area-line data-line")
404
+ .attr("d", dataLine)
405
+ .attr("fill", "none")
406
+ .attr("stroke", red)
407
+ .attr("stroke-width", 3.6)
408
+ .attr("stroke-linejoin", "round")
409
+ .attr("stroke-linecap", "round");
410
+
411
+ root.append("line")
412
+ .attr("class", "zero-line")
413
+ .attr("x1", plotX)
414
+ .attr("x2", plotRight)
415
+ .attr("y1", zeroY)
416
+ .attr("y2", zeroY)
417
+ .attr("stroke", neutralLine)
418
+ .attr("stroke-width", 1.2);
419
+ root.append("line")
420
+ .attr("class", "x-axis-line")
421
+ .attr("x1", plotX)
422
+ .attr("x2", plotRight)
423
+ .attr("y1", plotBottom)
424
+ .attr("y2", plotBottom)
425
+ .attr("stroke", "#BBC6D3")
426
+ .attr("stroke-width", 1.1);
427
+ root.append("line")
428
+ .attr("class", "y-axis-line")
429
+ .attr("x1", plotX)
430
+ .attr("x2", plotX)
431
+ .attr("y1", plotTop)
432
+ .attr("y2", plotBottom)
433
+ .attr("stroke", "#BBC6D3")
434
+ .attr("stroke-width", 1.1);
435
+
436
+ const tickMap = new Map();
437
+ xScale.ticks(6).concat([firstPoint.date, lastPoint.date]).forEach(tick => {
438
+ tickMap.set(formatDateLabel(tick), tick);
439
+ });
440
+ const xTickDates = Array.from(tickMap.values()).sort((a, b) => a - b);
441
+ xTickDates.forEach(tick => {
442
+ const x = xScale(tick);
443
+ root.append("text")
444
+ .attr("class", "x-axis-label chart-text")
445
+ .attr("x", x)
446
+ .attr("y", plotBottom + 34)
447
+ .attr("text-anchor", "middle")
448
+ .attr("fill", "#26394F")
449
+ .style("font-family", fontFamily)
450
+ .style("font-size", "17px")
451
+ .style("font-weight", "600")
452
+ .text(formatDateLabel(tick));
453
+ });
454
+
455
+ const keyPointSourceIndices = new Set([firstPoint, troughPoint, lastPoint].map(row => String(row.sourceIndex)));
456
+ const pointLayer = root.append("g").attr("class", "area-data-points");
457
+ rows.forEach(row => {
458
+ if (!keyPointSourceIndices.has(String(row.sourceIndex))) return;
459
+ const x = xScale(row.date);
460
+ const y = yScale(row.value);
461
+ const pointColor = red;
462
+ const point = pointLayer.append("g")
463
+ .attr("class", "area-point")
464
+ .attr("data-source-index", row.sourceIndex)
465
+ .attr("data-x", row.rawX)
466
+ .attr("data-value", row.value)
467
+ .attr("transform", `translate(${x}, ${y})`);
468
+ point.append("circle")
469
+ .attr("class", "area-point-outer")
470
+ .attr("r", 7.2)
471
+ .attr("fill", "#FFFFFF")
472
+ .attr("stroke", pointColor)
473
+ .attr("stroke-width", 2.3);
474
+ point.append("circle")
475
+ .attr("class", "area-point-inner")
476
+ .attr("r", 4.2)
477
+ .attr("fill", pointColor);
478
+ });
479
+
480
+ const labelRows = [];
481
+ const addLabelRow = (row, role) => {
482
+ if (labelRows.some(item => item.row.sourceIndex === row.sourceIndex)) return;
483
+ labelRows.push({ row, role });
484
+ };
485
+ addLabelRow(firstPoint, "first");
486
+ addLabelRow(troughPoint, "min");
487
+ addLabelRow(lastPoint, "last");
488
+ labelRows.sort((a, b) => a.row.date - b.row.date);
489
+
490
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
491
+ const boxFrom = (x, y, w, h) => ({ left: x, right: x + w, top: y, bottom: y + h, width: w, height: h });
492
+ const boxesOverlap = (a, b, pad = 0) => !(a.right + pad <= b.left || b.right + pad <= a.left || a.bottom + pad <= b.top || b.bottom + pad <= a.top);
493
+ const placedLabelBoxes = [];
494
+
495
+ const labelLayer = root.append("g").attr("class", "key-point-labels");
496
+ labelRows.forEach((item, index) => {
497
+ const row = item.row;
498
+ const x = xScale(row.date);
499
+ const y = yScale(row.value);
500
+ const valueText = formatCalloutValue(row.value);
501
+ const yearText = formatDateLabel(row.date);
502
+ const isMin = item.role === "min";
503
+ const labelW = Math.max(72, valueText.length * 13 + 28);
504
+ const labelH = isMin ? 42 : 40;
505
+ const referenceOffsets = isMin
506
+ ? [{ dx: -labelW - 14, dy: -54 }, { dx: -labelW / 2, dy: -62 }, { dx: 14, dy: -56 }, { dx: -labelW - 8, dy: 20 }]
507
+ : item.role === "first"
508
+ ? [{ dx: 14, dy: -52 }, { dx: 6, dy: -46 }, { dx: -labelW / 2, dy: -50 }]
509
+ : [{ dx: -labelW + 8, dy: -52 }, { dx: -labelW - 6, dy: -46 }, { dx: -labelW / 2, dy: -50 }];
510
+ let best = { tx: clamp(x + referenceOffsets[0].dx, plotX + 4, plotRight - labelW - 4), ty: clamp(y + referenceOffsets[0].dy, plotTop + 4, plotBottom - labelH - 4), score: Number.POSITIVE_INFINITY };
511
+ referenceOffsets.forEach(offset => {
512
+ const candidateX = clamp(x + offset.dx, plotX + 4, plotRight - labelW - 4);
513
+ const candidateY = clamp(y + offset.dy, plotTop + 4, plotBottom - labelH - 4);
514
+ const box = boxFrom(candidateX, candidateY, labelW, labelH);
515
+ const overlapPenalty = placedLabelBoxes.reduce((sum, placed) => sum + (boxesOverlap(box, placed, 5) ? 60000 : 0), 0);
516
+ const pointDx = Math.abs(candidateX + labelW / 2 - x);
517
+ const pointDy = Math.abs(candidateY + labelH / 2 - y);
518
+ const rolePenalty = isMin ? Math.abs(candidateY + labelH - (y - 18)) : Math.abs(candidateY + labelH - (y - 14));
519
+ const score = overlapPenalty + pointDx * 0.45 + pointDy * 0.15 + rolePenalty;
520
+ if (score < best.score) best = { tx: candidateX, ty: candidateY, score };
521
+ });
522
+ const tx = best.tx;
523
+ const ty = best.ty;
524
+ placedLabelBoxes.push(boxFrom(tx, ty, labelW, labelH));
525
+ const fillColor = isMin ? "#F8FAFD" : red;
526
+ const strokeColor = isMin ? "#25314A" : red;
527
+ const textFill = isMin ? "#25314A" : "#FFFFFF";
528
+ const label = labelLayer.append("g")
529
+ .attr("class", `key-point-label ${item.role}-point-label`)
530
+ .attr("data-source-index", row.sourceIndex)
531
+ .attr("data-x", row.rawX)
532
+ .attr("data-value", row.value)
533
+ .attr("transform", `translate(${tx}, ${ty})`);
534
+
535
+ if (isMin) {
536
+ const leaderStartX = x - tx;
537
+ const leaderStartY = y - ty;
538
+ const leaderEndX = labelW * 0.78;
539
+ const leaderEndY = labelH;
540
+ label.append("path")
541
+ .attr("class", "key-point-leader")
542
+ .attr("d", `M ${leaderEndX} ${leaderEndY} L ${leaderStartX} ${leaderStartY}`)
543
+ .attr("fill", "none")
544
+ .attr("stroke", strokeColor)
545
+ .attr("stroke-width", 1.4)
546
+ .attr("opacity", 0.92);
547
+ } else {
548
+ const notchX = clamp(x - tx, 12, labelW - 12);
549
+ label.append("path")
550
+ .attr("class", "key-point-label-notch")
551
+ .attr("d", `M ${notchX - 7} ${labelH - 1} L ${notchX} ${labelH + 10} L ${notchX + 7} ${labelH - 1} Z`)
552
+ .attr("fill", fillColor);
553
+ }
554
+ label.append("rect")
555
+ .attr("class", "key-point-label-bg")
556
+ .attr("width", labelW)
557
+ .attr("height", labelH)
558
+ .attr("rx", isMin ? 7 : 6)
559
+ .attr("fill", fillColor)
560
+ .attr("stroke", strokeColor)
561
+ .attr("stroke-width", isMin ? 1.4 : 0)
562
+ .attr("opacity", 0.98);
563
+ label.append("text")
564
+ .attr("class", "key-point-value chart-text")
565
+ .attr("x", labelW / 2)
566
+ .attr("y", isMin ? 27 : 27)
567
+ .attr("text-anchor", "middle")
568
+ .attr("fill", textFill)
569
+ .style("font-family", titleFontFamily)
570
+ .style("font-size", "21px")
571
+ .style("font-weight", "900")
572
+ .text(valueText);
573
+ });
574
+
575
+ return svg.node();
576
+ }
modules/chart_engine/template/d3-js/area_chart/area_chart_03.js ADDED
@@ -0,0 +1,545 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Area Chart",
5
+ "chart_name": "area_chart_03",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["temporal"], ["numerical"]],
8
+ "required_fields_range": [[5, 30], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 400,
15
+ "min_width": 800,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes",
21
+ "chart_for": "trend"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ function makeChart(containerSelector, data) {
27
+ const jsonData = data || {};
28
+ const chartData = jsonData.data?.data || [];
29
+ const dataColumns = chartUtils.schema.columns(jsonData);
30
+ const colorResolver = chartUtils.color.resolver(jsonData);
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
+
55
+ d3.select(containerSelector).html("");
56
+
57
+ const xColumn = dataColumns[0] || {};
58
+ const yColumn = dataColumns[1] || {};
59
+ const xField = xColumn.name;
60
+ const yField = yColumn.name;
61
+
62
+ const width = Math.max(900, Number(variables.width) || 900);
63
+ const height = Math.max(1180, Number(variables.height) || 1180);
64
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
65
+ const titleFontFamily = typography.title?.font_family || fontFamily;
66
+ const textColor = sourceColors.text_color || "#27313D";
67
+ const pageFill = "#F7FAFC";
68
+ const panelFill = "#FFFFFF";
69
+ const idSuffix = String(containerSelector || "area-chart-03")
70
+ .replace(/[^a-zA-Z0-9_-]/g, "")
71
+ .slice(0, 24) || "area03";
72
+
73
+ const svg = d3.select(containerSelector)
74
+ .append("svg")
75
+ .attr("width", "100%")
76
+ .attr("height", height)
77
+ .attr("viewBox", `0 0 ${width} ${height}`)
78
+ .attr("style", "max-width: 100%; height: auto;")
79
+ .attr("xmlns", "http://www.w3.org/2000/svg")
80
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
81
+ .attr("class", "area-chart-03-reference-layout");
82
+
83
+ const addEmptyState = message => {
84
+ svg.append("rect")
85
+ .attr("width", width)
86
+ .attr("height", height)
87
+ .attr("fill", pageFill);
88
+ svg.append("text")
89
+ .attr("x", width / 2)
90
+ .attr("y", height / 2)
91
+ .attr("text-anchor", "middle")
92
+ .attr("fill", textColor)
93
+ .style("font-family", fontFamily)
94
+ .style("font-size", "16px")
95
+ .text(message);
96
+ return svg.node();
97
+ };
98
+
99
+ if (!xField || !yField) {
100
+ return addEmptyState("Missing area chart fields");
101
+ }
102
+
103
+ const readableName = value => String(value ?? "")
104
+ .replace(/_/g, " ")
105
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
106
+ .replace(/\s+/g, " ")
107
+ .trim();
108
+
109
+ const shortText = (value, maxChars = 32) => {
110
+ const clean = readableName(value);
111
+ if (clean.length <= maxChars) return clean;
112
+ return `${clean.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
113
+ };
114
+
115
+ const wrapWords = (value, maxChars, maxLines = 2) => {
116
+ const words = String(value ?? "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
117
+ if (!words.length) return [""];
118
+ const lines = [];
119
+ let current = "";
120
+ words.forEach(word => {
121
+ const candidate = current ? `${current} ${word}` : word;
122
+ if (candidate.length <= maxChars || !current) {
123
+ current = candidate;
124
+ } else {
125
+ lines.push(current);
126
+ current = word;
127
+ }
128
+ });
129
+ if (current) lines.push(current);
130
+ if (lines.length <= maxLines) return lines;
131
+ return lines.slice(0, maxLines - 1).concat(lines.slice(maxLines - 1).join(" "));
132
+ };
133
+
134
+ const parseTimeValue = value => {
135
+ const raw = String(value ?? "").trim();
136
+ if (!raw) return null;
137
+ if (/^-?\d{1,6}$/.test(raw)) return new Date(Number(raw), 0, 1);
138
+ {
139
+ const parsedByHelper = chartUtils.format.parseDate(value);
140
+ if (parsedByHelper instanceof Date && Number.isFinite(parsedByHelper.getTime())) return parsedByHelper;
141
+ }
142
+ const parsed = Date.parse(raw);
143
+ if (Number.isFinite(parsed)) return new Date(parsed);
144
+ return null;
145
+ };
146
+
147
+ const unitRaw = String(yColumn.unit || "").trim();
148
+ const unit = unitRaw.toLowerCase() === "none" ? "" : unitRaw;
149
+ const valueTitle = readableName(yColumn.label || yField || yColumn.description);
150
+ const axisTitle = `${shortText(valueTitle, 34).toUpperCase()}${unit ? ` (${unit})` : ""}`;
151
+
152
+ const formatNumber = value => {
153
+ const numberValue = Number(value);
154
+ if (!Number.isFinite(numberValue)) return String(value ?? "");
155
+ const abs = Math.abs(numberValue);
156
+ const spec = abs >= 100000 ? ",.0f" : abs >= 1000 ? ",.1f" : abs >= 100 ? ",.1f" : abs >= 10 ? ",.1f" : abs >= 1 ? ",.2f" : ",.3f";
157
+ return d3.format(spec)(numberValue)
158
+ .replace(/\.0$/, "")
159
+ .replace(/(\.\d*?)0+$/, "$1")
160
+ .replace(/\.$/, "");
161
+ };
162
+
163
+ const formatLocalValue = value => {
164
+ const formatted = formatNumber(value);
165
+ if (!unit) return formatted;
166
+ if (unit === "%") return `${formatted}%`;
167
+ if (unit === "$" || unit.toLowerCase() === "usd") return `$${formatted}`;
168
+ if (unit.startsWith("$")) return `$${formatted}${unit.slice(1)}`;
169
+ return `${formatted} ${unit}`;
170
+ };
171
+
172
+ const formatCompactValue = value => {
173
+ const numberValue = Number(value);
174
+ if (!Number.isFinite(numberValue)) return String(value ?? "");
175
+ const abs = Math.abs(numberValue);
176
+ const prefix = unit === "$" || unit.toLowerCase() === "usd" || unit.startsWith("$") ? "$" : "";
177
+ const suffix = unit === "%" ? "%" : "";
178
+ if (abs >= 1000000) return `${prefix}${d3.format(".2f")(numberValue / 1000000).replace(/\.00$/, "")}M${suffix}`;
179
+ if (abs >= 1000) return `${prefix}${d3.format(".2f")(numberValue / 1000).replace(/\.00$/, "")}K${suffix}`;
180
+ return `${prefix}${formatNumber(numberValue)}${suffix}`;
181
+ };
182
+
183
+ const formatDateLabel = date => {
184
+ if (!(date instanceof Date) || !Number.isFinite(date.getTime())) return "";
185
+ return String(date.getFullYear());
186
+ };
187
+
188
+ const rows = chartData
189
+ .map((row, sourceIndex) => {
190
+ const date = parseTimeValue(row[xField]);
191
+ const value = Number(row[yField]);
192
+ return {
193
+ rawX: String(row[xField] ?? ""),
194
+ date,
195
+ value,
196
+ sourceIndex
197
+ };
198
+ })
199
+ .filter(row => row.date && Number.isFinite(row.date.getTime()) && Number.isFinite(row.value))
200
+ .sort((a, b) => a.date - b.date || a.sourceIndex - b.sourceIndex);
201
+
202
+ if (rows.length < 2) {
203
+ return addEmptyState("Area chart needs at least two valid points");
204
+ }
205
+
206
+ const minObserved = d3.min(rows, d => d.value) ?? 0;
207
+ const maxObserved = d3.max(rows, d => d.value) ?? 1;
208
+ const rawRange = Math.max(maxObserved - minObserved, Math.abs(maxObserved) * 0.08, 1);
209
+ let yBottom = minObserved - rawRange * 0.22;
210
+ let yTop = maxObserved + rawRange * 0.14;
211
+ if (minObserved >= 0) yBottom = Math.max(0, yBottom);
212
+ if (minObserved === maxObserved) {
213
+ const pad = Math.max(Math.abs(maxObserved) * 0.12, 1);
214
+ yBottom = minObserved >= 0 ? Math.max(0, minObserved - pad) : minObserved - pad;
215
+ yTop = maxObserved + pad;
216
+ }
217
+
218
+ const firstPoint = rows[0];
219
+ const lastPoint = rows[rows.length - 1];
220
+ const peakPoint = rows.reduce((max, row) => row.value > max.value ? row : max, rows[0]);
221
+ const lowPoint = rows.reduce((min, row) => row.value < min.value ? row : min, rows[0]);
222
+ const delta = lastPoint.value - firstPoint.value;
223
+ const trendWord = delta < 0 ? "lower" : delta > 0 ? "higher" : "steady";
224
+ const changeNoun = delta < 0 ? "Decrease" : delta > 0 ? "Increase" : "Change";
225
+ const percentDelta = firstPoint.value !== 0 ? Math.abs(delta / firstPoint.value) * 100 : null;
226
+ const changeMetric = percentDelta === null ? formatLocalValue(Math.abs(delta)) : `${d3.format(".1f")(percentDelta)}%`;
227
+ const firstYear = formatDateLabel(firstPoint.date);
228
+ const lastYear = formatDateLabel(lastPoint.date);
229
+
230
+ const referenceBlue = "#0B72D9";
231
+ const referenceBlueDark = "#075CB5";
232
+ const referenceBlueLight = "#CFE8FF";
233
+ const referenceGreen = "#22A843";
234
+ const referenceGreenDark = "#0E7E24";
235
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
236
+
237
+ const outerX = Math.max(28, width * 0.055);
238
+ const outerY = Math.max(18, height * 0.035);
239
+ const bottomRowH = 0;
240
+ const infoH = 0;
241
+ const infoGap = 0;
242
+ const infoY = height - outerY;
243
+ const chartCardX = outerX;
244
+ const chartCardY = Math.max(126, height * 0.21);
245
+ const chartCardW = width - outerX * 2;
246
+ const chartCardH = Math.max(300, infoY - chartCardY - Math.max(18, height * 0.035));
247
+ const plotX = chartCardX + Math.max(72, chartCardW * 0.115);
248
+ const plotRight = chartCardX + chartCardW - Math.max(36, chartCardW * 0.052);
249
+ const plotTop = chartCardY + Math.max(78, chartCardH * 0.19);
250
+ const plotBottom = chartCardY + chartCardH - Math.max(44, chartCardH * 0.12);
251
+ const plotW = Math.max(180, plotRight - plotX);
252
+ const plotH = Math.max(135, plotBottom - plotTop);
253
+ const baselineY = plotBottom;
254
+
255
+ const xScale = d3.scaleTime()
256
+ .domain(d3.extent(rows, d => d.date))
257
+ .range([plotX, plotRight]);
258
+ const yScale = d3.scaleLinear()
259
+ .domain([yBottom, yTop])
260
+ .nice(5)
261
+ .range([plotBottom, plotTop]);
262
+
263
+ const defs = svg.append("defs");
264
+ const cardShadow = defs.append("filter")
265
+ .attr("id", `area-chart-03-card-shadow-${idSuffix}`)
266
+ .attr("x", "-10%")
267
+ .attr("y", "-10%")
268
+ .attr("width", "120%")
269
+ .attr("height", "120%");
270
+ cardShadow.append("feDropShadow")
271
+ .attr("dx", 0)
272
+ .attr("dy", 7)
273
+ .attr("stdDeviation", 8)
274
+ .attr("flood-color", "#C9D8E6")
275
+ .attr("flood-opacity", 0.28);
276
+
277
+ const areaGradientId = `area-chart-03-fill-${idSuffix}`;
278
+ const areaGradient = defs.append("linearGradient")
279
+ .attr("id", areaGradientId)
280
+ .attr("gradientUnits", "userSpaceOnUse")
281
+ .attr("x1", 0)
282
+ .attr("x2", 0)
283
+ .attr("y1", plotTop)
284
+ .attr("y2", baselineY);
285
+ areaGradient.append("stop").attr("offset", "0%").attr("stop-color", referenceBlue).attr("stop-opacity", 0.42);
286
+ areaGradient.append("stop").attr("offset", "58%").attr("stop-color", referenceBlueLight).attr("stop-opacity", 0.42);
287
+ areaGradient.append("stop").attr("offset", "100%").attr("stop-color", "#F3FAFF").attr("stop-opacity", 0.88);
288
+
289
+ const lineGradientId = `area-chart-03-line-${idSuffix}`;
290
+ const lineGradient = defs.append("linearGradient")
291
+ .attr("id", lineGradientId)
292
+ .attr("gradientUnits", "userSpaceOnUse")
293
+ .attr("x1", plotX)
294
+ .attr("x2", plotRight)
295
+ .attr("y1", 0)
296
+ .attr("y2", 0);
297
+ lineGradient.append("stop").attr("offset", "0%").attr("stop-color", referenceBlueDark);
298
+ lineGradient.append("stop").attr("offset", "100%").attr("stop-color", referenceBlue);
299
+
300
+ const clipId = `area-chart-03-clip-${idSuffix}`;
301
+ defs.append("clipPath")
302
+ .attr("id", clipId)
303
+ .append("rect")
304
+ .attr("x", plotX)
305
+ .attr("y", plotTop)
306
+ .attr("width", plotW)
307
+ .attr("height", plotH);
308
+
309
+ const area = d3.area()
310
+ .x(d => xScale(d.date))
311
+ .y0(baselineY)
312
+ .y1(d => yScale(d.value))
313
+ .curve(d3.curveMonotoneX);
314
+ const line = d3.line()
315
+ .x(d => xScale(d.date))
316
+ .y(d => yScale(d.value))
317
+ .curve(d3.curveMonotoneX);
318
+
319
+ const root = svg.append("g").attr("class", "area-chart-03-reference-panel");
320
+ root.append("rect")
321
+ .attr("class", "chart-style-background")
322
+ .attr("width", width)
323
+ .attr("height", height)
324
+ .attr("fill", "#F7FBFF")
325
+ .attr("fill-opacity", 0);
326
+ root.append("circle")
327
+ .attr("class", "soft-background-cloud decorative-shape")
328
+ .attr("cx", width * 0.60)
329
+ .attr("cy", Math.max(56, height * 0.115))
330
+ .attr("r", Math.max(24, width * 0.045))
331
+ .attr("fill", "#FFFFFF")
332
+ .attr("opacity", 0.86);
333
+ root.append("circle")
334
+ .attr("class", "soft-background-cloud decorative-shape")
335
+ .attr("cx", width * 0.48)
336
+ .attr("cy", Math.max(78, height * 0.145))
337
+ .attr("r", Math.max(18, width * 0.035))
338
+ .attr("fill", "#FFFFFF")
339
+ .attr("opacity", 0.76);
340
+
341
+ const decorW = Math.min(180, Math.max(142, width * 0.24));
342
+ root.append("rect")
343
+ .attr("class", "top-decorative-image-slot decorative-image-slot")
344
+ .attr("data-reserved-slot", "header_illustration_slot")
345
+ .attr("x", width - outerX - decorW)
346
+ .attr("y", Math.max(8, outerY * 0.45))
347
+ .attr("width", decorW)
348
+ .attr("height", decorW * 0.72)
349
+ .attr("fill", "none")
350
+ .attr("stroke", "none");
351
+
352
+ root.append("rect")
353
+ .attr("class", "chart-style-panel chart-card-panel")
354
+ .attr("x", chartCardX)
355
+ .attr("y", chartCardY)
356
+ .attr("width", chartCardW)
357
+ .attr("height", chartCardH)
358
+ .attr("rx", 14)
359
+ .attr("fill", panelFill)
360
+ .attr("stroke", "#E6EEF7")
361
+ .attr("filter", `url(#area-chart-03-card-shadow-${idSuffix})`);
362
+
363
+ root.append("text")
364
+ .attr("class", "chart-axis-caption chart-text")
365
+ .attr("x", chartCardX + 28)
366
+ .attr("y", chartCardY + 34)
367
+ .attr("fill", referenceBlueDark)
368
+ .style("font-family", titleFontFamily)
369
+ .style("font-size", "12px")
370
+ .style("font-weight", "900")
371
+ .style("letter-spacing", "0px")
372
+ .text(axisTitle);
373
+
374
+ const yTicks = yScale.ticks(5);
375
+ const gridLayer = root.append("g").attr("class", "plot-grid-layer");
376
+ yTicks.forEach(tick => {
377
+ const y = yScale(tick);
378
+ gridLayer.append("line")
379
+ .attr("class", "y-grid-line")
380
+ .attr("x1", plotX)
381
+ .attr("x2", plotRight)
382
+ .attr("y1", y)
383
+ .attr("y2", y)
384
+ .attr("stroke", "#D7E1EF")
385
+ .attr("stroke-width", 0.9)
386
+ .attr("stroke-dasharray", "4 5");
387
+ gridLayer.append("text")
388
+ .attr("class", "y-axis-tick chart-text")
389
+ .attr("x", plotX - 16)
390
+ .attr("y", y + 4)
391
+ .attr("text-anchor", "end")
392
+ .attr("fill", "#2F3B52")
393
+ .style("font-family", fontFamily)
394
+ .style("font-size", "12px")
395
+ .text(formatNumber(tick));
396
+ });
397
+
398
+ const plotLayer = root.append("g")
399
+ .attr("class", "area-plot-layer")
400
+ .attr("clip-path", `url(#${clipId})`);
401
+ plotLayer.append("path")
402
+ .datum(rows)
403
+ .attr("class", "area-fill data-area positive-area-fill")
404
+ .attr("d", area)
405
+ .attr("fill", `url(#${areaGradientId})`)
406
+ .attr("data-point-count", rows.length)
407
+ .attr("data-first-x", firstPoint.rawX)
408
+ .attr("data-first-value", firstPoint.value)
409
+ .attr("data-last-x", lastPoint.rawX)
410
+ .attr("data-last-value", lastPoint.value)
411
+ .attr("data-min-x", lowPoint.rawX)
412
+ .attr("data-min-value", lowPoint.value)
413
+ .attr("data-max-x", peakPoint.rawX)
414
+ .attr("data-max-value", peakPoint.value);
415
+ plotLayer.append("path")
416
+ .datum(rows)
417
+ .attr("class", "area-line-underlay")
418
+ .attr("d", line)
419
+ .attr("fill", "none")
420
+ .attr("stroke", "#FFFFFF")
421
+ .attr("stroke-width", 7.5)
422
+ .attr("stroke-linejoin", "round")
423
+ .attr("stroke-linecap", "round")
424
+ .attr("opacity", 0.96);
425
+ plotLayer.append("path")
426
+ .datum(rows)
427
+ .attr("class", "area-line data-line")
428
+ .attr("d", line)
429
+ .attr("fill", "none")
430
+ .attr("stroke", `url(#${lineGradientId})`)
431
+ .attr("stroke-width", 4)
432
+ .attr("stroke-linejoin", "round")
433
+ .attr("stroke-linecap", "round");
434
+
435
+ root.append("line")
436
+ .attr("class", "x-axis-line")
437
+ .attr("x1", plotX)
438
+ .attr("x2", plotRight)
439
+ .attr("y1", baselineY)
440
+ .attr("y2", baselineY)
441
+ .attr("stroke", "#BAC8D8")
442
+ .attr("stroke-width", 1.1);
443
+ root.append("line")
444
+ .attr("class", "y-axis-line")
445
+ .attr("x1", plotX)
446
+ .attr("x2", plotX)
447
+ .attr("y1", plotTop)
448
+ .attr("y2", plotBottom)
449
+ .attr("stroke", "#BAC8D8")
450
+ .attr("stroke-width", 1.1);
451
+
452
+ const tickKey = date => date instanceof Date ? date.getTime() : new Date(date).getTime();
453
+ const xTickDates = rows.length <= 5
454
+ ? rows.map(row => row.date)
455
+ : Array.from(
456
+ new Map(
457
+ [firstPoint.date, ...xScale.ticks(4), lastPoint.date]
458
+ .filter(date => date >= firstPoint.date && date <= lastPoint.date)
459
+ .map(date => [tickKey(date), date])
460
+ ).values()
461
+ ).sort((a, b) => a - b);
462
+ xTickDates.forEach(tick => {
463
+ const x = xScale(tick);
464
+ root.append("text")
465
+ .attr("class", "x-axis-label chart-text")
466
+ .attr("x", x)
467
+ .attr("y", baselineY + 26)
468
+ .attr("text-anchor", "middle")
469
+ .attr("fill", "#0F1E38")
470
+ .style("font-family", fontFamily)
471
+ .style("font-size", "12px")
472
+ .text(formatDateLabel(tick));
473
+ });
474
+
475
+ const pointLayer = root.append("g").attr("class", "area-data-points");
476
+ rows.forEach(row => {
477
+ const x = xScale(row.date);
478
+ const y = yScale(row.value);
479
+ const point = pointLayer.append("g")
480
+ .attr("class", "area-point")
481
+ .attr("data-source-index", row.sourceIndex)
482
+ .attr("data-x", row.rawX)
483
+ .attr("data-value", row.value)
484
+ .attr("transform", `translate(${x}, ${y})`);
485
+ point.append("circle")
486
+ .attr("class", "area-point-outer")
487
+ .attr("r", 6.4)
488
+ .attr("fill", "#FFFFFF")
489
+ .attr("stroke", referenceBlue)
490
+ .attr("stroke-width", 3.1);
491
+ point.append("circle")
492
+ .attr("class", "area-point-inner")
493
+ .attr("r", 2.5)
494
+ .attr("fill", referenceBlue);
495
+ });
496
+
497
+ const labelLayer = root.append("g").attr("class", "key-point-labels");
498
+ [
499
+ { row: firstPoint, role: "first" },
500
+ { row: lastPoint, role: "last" }
501
+ ].filter((item, index, array) => array.findIndex(other => other.row.sourceIndex === item.row.sourceIndex) === index)
502
+ .forEach(item => {
503
+ const row = item.row;
504
+ const x = xScale(row.date);
505
+ const y = yScale(row.value);
506
+ const valueText = formatCompactValue(row.value);
507
+ const labelW = Math.max(58, valueText.length * 8.3 + 20);
508
+ const labelH = 27;
509
+ const tx = item["role"] === "first"
510
+ ? clamp(x - labelW * 0.18, plotX + 4, plotRight - labelW - 4)
511
+ : clamp(x - labelW * 0.72, plotX + 4, plotRight - labelW - 4);
512
+ const ty = clamp(y - labelH - 17, plotTop + 4, plotBottom - labelH - 8);
513
+ const pointerX = clamp(x - tx, 10, labelW - 10);
514
+ const label = labelLayer.append("g")
515
+ .attr("class", `key-point-label ${item.role}-point-label`)
516
+ .attr("data-source-index", row.sourceIndex)
517
+ .attr("data-x", row.rawX)
518
+ .attr("data-value", row.value)
519
+ .attr("transform", `translate(${tx}, ${ty})`);
520
+ label.append("rect")
521
+ .attr("class", "key-point-label-bg")
522
+ .attr("width", labelW)
523
+ .attr("height", labelH)
524
+ .attr("rx", 6)
525
+ .attr("fill", referenceBlue)
526
+ .attr("opacity", 0.98);
527
+ label.append("path")
528
+ .attr("class", "key-point-label-pointer")
529
+ .attr("d", `M ${pointerX - 6} ${labelH - 1} L ${pointerX + 6} ${labelH - 1} L ${pointerX} ${labelH + 8} Z`)
530
+ .attr("fill", referenceBlue)
531
+ .attr("opacity", 0.98);
532
+ label.append("text")
533
+ .attr("class", "key-point-value chart-text")
534
+ .attr("x", labelW / 2)
535
+ .attr("y", 18)
536
+ .attr("text-anchor", "middle")
537
+ .attr("fill", "#FFFFFF")
538
+ .style("font-family", titleFontFamily)
539
+ .style("font-size", "13px")
540
+ .style("font-weight", "900")
541
+ .text(valueText);
542
+ });
543
+
544
+ return svg.node();
545
+ }
modules/chart_engine/template/d3-js/area_chart/area_chart_04.js ADDED
@@ -0,0 +1,718 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Area Chart",
5
+ "chart_name": "area_chart_04",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["temporal"], ["numerical"]],
8
+ "required_fields_range": [[5, 30], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 400,
15
+ "min_width": 800,
16
+ "background": "dark",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_title": "yes",
20
+ "has_x_axis": "yes",
21
+ "has_y_axis": "no",
22
+ "chart_for": "trend"
23
+ }
24
+ REQUIREMENTS_END
25
+ */
26
+
27
+ function makeChart(containerSelector, data) {
28
+ const jsonData = data || {};
29
+ const chartData = jsonData.data?.data || [];
30
+ const dataColumns = chartUtils.schema.columns(jsonData);
31
+ const colorResolver = chartUtils.color.resolver(jsonData);
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 variables = jsonData.variables || {};
53
+ const typography = jsonData.typography || {};
54
+ const sourceColors = jsonData.colors_dark || jsonData.colors || {};
55
+ const titles = jsonData.titles || {};
56
+ const metadata = jsonData.metadata || {};
57
+
58
+ d3.select(containerSelector).html("");
59
+
60
+ const xColumn = dataColumns[0] || {};
61
+ const yColumn = dataColumns[1] || {};
62
+ const xField = xColumn.name;
63
+ const yField = yColumn.name;
64
+
65
+ const width = Math.max(1200, Number(variables.width) || 1200);
66
+ const height = Math.max(800, Number(variables.height) || Math.round(width * 2 / 3));
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 = "#F5FBF8";
70
+ const mutedText = "#B7C4C8";
71
+ const bgTop = "#031522";
72
+ const bgBottom = "#071E26";
73
+ const primary = sourceColors.other?.primary || "#A5DEBE";
74
+ const primarySoft = "#9DE5B6";
75
+ const primaryMid = "#4FC98D";
76
+ const primaryDark = "#103B35";
77
+ const cyan = "#1FA6B0";
78
+ const idSuffix = String(containerSelector || "area-chart-04")
79
+ .replace(/[^a-zA-Z0-9_-]/g, "")
80
+ .slice(0, 24) || "area04";
81
+
82
+ const svg = d3.select(containerSelector)
83
+ .append("svg")
84
+ .attr("width", "100%")
85
+ .attr("height", height)
86
+ .attr("viewBox", `0 0 ${width} ${height}`)
87
+ .attr("style", "max-width: 100%; height: auto;")
88
+ .attr("xmlns", "http://www.w3.org/2000/svg")
89
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
90
+ .attr("class", "area-chart-04-reference-layout");
91
+
92
+ const addEmptyState = message => {
93
+ svg.append("rect")
94
+ .attr("width", width)
95
+ .attr("height", height)
96
+ .attr("fill", bgTop);
97
+ svg.append("text")
98
+ .attr("x", width / 2)
99
+ .attr("y", height / 2)
100
+ .attr("text-anchor", "middle")
101
+ .attr("fill", textColor)
102
+ .style("font-family", fontFamily)
103
+ .style("font-size", "16px")
104
+ .text(message);
105
+ return svg.node();
106
+ };
107
+
108
+ if (!xField || !yField) {
109
+ return addEmptyState("Missing area chart fields");
110
+ }
111
+
112
+ const readableName = value => String(value ?? "")
113
+ .replace(/_/g, " ")
114
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
115
+ .replace(/\s+/g, " ")
116
+ .trim();
117
+
118
+ const shortText = (value, maxChars = 36) => {
119
+ const clean = readableName(value);
120
+ if (clean.length <= maxChars) return clean;
121
+ return `${clean.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
122
+ };
123
+
124
+ const wrapWords = (value, maxChars, maxLines = 2) => {
125
+ const words = String(value ?? "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
126
+ if (!words.length) return [""];
127
+ const lines = [];
128
+ let current = "";
129
+ words.forEach(word => {
130
+ const candidate = current ? `${current} ${word}` : word;
131
+ if (candidate.length <= maxChars || !current) {
132
+ current = candidate;
133
+ } else {
134
+ lines.push(current);
135
+ current = word;
136
+ }
137
+ });
138
+ if (current) lines.push(current);
139
+ if (lines.length <= maxLines) return lines;
140
+ return lines.slice(0, maxLines - 1).concat(lines.slice(maxLines - 1).join(" "));
141
+ };
142
+
143
+ const parseTimeValue = value => {
144
+ const raw = String(value ?? "").trim();
145
+ if (!raw) return null;
146
+ if (/^-?\d{1,6}$/.test(raw)) return new Date(Number(raw), 0, 1);
147
+ {
148
+ const parsedByHelper = chartUtils.format.parseDate(value);
149
+ if (parsedByHelper instanceof Date && Number.isFinite(parsedByHelper.getTime())) return parsedByHelper;
150
+ }
151
+ const parsed = Date.parse(raw);
152
+ if (Number.isFinite(parsed)) return new Date(parsed);
153
+ return null;
154
+ };
155
+
156
+ const unitRaw = String(yColumn.unit || "").trim();
157
+ const unit = unitRaw.toLowerCase() === "none" ? "" : unitRaw;
158
+ const valueTitle = readableName(yColumn.label || yField || yColumn.description);
159
+ const axisTitle = shortText(valueTitle, 38).toUpperCase();
160
+ const rawTitle = String(titles.main_title || metadata.title || `${valueTitle} on the Rise`).trim();
161
+ const titleMatch = rawTitle.match(/\bon\s+the\s+rise\b/i);
162
+ const titleKicker = titleMatch
163
+ ? rawTitle.slice(0, titleMatch.index).trim().toUpperCase()
164
+ : rawTitle.toUpperCase();
165
+ const titleEmphasis = titleMatch
166
+ ? rawTitle.slice(titleMatch.index).trim()
167
+ : "on the Rise";
168
+ const subtitleText = String(
169
+ titles.sub_title
170
+ || metadata.description
171
+ || `${valueTitle} over time.`
172
+ ).trim();
173
+
174
+ const formatNumber = value => {
175
+ const numberValue = Number(value);
176
+ if (!Number.isFinite(numberValue)) return String(value ?? "");
177
+ const abs = Math.abs(numberValue);
178
+ const spec = abs >= 100000 ? ",.0f" : abs >= 1000 ? ",.1f" : abs >= 100 ? ",.1f" : abs >= 10 ? ",.1f" : abs >= 1 ? ",.2f" : ",.3f";
179
+ return d3.format(spec)(numberValue)
180
+ .replace(/\.0$/, "")
181
+ .replace(/(\.\d*?)0+$/, "$1")
182
+ .replace(/\.$/, "");
183
+ };
184
+
185
+ const formatLocalValue = value => {
186
+ const formatted = formatNumber(value);
187
+ if (!unit) return formatted;
188
+ if (unit === "%") return `${formatted}%`;
189
+ if (unit === "$" || unit.toLowerCase() === "usd") return `$${formatted}`;
190
+ if (unit === "£") return `£${formatted}`;
191
+ if (unit.toLowerCase() === "£k") return `£${formatted}k`;
192
+ if (unit.startsWith("$")) return `$${formatted}${unit.slice(1)}`;
193
+ return `${formatted} ${unit}`;
194
+ };
195
+
196
+ const formatCompactValue = value => {
197
+ const numberValue = Number(value);
198
+ if (!Number.isFinite(numberValue)) return String(value ?? "");
199
+ if (unit === "%") return `${formatNumber(numberValue)}%`;
200
+ if (unit === "£" || unit.toLowerCase() === "£k") {
201
+ return unit.toLowerCase() === "£k" ? `£${formatNumber(numberValue)}k` : `£${formatNumber(numberValue)}`;
202
+ }
203
+ const abs = Math.abs(numberValue);
204
+ const suffix = unit && unit !== "$" && unit.toLowerCase() !== "usd" ? ` ${unit}` : "";
205
+ const prefix = unit === "$" || unit.toLowerCase() === "usd" || unit.startsWith("$") ? "$" : "";
206
+ if (abs >= 1000000) return `${prefix}${d3.format(".2f")(numberValue / 1000000).replace(/\.00$/, "")}M${suffix}`;
207
+ if (abs >= 1000) return `${prefix}${d3.format(".2f")(numberValue / 1000).replace(/\.00$/, "")}K${suffix}`;
208
+ return `${prefix}${formatNumber(numberValue)}${suffix}`;
209
+ };
210
+ const formatPillValue = value => {
211
+ const numberValue = Number(value);
212
+ if (!Number.isFinite(numberValue)) return String(value ?? "");
213
+ const prefix = unit === "$" || unit.toLowerCase() === "usd" || unit.startsWith("$") ? "$" : "";
214
+ if (Math.abs(numberValue) >= 1000) return `${prefix}${d3.format(".2f")(numberValue / 1000)}K`;
215
+ return `${prefix}${formatNumber(numberValue)}`;
216
+ };
217
+
218
+ const formatDateLabel = date => {
219
+ if (!(date instanceof Date) || !Number.isFinite(date.getTime())) return "";
220
+ return String(date.getFullYear());
221
+ };
222
+
223
+ const rows = chartData
224
+ .map((row, sourceIndex) => {
225
+ const date = parseTimeValue(row[xField]);
226
+ const value = Number(row[yField]);
227
+ return {
228
+ rawX: String(row[xField] ?? ""),
229
+ date,
230
+ value,
231
+ sourceIndex
232
+ };
233
+ })
234
+ .filter(row => row.date && Number.isFinite(row.date.getTime()) && Number.isFinite(row.value))
235
+ .sort((a, b) => a.date - b.date || a.sourceIndex - b.sourceIndex);
236
+
237
+ if (rows.length < 2) {
238
+ return addEmptyState("Area chart needs at least two valid points");
239
+ }
240
+
241
+ const minObserved = d3.min(rows, d => d.value) ?? 0;
242
+ const maxObserved = d3.max(rows, d => d.value) ?? 1;
243
+ const valueRange = Math.max(maxObserved - minObserved, Math.abs(maxObserved) * 0.08, 1);
244
+ const yTop = maxObserved + valueRange * 0.18;
245
+ const yBottom = minObserved >= 0 ? Math.max(0, minObserved - valueRange * 0.34) : minObserved - valueRange * 0.18;
246
+
247
+ const firstPoint = rows[0];
248
+ const lastPoint = rows[rows.length - 1];
249
+ const peakPoint = rows.reduce((max, row) => row.value > max.value ? row : max, rows[0]);
250
+ const lowPoint = rows.reduce((min, row) => row.value < min.value ? row : min, rows[0]);
251
+ const delta = lastPoint.value - firstPoint.value;
252
+ const trendWord = delta < 0 ? "lower" : delta > 0 ? "higher" : "steady";
253
+ const growthRatio = firstPoint.value !== 0 ? lastPoint.value / firstPoint.value : null;
254
+ const growthText = growthRatio && Number.isFinite(growthRatio)
255
+ ? `${d3.format(".1f")(Math.abs(growthRatio))}x`
256
+ : formatCompactValue(Math.abs(delta));
257
+ const firstYear = formatDateLabel(firstPoint.date);
258
+ const lastYear = formatDateLabel(lastPoint.date);
259
+
260
+ const padX = Math.max(42, width * 0.035);
261
+ const padY = Math.max(46, height * 0.055);
262
+ const plotLeft = padX;
263
+ const plotRight = width - Math.max(58, width * 0.056);
264
+ const plotTop = Math.max(260, height * 0.33);
265
+ const plotBottom = height - Math.max(96, height * 0.145);
266
+ const plotW = Math.max(260, plotRight - plotLeft);
267
+ const plotH = Math.max(170, plotBottom - plotTop);
268
+
269
+ const xScale = d3.scaleTime()
270
+ .domain(d3.extent(rows, d => d.date))
271
+ .range([plotLeft, plotRight]);
272
+ const yScale = d3.scaleLinear()
273
+ .domain([yBottom, yTop])
274
+ .nice(5)
275
+ .range([plotBottom, plotTop]);
276
+
277
+ const defs = svg.append("defs");
278
+ const bgGradientId = `area-chart-04-bg-${idSuffix}`;
279
+ const bgGradient = defs.append("linearGradient")
280
+ .attr("id", bgGradientId)
281
+ .attr("x1", "0%")
282
+ .attr("y1", "0%")
283
+ .attr("x2", "100%")
284
+ .attr("y2", "100%");
285
+ bgGradient.append("stop").attr("offset", "0%").attr("stop-color", bgTop);
286
+ bgGradient.append("stop").attr("offset", "58%").attr("stop-color", bgBottom);
287
+ bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#010A12");
288
+
289
+ const glowId = `area-chart-04-glow-${idSuffix}`;
290
+ const glow = defs.append("filter")
291
+ .attr("id", glowId)
292
+ .attr("x", "-20%")
293
+ .attr("y", "-20%")
294
+ .attr("width", "140%")
295
+ .attr("height", "140%");
296
+ glow.append("feGaussianBlur")
297
+ .attr("stdDeviation", 4)
298
+ .attr("result", "blur");
299
+ glow.append("feMerge")
300
+ .selectAll("feMergeNode")
301
+ .data(["blur", "SourceGraphic"])
302
+ .enter()
303
+ .append("feMergeNode")
304
+ .attr("in", d => d);
305
+
306
+ const areaGradientId = `area-chart-04-fill-${idSuffix}`;
307
+ const areaGradient = defs.append("linearGradient")
308
+ .attr("id", areaGradientId)
309
+ .attr("gradientUnits", "userSpaceOnUse")
310
+ .attr("x1", 0)
311
+ .attr("x2", 0)
312
+ .attr("y1", plotTop)
313
+ .attr("y2", plotBottom);
314
+ areaGradient.append("stop").attr("offset", "0%").attr("stop-color", primarySoft).attr("stop-opacity", 0.88);
315
+ areaGradient.append("stop").attr("offset", "55%").attr("stop-color", primaryMid).attr("stop-opacity", 0.46);
316
+ areaGradient.append("stop").attr("offset", "100%").attr("stop-color", primaryDark).attr("stop-opacity", 0.10);
317
+
318
+ const clipId = `area-chart-04-clip-${idSuffix}`;
319
+ defs.append("clipPath")
320
+ .attr("id", clipId)
321
+ .append("rect")
322
+ .attr("x", plotLeft)
323
+ .attr("y", plotTop - 24)
324
+ .attr("width", plotW)
325
+ .attr("height", plotH + 24);
326
+
327
+ const area = d3.area()
328
+ .x(d => xScale(d.date))
329
+ .y0(plotBottom)
330
+ .y1(d => yScale(d.value))
331
+ .curve(d3.curveMonotoneX);
332
+ const line = d3.line()
333
+ .x(d => xScale(d.date))
334
+ .y(d => yScale(d.value))
335
+ .curve(d3.curveMonotoneX);
336
+
337
+ const root = svg.append("g").attr("class", "area-chart-04-reference-panel");
338
+ root.append("rect")
339
+ .attr("class", "chart-style-background")
340
+ .attr("width", width)
341
+ .attr("height", height)
342
+ .attr("fill", `url(#${bgGradientId})`);
343
+
344
+ const titleSlot = root.append("g")
345
+ .attr("class", "header-title-art-slot")
346
+ .attr("data-reserved-slot", "header_title_art_slot")
347
+ .attr("transform", `translate(${padX}, ${padY + height * 0.02})`);
348
+ const titleKickerLines = wrapWords(titleKicker, 40, 2);
349
+ const titleKickerFont = Math.max(25, width * 0.026);
350
+ const titleKickerLineH = titleKickerFont * 1.18;
351
+ titleKickerLines.forEach((lineText, index) => {
352
+ titleSlot.append("text")
353
+ .attr("class", "reference-title-kicker chart-text")
354
+ .attr("x", 0)
355
+ .attr("y", index * titleKickerLineH)
356
+ .attr("fill", textColor)
357
+ .style("font-family", "Arial, Helvetica, sans-serif")
358
+ .style("font-size", `${titleKickerFont}px`)
359
+ .style("font-weight", "900")
360
+ .style("letter-spacing", "0px")
361
+ .text(lineText);
362
+ });
363
+ const titleEmphasisY = Math.max(72, height * 0.09) + (titleKickerLines.length - 1) * titleKickerLineH;
364
+ titleSlot.append("text")
365
+ .attr("class", "reference-title-emphasis chart-text")
366
+ .attr("x", 0)
367
+ .attr("y", titleEmphasisY)
368
+ .attr("fill", primarySoft)
369
+ .style("font-family", "Georgia, 'Times New Roman', serif")
370
+ .style("font-size", `${Math.max(58, width * 0.054)}px`)
371
+ .style("font-weight", "900")
372
+ .style("letter-spacing", "0px")
373
+ .text(titleEmphasis);
374
+ const subtitleLines = wrapWords(subtitleText, 44, 2);
375
+ const subtitleY = titleEmphasisY + Math.max(54, height * 0.068);
376
+ subtitleLines.forEach((lineText, index) => {
377
+ titleSlot.append("text")
378
+ .attr("class", "reference-subtitle chart-text")
379
+ .attr("x", 0)
380
+ .attr("y", subtitleY + index * Math.max(25, height * 0.029))
381
+ .attr("fill", textColor)
382
+ .style("font-family", fontFamily)
383
+ .style("font-size", `${Math.max(20, width * 0.018)}px`)
384
+ .style("font-weight", "500")
385
+ .style("letter-spacing", "0px")
386
+ .text(lineText);
387
+ });
388
+ titleSlot.append("line")
389
+ .attr("class", "reference-title-accent")
390
+ .attr("x1", 0)
391
+ .attr("x2", Math.max(50, width * 0.042))
392
+ .attr("y1", subtitleY + subtitleLines.length * Math.max(25, height * 0.029) + 22)
393
+ .attr("y2", subtitleY + subtitleLines.length * Math.max(25, height * 0.029) + 22)
394
+ .attr("stroke", primaryMid)
395
+ .attr("stroke-width", Math.max(4, width * 0.004))
396
+ .attr("stroke-linecap", "round");
397
+
398
+ root.append("path")
399
+ .attr("class", "stage-light decorative-shape")
400
+ .attr("d", `M ${width * 0.70} ${padY} L ${width * 0.84} ${padY} L ${width * 0.78} ${plotBottom + 52} Z`)
401
+ .attr("fill", cyan)
402
+ .attr("opacity", 0.10);
403
+ root.append("path")
404
+ .attr("class", "stage-light decorative-shape")
405
+ .attr("d", `M ${width * 0.86} ${padY} L ${width * 0.98} ${padY} L ${width * 0.82} ${plotBottom + 36} Z`)
406
+ .attr("fill", cyan)
407
+ .attr("opacity", 0.08);
408
+
409
+ const insightX = padX;
410
+ const insightY = Math.max(plotTop + 18, height * 0.39);
411
+ const insightW = Math.min(300, Math.max(248, width * 0.23));
412
+ const insightH = Math.max(142, height * 0.18);
413
+ const insight = root.append("g")
414
+ .attr("class", "insight-callout reference-insight-card")
415
+ .attr("transform", `translate(${insightX}, ${insightY})`);
416
+ insight.append("rect")
417
+ .attr("class", "insight-callout-card")
418
+ .attr("width", insightW)
419
+ .attr("height", insightH)
420
+ .attr("rx", 9)
421
+ .attr("fill", "#071C25")
422
+ .attr("stroke", "#5E9A78")
423
+ .attr("stroke-width", 1)
424
+ .attr("opacity", 0.72);
425
+ const calloutIcon = insight.append("g")
426
+ .attr("class", "callout-icon-slot decorative-icon-slot")
427
+ .attr("data-reserved-slot", "growth_icon_slot")
428
+ .attr("transform", `translate(${Math.max(63, insightW * 0.25)}, ${insightH * 0.47})`);
429
+ calloutIcon.append("circle")
430
+ .attr("r", Math.min(48, insightH * 0.28))
431
+ .attr("fill", "none")
432
+ .attr("stroke", "#8CCB8F")
433
+ .attr("stroke-width", 2.2)
434
+ .attr("opacity", 0.9);
435
+ const growthRounded = growthRatio && Number.isFinite(growthRatio)
436
+ ? `${Math.max(1, Math.round(Math.abs(growthRatio)))}x`
437
+ : growthText;
438
+ insight.append("text")
439
+ .attr("class", "callout-nearly chart-text")
440
+ .attr("x", insightW * 0.52)
441
+ .attr("y", insightH * 0.30)
442
+ .attr("fill", textColor)
443
+ .style("font-family", fontFamily)
444
+ .style("font-size", `${Math.max(18, width * 0.017)}px`)
445
+ .style("font-weight", "500")
446
+ .text(delta === 0 ? "Nearly" : "Nearly");
447
+ insight.append("text")
448
+ .attr("class", "callout-growth-value chart-text")
449
+ .attr("x", insightW * 0.52)
450
+ .attr("y", insightH * 0.63)
451
+ .attr("fill", primarySoft)
452
+ .style("font-family", "Georgia, 'Times New Roman', serif")
453
+ .style("font-size", `${Math.max(48, width * 0.049)}px`)
454
+ .style("font-weight", "900")
455
+ .style("letter-spacing", "0px")
456
+ .text(growthRounded);
457
+ ["growth in", `${Math.max(1, Number(lastYear) - Number(firstYear))} years`].forEach((lineText, lineIndex) => {
458
+ insight.append("text")
459
+ .attr("class", "callout-subtitle chart-text")
460
+ .attr("x", insightW * 0.52)
461
+ .attr("y", insightH * 0.76 + lineIndex * Math.max(22, height * 0.027))
462
+ .attr("fill", textColor)
463
+ .style("font-family", fontFamily)
464
+ .style("font-size", `${Math.max(18, width * 0.017)}px`)
465
+ .style("font-weight", "500")
466
+ .text(lineText);
467
+ });
468
+
469
+ const decorW = Math.min(width * 0.36, plotW * 0.42);
470
+ const decorH = Math.min(height * 0.30, plotH * 0.52);
471
+ const decor = root.append("g")
472
+ .attr("class", "venue-illustration-slot right-decorative-image-slot decorative-image-slot")
473
+ .attr("data-reserved-slot", "venue_illustration_slot")
474
+ .attr("data-asset-owner", "image2")
475
+ .attr("data-anchor", "plot-lower-right")
476
+ .attr("transform", `translate(${plotRight - decorW - width * 0.02}, ${plotBottom - decorH - height * 0.035})`)
477
+ .attr("opacity", 0.68);
478
+ decor.append("rect")
479
+ .attr("class", "venue-slot-bbox")
480
+ .attr("width", decorW)
481
+ .attr("height", decorH)
482
+ .attr("rx", 12)
483
+ .attr("fill", "#0B2C2A")
484
+ .attr("stroke", primarySoft)
485
+ .attr("stroke-width", 1.2)
486
+ .attr("stroke-opacity", 0.25)
487
+ .attr("fill-opacity", 0.16);
488
+ decor.append("path")
489
+ .attr("class", "venue-slot-arch semantic-placeholder")
490
+ .attr("d", `M ${decorW * 0.08} ${decorH * 0.66} C ${decorW * 0.26} ${decorH * 0.18}, ${decorW * 0.74} ${decorH * 0.18}, ${decorW * 0.92} ${decorH * 0.66}`)
491
+ .attr("fill", "none")
492
+ .attr("stroke", primarySoft)
493
+ .attr("stroke-width", 2.2)
494
+ .attr("stroke-opacity", 0.42)
495
+ .attr("stroke-linecap", "round");
496
+ decor.append("line")
497
+ .attr("class", "venue-slot-stage semantic-placeholder")
498
+ .attr("x1", decorW * 0.12)
499
+ .attr("x2", decorW * 0.88)
500
+ .attr("y1", decorH * 0.72)
501
+ .attr("y2", decorH * 0.72)
502
+ .attr("stroke", primarySoft)
503
+ .attr("stroke-width", 2)
504
+ .attr("stroke-opacity", 0.35)
505
+ .attr("stroke-linecap", "round");
506
+ d3.range(12).forEach(index => {
507
+ decor.append("circle")
508
+ .attr("class", "venue-slot-audience-dot semantic-placeholder")
509
+ .attr("cx", decorW * (0.14 + index * 0.065))
510
+ .attr("cy", decorH * (0.83 + (index % 2) * 0.045))
511
+ .attr("r", Math.max(3, width * 0.004))
512
+ .attr("fill", primarySoft)
513
+ .attr("fill-opacity", 0.18);
514
+ });
515
+
516
+ const plotLayer = root.append("g")
517
+ .attr("class", "area-plot-layer")
518
+ .attr("clip-path", `url(#${clipId})`);
519
+ plotLayer.append("path")
520
+ .datum(rows)
521
+ .attr("class", "area-fill data-area positive-area-fill")
522
+ .attr("d", area)
523
+ .attr("fill", `url(#${areaGradientId})`)
524
+ .attr("data-point-count", rows.length)
525
+ .attr("data-first-x", firstPoint.rawX)
526
+ .attr("data-first-value", firstPoint.value)
527
+ .attr("data-last-x", lastPoint.rawX)
528
+ .attr("data-last-value", lastPoint.value)
529
+ .attr("data-min-x", lowPoint.rawX)
530
+ .attr("data-min-value", lowPoint.value)
531
+ .attr("data-max-x", peakPoint.rawX)
532
+ .attr("data-max-value", peakPoint.value);
533
+ plotLayer.append("path")
534
+ .datum(rows)
535
+ .attr("class", "area-line-underlay")
536
+ .attr("d", line)
537
+ .attr("fill", "none")
538
+ .attr("stroke", "#E8FFF0")
539
+ .attr("stroke-width", 6)
540
+ .attr("stroke-linecap", "round")
541
+ .attr("stroke-linejoin", "round")
542
+ .attr("opacity", 0.88)
543
+ .attr("filter", `url(#${glowId})`);
544
+ plotLayer.append("path")
545
+ .datum(rows)
546
+ .attr("class", "area-line data-line")
547
+ .attr("d", line)
548
+ .attr("fill", "none")
549
+ .attr("stroke", primarySoft)
550
+ .attr("stroke-width", 3.3)
551
+ .attr("stroke-linecap", "round")
552
+ .attr("stroke-linejoin", "round");
553
+
554
+ root.append("line")
555
+ .attr("class", "x-axis-line")
556
+ .attr("x1", plotLeft)
557
+ .attr("x2", plotRight)
558
+ .attr("y1", plotBottom)
559
+ .attr("y2", plotBottom)
560
+ .attr("stroke", "#9AA8AD")
561
+ .attr("stroke-width", 1.2)
562
+ .attr("opacity", 0.78);
563
+
564
+ const chooseRowsByPosition = () => {
565
+ if (rows.length <= 5) return rows;
566
+ const indexes = [0, Math.round((rows.length - 1) * 0.25), Math.round((rows.length - 1) * 0.5), Math.round((rows.length - 1) * 0.75), rows.length - 1];
567
+ return indexes
568
+ .map(index => rows[index])
569
+ .filter((row, index, array) => array.findIndex(item => item.sourceIndex === row.sourceIndex) === index)
570
+ .sort((a, b) => a.date - b.date);
571
+ };
572
+ const labeledRows = chooseRowsByPosition();
573
+ const labeledIndexes = new Set(labeledRows.map(row => row.sourceIndex));
574
+
575
+ const guideLayer = root.append("g").attr("class", "point-guide-layer");
576
+ labeledRows.forEach(row => {
577
+ const x = xScale(row.date);
578
+ const y = yScale(row.value);
579
+ guideLayer.append("line")
580
+ .attr("class", "point-guide")
581
+ .attr("x1", x)
582
+ .attr("x2", x)
583
+ .attr("y1", y + 8)
584
+ .attr("y2", plotBottom)
585
+ .attr("stroke", "#A9DDBB")
586
+ .attr("stroke-width", 1.1)
587
+ .attr("stroke-dasharray", "4 5")
588
+ .attr("opacity", 0.54)
589
+ .attr("data-source-index", row.sourceIndex)
590
+ .attr("data-x", row.rawX)
591
+ .attr("data-value", row.value);
592
+ guideLayer.append("circle")
593
+ .attr("class", "x-axis-dot")
594
+ .attr("cx", x)
595
+ .attr("cy", plotBottom)
596
+ .attr("r", 2.8)
597
+ .attr("fill", mutedText)
598
+ .attr("opacity", 0.82);
599
+ });
600
+
601
+ const pointLayer = root.append("g").attr("class", "area-data-points");
602
+ rows.forEach(row => {
603
+ const x = xScale(row.date);
604
+ const y = yScale(row.value);
605
+ const point = pointLayer.append("g")
606
+ .attr("class", labeledIndexes.has(row.sourceIndex) ? "area-point labeled-area-point" : "area-point")
607
+ .attr("data-source-index", row.sourceIndex)
608
+ .attr("data-x", row.rawX)
609
+ .attr("data-value", row.value)
610
+ .attr("transform", `translate(${x}, ${y})`);
611
+ point.append("circle")
612
+ .attr("class", "area-point-outer")
613
+ .attr("r", labeledIndexes.has(row.sourceIndex) ? 6.3 : 3.1)
614
+ .attr("fill", bgTop)
615
+ .attr("stroke", "#F3FFF7")
616
+ .attr("stroke-width", labeledIndexes.has(row.sourceIndex) ? 3 : 1.5);
617
+ point.append("circle")
618
+ .attr("class", "area-point-inner")
619
+ .attr("r", labeledIndexes.has(row.sourceIndex) ? 2.5 : 1.4)
620
+ .attr("fill", primarySoft);
621
+ });
622
+
623
+ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
624
+ const boxFrom = (x, y, w, h) => ({ left: x, right: x + w, top: y, bottom: y + h, width: w, height: h });
625
+ const boxesOverlap = (a, b, pad = 0) => !(a.right + pad <= b.left || b.right + pad <= a.left || a.bottom + pad <= b.top || b.bottom + pad <= a.top);
626
+ const placedLabelBoxes = [];
627
+ const labelLayer = root.append("g").attr("class", "key-point-labels");
628
+ labeledRows.forEach(row => {
629
+ const x = xScale(row.date);
630
+ const y = yScale(row.value);
631
+ const valueText = formatPillValue(row.value);
632
+ const labelW = Math.max(86, valueText.length * Math.max(8.2, width * 0.0068) + 28);
633
+ const labelH = Math.max(38, height * 0.046);
634
+ const xOptions = [
635
+ x - labelW / 2,
636
+ x - labelW * 0.18,
637
+ x - labelW * 0.82,
638
+ x + 8,
639
+ x - labelW - 8,
640
+ x + 26,
641
+ x - labelW - 26
642
+ ].map(value => clamp(value, padX + 6, width - padX - labelW - 6));
643
+ const yOptions = [
644
+ y - labelH - Math.max(28, height * 0.038),
645
+ y - labelH - Math.max(42, height * 0.056),
646
+ y - labelH - Math.max(58, height * 0.074),
647
+ plotTop + 4
648
+ ].map(value => clamp(value, padY + 8, Math.max(padY + 8, y - labelH - 8)));
649
+ let best = { tx: xOptions[0], ty: yOptions[0], score: Number.POSITIVE_INFINITY };
650
+ xOptions.forEach(candidateX => {
651
+ yOptions.forEach(candidateY => {
652
+ const box = boxFrom(candidateX, candidateY, labelW, labelH);
653
+ const overlapPenalty = placedLabelBoxes.reduce((sum, placed) => sum + (boxesOverlap(box, placed, 5) ? 50000 : 0), 0);
654
+ const pointDx = Math.abs(candidateX + labelW / 2 - x);
655
+ const pointDy = Math.abs(candidateY + labelH / 2 - y);
656
+ const abovePenalty = candidateY + labelH > y - 6 ? 10000 : 0;
657
+ const topPenalty = candidateY < plotTop - 90 ? 14 : 0;
658
+ const score = overlapPenalty + abovePenalty + pointDx * 2.4 + pointDy * 0.18 + topPenalty;
659
+ if (score < best.score) best = { tx: candidateX, ty: candidateY, score };
660
+ });
661
+ });
662
+ const tx = best.tx;
663
+ const ty = best.ty;
664
+ placedLabelBoxes.push(boxFrom(tx, ty, labelW, labelH));
665
+ const pointerX = clamp(x - tx, 10, labelW - 10);
666
+ const label = labelLayer.append("g")
667
+ .attr("class", "key-point-label")
668
+ .attr("data-source-index", row.sourceIndex)
669
+ .attr("data-x", row.rawX)
670
+ .attr("data-value", row.value)
671
+ .attr("transform", `translate(${tx}, ${ty})`);
672
+ label.append("rect")
673
+ .attr("class", "key-point-label-bg")
674
+ .attr("width", labelW)
675
+ .attr("height", labelH)
676
+ .attr("rx", 7)
677
+ .attr("fill", primarySoft)
678
+ .attr("opacity", 0.98);
679
+ label.append("path")
680
+ .attr("class", "key-point-label-pointer")
681
+ .attr("d", `M ${pointerX - 8} ${labelH - 1} L ${pointerX + 8} ${labelH - 1} L ${pointerX} ${labelH + 14} Z`)
682
+ .attr("fill", primarySoft)
683
+ .attr("opacity", 0.98);
684
+ label.append("text")
685
+ .attr("class", "key-point-value chart-text")
686
+ .attr("x", labelW / 2)
687
+ .attr("y", labelH * 0.64)
688
+ .attr("text-anchor", "middle")
689
+ .attr("fill", "#06131A")
690
+ .style("font-family", titleFontFamily)
691
+ .style("font-size", `${Math.max(17, width * 0.015)}px`)
692
+ .style("font-weight", "900")
693
+ .text(valueText);
694
+ });
695
+
696
+ const tickKey = date => date instanceof Date ? date.getTime() : new Date(date).getTime();
697
+ const xTickDates = Array.from(
698
+ new Map(
699
+ [firstPoint.date, ...xScale.ticks(4), lastPoint.date]
700
+ .filter(date => date >= firstPoint.date && date <= lastPoint.date)
701
+ .map(date => [tickKey(date), date])
702
+ ).values()
703
+ ).sort((a, b) => a - b);
704
+ xTickDates.forEach(tick => {
705
+ const x = xScale(tick);
706
+ root.append("text")
707
+ .attr("class", "x-axis-label chart-text")
708
+ .attr("x", x)
709
+ .attr("y", plotBottom + 24)
710
+ .attr("text-anchor", "middle")
711
+ .attr("fill", "#D6DEE0")
712
+ .style("font-family", fontFamily)
713
+ .style("font-size", "13px")
714
+ .text(formatDateLabel(tick));
715
+ });
716
+
717
+ return svg.node();
718
+ }
modules/chart_engine/template/d3-js/area_chart/layered_area_chart_03.js ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Layered Area Chart",
5
+ "chart_name": "layered_area_chart_03",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 50], [0, "inf"], [2, 6]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "dark",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ // 提取数据
27
+ const jsonData = data;
28
+ let chartData = jsonData.data.data;
29
+ const variables = jsonData.variables;
30
+ const typography = jsonData.typography;
31
+ const colors = jsonData.colors_dark || {};
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 xField = chartUtils.schema.columnField(dataColumns, 0);
41
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
42
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
43
+
44
+ chartData = temporalFilter(chartData, xField);
45
+ if (chartData.length === 0) {
46
+ return;
47
+ }
48
+
49
+ // 获取唯一的组值并按最后y值排序
50
+ const groups = [...new Set(chartData.map(d => d[groupField]))]
51
+ .sort((a, b) => {
52
+ const aLast = chartData.filter(d => d[groupField] === a).slice(-1)[0][yField];
53
+ const bLast = chartData.filter(d => d[groupField] === b).slice(-1)[0][yField];
54
+ return aLast - bLast;
55
+ });
56
+
57
+ // 设置尺寸和边距
58
+ const width = variables.width;
59
+ const height = variables.height;
60
+ const margin = { top: 20, right: 60, bottom: 40, left: 60 };
61
+
62
+ // 创建SVG
63
+ const svg = d3.select(containerSelector)
64
+ .append("svg")
65
+ .attr("width", "100%")
66
+ .attr("height", height)
67
+ .attr("viewBox", `0 0 ${width} ${height}`)
68
+ .attr("style", "max-width: 100%; height: auto;")
69
+ .attr("xmlns", "http://www.w3.org/2000/svg")
70
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
71
+
72
+ // 创建图表区域
73
+ const chartWidth = width - margin.left - margin.right;
74
+ const chartHeight = height - margin.top - margin.bottom;
75
+
76
+ const g = svg.append("g")
77
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
78
+
79
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
80
+
81
+ // 创建y轴比例尺 (百分比)
82
+ const yScale = d3.scaleLinear()
83
+ .domain([0, d3.max(chartData, d => +d[yField]) * 1.5])
84
+ .range([chartHeight, 0]);
85
+
86
+ // 添加X轴线
87
+ g.append("line")
88
+ .attr("x1", -40)
89
+ .attr("y1", chartHeight)
90
+ .attr("x2", chartWidth)
91
+ .attr("y2", chartHeight)
92
+ .attr("stroke", "#9badd3")
93
+ .attr("opacity", 0.6)
94
+ .attr("stroke-width", 1)
95
+
96
+ // 添加水平网格线
97
+ const yTicks = d3.ticks(0, d3.max(chartData, d => +d[yField]) * 1.3, 5); // 根据实际数据生成刻度
98
+
99
+ // 添加y轴刻度和标签
100
+ yTicks.forEach(tick => {
101
+ if (tick !== 0) { // 跳过0刻度
102
+ g.append("text")
103
+ .attr("x", -10)
104
+ .attr("y", yScale(tick) + 15)
105
+ .attr("text-anchor", "end")
106
+ .attr("dominant-baseline", "middle")
107
+ .style("font-family", "Arial")
108
+ .style("font-size", "16px")
109
+ .style("fill", "#87aac0")
110
+ .text(tick);
111
+ }
112
+ });
113
+
114
+ // 添加x轴刻度和标签
115
+ xTicks.forEach((tick, i) => {
116
+ if (i === xTicks.length - 1) {
117
+ return;
118
+ }
119
+ // 添加刻度标签
120
+ g.append("text")
121
+ .attr("x", xScale(tick))
122
+ .attr("y", chartHeight + 20)
123
+ .attr("text-anchor", "middle")
124
+ .style("font-family", "Arial")
125
+ .style("font-size", "16px")
126
+ .style("font-weight", "bold")
127
+ .style("fill", "#87aac0")
128
+ .text(xFormat(tick));
129
+ });
130
+
131
+ // 创建面积生成器
132
+ const area = d3.area()
133
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
134
+ .y0(chartHeight)
135
+ .y1(d => yScale(+d[yField]))
136
+ .curve(d3.curveLinear);
137
+
138
+ // 创建渐变定义
139
+ const defs = svg.append("defs");
140
+
141
+ // 为每个组创建渐变
142
+ groups.forEach(group => {
143
+ const color = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
144
+ const gradientId = `gradient-${group.replace(/\s+/g, '-')}`;
145
+
146
+ const gradient = defs.append("linearGradient")
147
+ .attr("id", gradientId)
148
+ .attr("x1", "0%")
149
+ .attr("y1", "0%")
150
+ .attr("x2", "0%")
151
+ .attr("y2", "100%");
152
+
153
+ // 渐变起始颜色(原始颜色)
154
+ gradient.append("stop")
155
+ .attr("offset", "0%")
156
+ .attr("stop-color", d3.color(color))
157
+ .attr("stop-opacity", 1);
158
+
159
+ // 渐变结束颜色(偏白色)
160
+ gradient.append("stop")
161
+ .attr("offset", "100%")
162
+ .attr("stop-color", chartUtils.color.variant(color, { mode: "brighter", amount: 1.0 })) // 使颜色更亮(偏白)
163
+ .attr("stop-opacity", 1);
164
+ });
165
+
166
+ // 创建阴影滤镜 - 修复截断问题
167
+ const filters = defs.append("filter")
168
+ .attr("id", "drop-shadow")
169
+ .attr("x", "-50%") // 扩展滤镜区域左侧
170
+ .attr("y", "-100%") // 扩展滤镜区域上方
171
+ .attr("width", "200%") // 扩展滤镜区域宽度
172
+ .attr("height", "200%"); // 扩展滤镜区域高度
173
+
174
+ // 调整阴影颜色和透明度
175
+ filters.append("feComponentTransfer")
176
+ .append("feFuncA")
177
+ .attr("type", "linear")
178
+ .attr("slope", 0.2); // 降低阴影不透明度,使其更加柔和
179
+
180
+ // 添加高斯模糊
181
+ filters.append("feGaussianBlur")
182
+ .attr("in", "SourceAlpha")
183
+ .attr("stdDeviation", 25) // 调整模糊程度,使阴影更加柔和
184
+ .attr("result", "blur");
185
+
186
+ // 添加偏移
187
+ filters.append("feOffset")
188
+ .attr("in", "blur")
189
+ .attr("dx", 0)
190
+ .attr("dy", 15)
191
+ .attr("result", "offsetBlur");
192
+
193
+ // 合并原始图形和阴影
194
+ const feMerge = filters.append("feMerge");
195
+ feMerge.append("feMergeNode")
196
+ .attr("in", "offsetBlur");
197
+ feMerge.append("feMergeNode")
198
+ .attr("in", "SourceGraphic");
199
+
200
+ // 绘制面积图
201
+ // 按照组的顺序反向绘制,确保第一个组在最上面
202
+ [...groups].reverse().forEach(group => {
203
+ const groupData = chartData.filter(d => d[groupField] === group);
204
+ const gradientId = `gradient-${group.replace(/\s+/g, '-')}`;
205
+
206
+ // 绘制面积
207
+ g.append("path")
208
+ .datum(groupData)
209
+ .attr("fill", `url(#${gradientId})`) // 使用渐变填充
210
+ .attr("opacity", 0.8)
211
+ .attr("d", area)
212
+ .style("filter", "url(#drop-shadow)"); // 添加阴影效果
213
+ });
214
+
215
+ // 添加网格背景
216
+ g.append("rect")
217
+ .attr("class", "background")
218
+ .attr("x", 0)
219
+ .attr("y", yScale(d3.max(yTicks)))
220
+ .attr("width", chartWidth)
221
+ .attr("height", chartHeight - yScale(d3.max(yTicks)))
222
+ .attr("fill", "#87aac0")
223
+ .attr("opacity", 0.1);
224
+
225
+ // 添加垂直网格线
226
+ xTicks.forEach(tick => {
227
+ g.append("line")
228
+ .attr("x1", xScale(tick))
229
+ .attr("y1", yScale(d3.max(yTicks))) // 从y轴最大刻度开始
230
+ .attr("x2", xScale(tick))
231
+ .attr("y2", chartHeight)
232
+ .attr("stroke", "#87aac0")
233
+ .attr("opacity", 0.2)
234
+ .attr("class", "background")
235
+ .attr("stroke-width", 1)
236
+ });
237
+ // 添加水平网格线
238
+ yTicks.forEach(tick => {
239
+ g.append("line")
240
+ .attr("x1", -40)
241
+ .attr("y1", yScale(tick))
242
+ .attr("x2", chartWidth)
243
+ .attr("y2", yScale(tick))
244
+ .attr("stroke", "#87aac0")
245
+ .attr("opacity", 0.2)
246
+ .attr("stroke-width", 1)
247
+ .attr("class", "background")
248
+ });
249
+
250
+ // 获取最后一个刻度的x值
251
+ const lastTickX = xScale(xTicks[xTicks.length - 1]);
252
+
253
+ // 添加最后一年标注
254
+ g.append("text")
255
+ .attr("x", lastTickX)
256
+ .attr("y", yScale(d3.max(yTicks)) - 10) // 调整文本位置到y轴最大刻度上方
257
+ .attr("text-anchor", "middle")
258
+ .style("font-family", "Arial")
259
+ .style("font-size", "14px")
260
+ .style("fill", "#87aac0")
261
+ .text(xFormat(xTicks[xTicks.length - 1]));
262
+
263
+ // 添加倒置的蓝色小三角
264
+ g.append("path")
265
+ .attr("d", "M" + (lastTickX-5) + "," + yScale(d3.max(yTicks)) + " L" + (lastTickX+5) + "," + yScale(d3.max(yTicks)) + " L" + lastTickX + "," + (yScale(d3.max(yTicks))+5) + "Z")
266
+ .attr("fill", "#87aac0");
267
+
268
+ // 添加垂直虚线指向最后一年
269
+ g.append("line")
270
+ .attr("x1", lastTickX)
271
+ .attr("y1", yScale(d3.max(yTicks))+5)
272
+ .attr("x2", lastTickX)
273
+ .attr("y2", chartHeight)
274
+ .attr("stroke", "#87aac0")
275
+ .attr("stroke-width", 1)
276
+ .attr("stroke-dasharray", "3,3");
277
+
278
+ // 添加最终值标签
279
+ let prevLabelYBase = null; // 跟踪上一个标签的Y位置
280
+ const minLabelDistance = 50; // 最小标签间距
281
+
282
+ [...groups].forEach(group => {
283
+ const groupData = chartData.filter(d => d[groupField] === group);
284
+ const lastPoint = groupData[groupData.length - 1];
285
+ const color = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
286
+
287
+ // 计算标签基础位置
288
+ let labelYBase = yScale(+lastPoint[yField]);
289
+ let labelXBase = xScale(chartUtils.format.parseDate(lastPoint[xField]));
290
+
291
+ // 检查与上一个标签的距离,如果太近则调整位置
292
+ if (prevLabelYBase !== null && Math.abs(prevLabelYBase - labelYBase) < minLabelDistance) {
293
+ labelYBase = labelYBase - minLabelDistance;
294
+ }
295
+
296
+ // 更新上一个标签位置
297
+ prevLabelYBase = labelYBase;
298
+ let textWidth = chartUtils.text.measure(null, group, { fontSize: 10 }).width * 1.3;
299
+ // 创建标签背景
300
+ const labelBg = g.append("rect")
301
+ .attr("x", labelXBase - textWidth / 2)
302
+ .attr("y", labelYBase - 50)
303
+ .attr("rx", 10)
304
+ .attr("ry", 10)
305
+ .attr("width", textWidth)
306
+ .attr("height", 40)
307
+ .attr("fill", color);
308
+
309
+ // 添加倒三角
310
+ g.append("path")
311
+ .attr("d", `M${labelXBase}, ${labelYBase}
312
+ L${labelXBase - 10}, ${labelYBase - 15}
313
+ L${labelXBase + 10}, ${labelYBase - 15} Z`)
314
+ .attr("fill", color);
315
+
316
+ // 添加组名(上部分文本)
317
+ g.append("text")
318
+ .attr("x", labelXBase)
319
+ .attr("y", labelYBase - 40)
320
+ .attr("text-anchor", "middle")
321
+ .attr("dominant-baseline", "middle")
322
+ .style("font-family", "Arial")
323
+ .style("font-size", "10px")
324
+ .style("fill", "#ffffff")
325
+ .style("opacity", 0.5)
326
+ .text(group);
327
+
328
+ // 添加最终值(下部分文本)
329
+ g.append("text")
330
+ .attr("x", labelXBase)
331
+ .attr("y", labelYBase - 22)
332
+ .attr("text-anchor", "middle")
333
+ .attr("dominant-baseline", "middle")
334
+ .style("font-family", "Arial")
335
+ .style("font-size", "22px")
336
+ .style("font-weight", "bold")
337
+ .style("fill", "#ffffff")
338
+ .text(chartUtils.format.integer(lastPoint[yField]).text);
339
+
340
+ });
341
+
342
+ return svg.node();
343
+ }
modules/chart_engine/template/d3-js/area_chart/multiple_area_chart_01.js ADDED
@@ -0,0 +1,547 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Area Chart",
5
+ "chart_name": "multiple_area_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 50], [0, "inf"], [2, 2]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ const jsonData = data;
27
+ const chartData = jsonData.data.data;
28
+ const variables = jsonData.variables;
29
+ const typography = jsonData.typography || {};
30
+ const dataColumns = chartUtils.schema.columns(jsonData);
31
+ const colorResolver = chartUtils.color.resolver(jsonData);
32
+
33
+ d3.select(containerSelector).html("");
34
+
35
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
36
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
37
+ const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
38
+ const yLabel = yColumn.rawLabel || yField;
39
+ const yUnit = yColumn.unit || "";
40
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
41
+ let groups = [...new Set(chartData.map(d => d[groupField]))].slice(0, 2);
42
+
43
+ const width = +variables.width || 600;
44
+ const height = +variables.height || 600;
45
+ const svg = d3.select(containerSelector)
46
+ .append("svg")
47
+ .attr("width", "100%")
48
+ .attr("height", height)
49
+ .attr("viewBox", `0 0 ${width} ${height}`)
50
+ .attr("style", "max-width: 100%; height: auto; background: #f8f0df;")
51
+ .attr("xmlns", "http://www.w3.org/2000/svg")
52
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
53
+
54
+ const defs = svg.append("defs");
55
+ defs.append("linearGradient")
56
+ .attr("id", "multiple-area-paper")
57
+ .attr("x1", "0%").attr("y1", "0%")
58
+ .attr("x2", "100%").attr("y2", "100%")
59
+ .call(grad => {
60
+ grad.append("stop").attr("offset", "0%").attr("stop-color", "#fff8e9");
61
+ grad.append("stop").attr("offset", "54%").attr("stop-color", "#f4ead6");
62
+ grad.append("stop").attr("offset", "100%").attr("stop-color", "#efe0c3");
63
+ });
64
+ defs.append("linearGradient")
65
+ .attr("id", "multiple-area-left-fill")
66
+ .attr("x1", "0%").attr("y1", "0%")
67
+ .attr("x2", "100%").attr("y2", "0%")
68
+ .call(grad => {
69
+ grad.append("stop").attr("offset", "0%").attr("stop-color", "#d84a42").attr("stop-opacity", 0.9);
70
+ grad.append("stop").attr("offset", "100%").attr("stop-color", "#b9151f").attr("stop-opacity", 0.96);
71
+ });
72
+ defs.append("linearGradient")
73
+ .attr("id", "multiple-area-right-fill")
74
+ .attr("x1", "0%").attr("y1", "0%")
75
+ .attr("x2", "100%").attr("y2", "0%")
76
+ .call(grad => {
77
+ grad.append("stop").attr("offset", "0%").attr("stop-color", "#6c934e").attr("stop-opacity", 0.96);
78
+ grad.append("stop").attr("offset", "100%").attr("stop-color", "#9eb776").attr("stop-opacity", 0.9);
79
+ });
80
+ defs.append("filter")
81
+ .attr("id", "multiple-area-soft-shadow")
82
+ .attr("x", "-20%").attr("y", "-20%")
83
+ .attr("width", "140%").attr("height", "140%")
84
+ .call(filter => {
85
+ filter.append("feDropShadow")
86
+ .attr("dx", 0)
87
+ .attr("dy", 2)
88
+ .attr("stdDeviation", 2)
89
+ .attr("flood-color", "#162233")
90
+ .attr("flood-opacity", 0.18);
91
+ });
92
+
93
+ svg.append("rect")
94
+ .attr("x", 8)
95
+ .attr("y", 8)
96
+ .attr("width", width - 16)
97
+ .attr("height", height - 16)
98
+ .attr("fill", "url(#multiple-area-paper)")
99
+ .attr("stroke", "#0e2134")
100
+ .attr("stroke-width", 1.25);
101
+
102
+ const fontFamily = "Georgia, 'Times New Roman', serif";
103
+ const sansFamily = (typography.label && typography.label.font_family) || "Arial, sans-serif";
104
+ const navy = "#0d2238";
105
+ const muted = "#4d463c";
106
+ const red = "#a51519";
107
+ const green = "#2d6830";
108
+ const portrait = height > width * 1.15;
109
+
110
+ function fitFont(text, maxWidth, start, min, weight, family) {
111
+ let size = start;
112
+ while (size > min && chartUtils.text.measure(null, String(text), {
113
+ fontFamily: family,
114
+ fontSize: size,
115
+ fontWeight: weight
116
+ }).width > maxWidth) {
117
+ size -= 1;
118
+ }
119
+ return size;
120
+ }
121
+
122
+ function wrapLines(text, maxWidth, fontSize, fontWeight, family, maxLines) {
123
+ const words = String(text || "").split(/\s+/).filter(Boolean);
124
+ const lines = [];
125
+ let current = "";
126
+ words.forEach(word => {
127
+ const test = current ? `${current} ${word}` : word;
128
+ const widthTest = chartUtils.text.measure(null, test, {
129
+ fontFamily: family,
130
+ fontSize,
131
+ fontWeight
132
+ }).width;
133
+ if (widthTest <= maxWidth || !current) {
134
+ current = test;
135
+ } else {
136
+ lines.push(current);
137
+ current = word;
138
+ }
139
+ });
140
+ if (current) lines.push(current);
141
+ return lines.slice(0, maxLines);
142
+ }
143
+
144
+ function drawTextBlock(parent, lines, x, y, options) {
145
+ const opts = Object.assign({
146
+ fill: navy,
147
+ fontSize: 12,
148
+ fontWeight: 400,
149
+ family: fontFamily,
150
+ anchor: "middle",
151
+ lineHeight: 1.18,
152
+ style: null
153
+ }, options || {});
154
+ const text = parent.append("text")
155
+ .attr("x", x)
156
+ .attr("y", y)
157
+ .attr("text-anchor", opts.anchor)
158
+ .attr("fill", opts.fill)
159
+ .style("font-family", opts.family)
160
+ .style("font-size", `${opts.fontSize}px`)
161
+ .style("font-weight", opts.fontWeight);
162
+ if (opts.style) text.style("font-style", opts.style);
163
+ lines.forEach((line, i) => {
164
+ text.append("tspan")
165
+ .attr("x", x)
166
+ .attr("dy", i === 0 ? 0 : `${opts.fontSize * opts.lineHeight}px`)
167
+ .text(line);
168
+ });
169
+ return text;
170
+ }
171
+
172
+ function temporalNumber(value) {
173
+ if (value instanceof Date) return value.getTime();
174
+ const numeric = +value;
175
+ if (Number.isFinite(numeric)) return numeric;
176
+ const parsed = chartUtils.format.parseDate(value);
177
+ if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed.getTime();
178
+ const fallback = new Date(value);
179
+ return fallback instanceof Date && !Number.isNaN(fallback.getTime()) ? fallback.getTime() : 0;
180
+ }
181
+
182
+ function temporalLabel(value) {
183
+ const numeric = +value;
184
+ if (Number.isFinite(numeric) && numeric >= 1000 && numeric <= 3000) return String(Math.round(numeric));
185
+ const parsed = chartUtils.format.parseDate(value);
186
+ if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return String(parsed.getFullYear());
187
+ return String(value);
188
+ }
189
+
190
+ function niceValueMax(value) {
191
+ if (!Number.isFinite(value) || value <= 0) return 1;
192
+ const power = Math.pow(10, Math.floor(Math.log10(value)));
193
+ const scaled = value / power;
194
+ const step = scaled <= 1 ? 1 : scaled <= 2 ? 2 : scaled <= 5 ? 5 : 10;
195
+ return step * power;
196
+ }
197
+
198
+ function drawReservedSlot(parent, x, y, w, h, slotName, shape) {
199
+ const slot = parent.append("g")
200
+ .attr("class", "asset-slot")
201
+ .attr("data-asset-slot", slotName)
202
+ .attr("data-asset-source-policy", "image2_asset")
203
+ .attr("data-anchor", "header-safe-area")
204
+ .attr("data-collision-rule", "avoid-title-subtitle-category-labels-and-plot");
205
+ if (shape === "circle") {
206
+ slot.append("circle")
207
+ .attr("cx", x + w / 2)
208
+ .attr("cy", y + h / 2)
209
+ .attr("r", Math.min(w, h) / 2)
210
+ .attr("fill", "#eef0dc")
211
+ .attr("stroke", "#b9ad8e")
212
+ .attr("stroke-width", 1.2);
213
+ slot.append("circle")
214
+ .attr("cx", x + w / 2)
215
+ .attr("cy", y + h / 2)
216
+ .attr("r", Math.min(w, h) / 2 - 7)
217
+ .attr("fill", "none")
218
+ .attr("stroke", "#d2c6a4")
219
+ .attr("stroke-dasharray", "3 4");
220
+ } else {
221
+ slot.append("rect")
222
+ .attr("x", x)
223
+ .attr("y", y)
224
+ .attr("width", w)
225
+ .attr("height", h)
226
+ .attr("rx", 18)
227
+ .attr("fill", "#f1ead7")
228
+ .attr("stroke", "#bead85")
229
+ .attr("stroke-width", 1.1)
230
+ .attr("stroke-dasharray", "5 5")
231
+ .attr("opacity", 0.75);
232
+ slot.append("path")
233
+ .attr("d", `M${x + 14},${y + h - 14} C${x + w * 0.35},${y + h * 0.25} ${x + w * 0.62},${y + h * 0.76} ${x + w - 14},${y + 14}`)
234
+ .attr("fill", "none")
235
+ .attr("stroke", "#c7b891")
236
+ .attr("stroke-width", 1)
237
+ .attr("opacity", 0.8);
238
+ }
239
+ return slot;
240
+ }
241
+
242
+ const titles = jsonData.titles || {};
243
+ const metadata = jsonData.metadata || {};
244
+ const titleText = titles.main_title || metadata.title || "Multiple Area Comparison";
245
+ const subtitleText = titles.sub_title || metadata.description || jsonData.description || "";
246
+ const titleSize = fitFont(titleText, width - 170, 35, 24, 700, fontFamily);
247
+ const titleLines = wrapLines(titleText, width - 170, titleSize, 700, fontFamily, 2);
248
+ drawTextBlock(svg, titleLines, width / 2, 48, {
249
+ fill: navy,
250
+ fontSize: titleSize,
251
+ fontWeight: 700,
252
+ family: fontFamily,
253
+ anchor: "middle",
254
+ lineHeight: 1.05
255
+ });
256
+ const titleBlockHeight = titleLines.length * titleSize * 1.05;
257
+ const subtitleSize = fitFont(subtitleText, width - 160, 18, 13, 500, fontFamily);
258
+ const subtitleLines = wrapLines(subtitleText, width - 168, subtitleSize, 500, fontFamily, 2);
259
+ drawTextBlock(svg, subtitleLines, width / 2, 52 + titleBlockHeight, {
260
+ fill: navy,
261
+ fontSize: subtitleSize,
262
+ fontWeight: 500,
263
+ family: fontFamily,
264
+ style: "italic",
265
+ anchor: "middle",
266
+ lineHeight: 1.15
267
+ });
268
+
269
+ const headerSlotY = portrait ? 124 : 55;
270
+ const headerSlotW = portrait ? 112 : 88;
271
+ const headerSlotH = portrait ? 72 : 56;
272
+ drawReservedSlot(svg, 25, headerSlotY, headerSlotW, headerSlotH, "header-left-illustration", "rect");
273
+ drawReservedSlot(svg, width - 25 - headerSlotW, headerSlotY, headerSlotW, headerSlotH, "header-right-illustration", "rect");
274
+
275
+ const deckY = portrait ? 224 : 122;
276
+ const deckText = metadata.main_insight || jsonData.description || "";
277
+ const deckX = portrait ? 166 : 150;
278
+ const deckW = portrait ? width - 332 : width - 300;
279
+ const deckFont = portrait ? 13 : 12;
280
+ const deckLines = wrapLines(deckText, deckW - 28, deckFont, 400, fontFamily, 2);
281
+ svg.append("rect")
282
+ .attr("x", deckX)
283
+ .attr("y", deckY - (portrait ? 24 : 19))
284
+ .attr("width", deckW)
285
+ .attr("height", Math.max(portrait ? 48 : 34, deckLines.length * (portrait ? 18 : 15) + 13))
286
+ .attr("rx", 8)
287
+ .attr("fill", "#f7eedb")
288
+ .attr("stroke", "#cfbd94")
289
+ .attr("opacity", 0.92);
290
+ drawTextBlock(svg, deckLines, width / 2, deckY, {
291
+ fill: muted,
292
+ fontSize: deckFont,
293
+ family: fontFamily,
294
+ anchor: "middle",
295
+ lineHeight: 1.2
296
+ });
297
+
298
+ const plot = {
299
+ x: portrait ? 52 : 45,
300
+ y: portrait ? 370 : 185,
301
+ width: width - (portrait ? 104 : 90),
302
+ height: portrait ? Math.min(420, height * 0.43) : 300
303
+ };
304
+ const centerX = width / 2;
305
+ const axisWidth = Math.max(48, width * 0.08);
306
+ const axisLeft = centerX - axisWidth / 2;
307
+ const axisRight = centerX + axisWidth / 2;
308
+ const leftAreaMinX = plot.x + 18;
309
+ const rightAreaMaxX = plot.x + plot.width - 18;
310
+
311
+ const grouped = groups.map((group, index) => {
312
+ const rows = chartData
313
+ .filter(d => d[groupField] === group)
314
+ .map(d => Object.assign({}, d, {
315
+ __time: temporalNumber(d[xField]),
316
+ __label: temporalLabel(d[xField]),
317
+ __value: Math.max(0, +d[yField] || 0)
318
+ }))
319
+ .sort((a, b) => a.__time - b.__time);
320
+ return { group, index, rows, maxValue: d3.max(rows, d => d.__value) || 1 };
321
+ });
322
+
323
+ const allTimes = [...new Set(grouped.flatMap(series => series.rows.map(d => d.__time)))].sort((a, b) => a - b);
324
+ const minTime = d3.min(allTimes);
325
+ const maxTime = d3.max(allTimes);
326
+ const yScale = d3.scaleLinear()
327
+ .domain([minTime, maxTime])
328
+ .range([plot.y + 18, plot.y + plot.height - 18]);
329
+
330
+ const leftScale = d3.scaleLinear()
331
+ .domain([0, grouped[0] ? grouped[0].maxValue : 1])
332
+ .range([axisLeft, leftAreaMinX]);
333
+ const rightScale = d3.scaleLinear()
334
+ .domain([0, grouped[1] ? grouped[1].maxValue : 1])
335
+ .range([axisRight, rightAreaMaxX]);
336
+
337
+ const tickValues = allTimes;
338
+ tickValues.forEach(time => {
339
+ const y = yScale(time);
340
+ svg.append("line")
341
+ .attr("x1", plot.x)
342
+ .attr("x2", plot.x + plot.width)
343
+ .attr("y1", y)
344
+ .attr("y2", y)
345
+ .attr("stroke", "#b8afa0")
346
+ .attr("stroke-width", 0.8)
347
+ .attr("stroke-dasharray", "3 4")
348
+ .attr("opacity", 0.75);
349
+ });
350
+
351
+ const areaLeft = d3.area()
352
+ .x0(axisLeft)
353
+ .x1(d => leftScale(d.__value))
354
+ .y(d => yScale(d.__time))
355
+ .curve(d3.curveLinear);
356
+ const areaRight = d3.area()
357
+ .x0(axisRight)
358
+ .x1(d => rightScale(d.__value))
359
+ .y(d => yScale(d.__time))
360
+ .curve(d3.curveLinear);
361
+
362
+ if (grouped[0]) {
363
+ svg.append("path")
364
+ .datum(grouped[0].rows)
365
+ .attr("class", "series-area series-area-left")
366
+ .attr("data-series", grouped[0].group)
367
+ .attr("fill", "url(#multiple-area-left-fill)")
368
+ .attr("stroke", "#95121b")
369
+ .attr("stroke-width", 2)
370
+ .attr("d", areaLeft);
371
+ }
372
+ if (grouped[1]) {
373
+ svg.append("path")
374
+ .datum(grouped[1].rows)
375
+ .attr("class", "series-area series-area-right")
376
+ .attr("data-series", grouped[1].group)
377
+ .attr("fill", "url(#multiple-area-right-fill)")
378
+ .attr("stroke", "#315f2d")
379
+ .attr("stroke-width", 2)
380
+ .attr("d", areaRight);
381
+ }
382
+
383
+ const axisTop = plot.y - 26;
384
+ const axisHeight = plot.height + 45;
385
+ svg.append("rect")
386
+ .attr("class", "central-year-axis")
387
+ .attr("data-axis-role", "chronological-year-spine")
388
+ .attr("x", axisLeft)
389
+ .attr("y", axisTop)
390
+ .attr("width", axisWidth)
391
+ .attr("height", axisHeight)
392
+ .attr("rx", 11)
393
+ .attr("fill", "#0c3345")
394
+ .attr("filter", "url(#multiple-area-soft-shadow)");
395
+ svg.append("text")
396
+ .attr("x", centerX)
397
+ .attr("y", axisTop + 23)
398
+ .attr("text-anchor", "middle")
399
+ .attr("fill", "#ffffff")
400
+ .style("font-family", fontFamily)
401
+ .style("font-size", "15px")
402
+ .style("font-weight", 700)
403
+ .text("Year");
404
+
405
+ tickValues.forEach(time => {
406
+ const y = yScale(time);
407
+ const source = grouped[0] && grouped[0].rows.find(d => d.__time === time);
408
+ const label = source ? source.__label : String(time);
409
+ svg.append("text")
410
+ .attr("x", centerX)
411
+ .attr("y", y + 4)
412
+ .attr("text-anchor", "middle")
413
+ .attr("fill", "#ffffff")
414
+ .style("font-family", sansFamily)
415
+ .style("font-size", "14px")
416
+ .text(label);
417
+ [axisLeft, axisRight].forEach(x => {
418
+ svg.append("circle")
419
+ .attr("class", "axis-edge-dot")
420
+ .attr("data-axis-row", label)
421
+ .attr("cx", x)
422
+ .attr("cy", y)
423
+ .attr("r", 3.5)
424
+ .attr("fill", "#f8f0df")
425
+ .attr("stroke", "#ffffff")
426
+ .attr("stroke-width", 0.8);
427
+ });
428
+ });
429
+
430
+ function groupHeadingText(group) {
431
+ const cleaned = String(group || "").replace(/\s*\([^)]*\)\s*/g, "").trim();
432
+ return cleaned || String(group || "");
433
+ }
434
+
435
+ function drawSeriesHeading(series, side) {
436
+ if (!series) return;
437
+ const isLeft = side === "left";
438
+ const medallionSize = portrait ? 58 : 48;
439
+ const medallionY = plot.y - (portrait ? 93 : 42);
440
+ const medallionX = isLeft ? plot.x + 10 : plot.x + plot.width - medallionSize - 10;
441
+ const labelX = isLeft ? medallionX + medallionSize + 20 : medallionX - 20;
442
+ const headingY = medallionY + (portrait ? 21 : 14);
443
+ const anchor = isLeft ? "start" : "end";
444
+ const accent = isLeft ? red : green;
445
+ drawReservedSlot(svg, medallionX, medallionY, medallionSize, medallionSize, isLeft ? "series-left-medallion" : "series-right-medallion", "circle");
446
+ const heading = groupHeadingText(series.group);
447
+ const headingMaxWidth = portrait ? 220 : 190;
448
+ const headingSize = fitFont(heading, headingMaxWidth, portrait ? 22 : 18, 13, 700, fontFamily);
449
+ svg.append("text")
450
+ .attr("x", labelX)
451
+ .attr("y", headingY)
452
+ .attr("text-anchor", anchor)
453
+ .attr("fill", accent)
454
+ .style("font-family", fontFamily)
455
+ .style("font-size", `${headingSize}px`)
456
+ .style("font-weight", 700)
457
+ .text(heading);
458
+ const descriptor = `${yLabel}${yUnit ? ` (${yUnit})` : ""}`;
459
+ wrapLines(descriptor, headingMaxWidth, portrait ? 14 : 11, 400, fontFamily, 2).forEach((line, i) => {
460
+ svg.append("text")
461
+ .attr("x", labelX)
462
+ .attr("y", headingY + (portrait ? 24 : 17) + i * (portrait ? 17 : 14))
463
+ .attr("text-anchor", anchor)
464
+ .attr("fill", "#2f2c29")
465
+ .style("font-family", fontFamily)
466
+ .style("font-size", `${portrait ? 14 : 11}px`)
467
+ .text(line);
468
+ });
469
+ }
470
+ drawSeriesHeading(grouped[0], "left");
471
+ drawSeriesHeading(grouped[1], "right");
472
+
473
+ const sideDomain = niceValueMax(d3.max(grouped, s => s.maxValue) || 1);
474
+ const labelTicks = d3.ticks(0, sideDomain, 6).filter((_, i, arr) => i === 0 || i === arr.length - 1 || i % 1 === 0);
475
+ const leftLabelScale = d3.scaleLinear().domain([0, sideDomain]).range([axisLeft, leftAreaMinX]);
476
+ const rightLabelScale = d3.scaleLinear().domain([0, sideDomain]).range([axisRight, rightAreaMaxX]);
477
+ svg.append("line")
478
+ .attr("x1", leftAreaMinX)
479
+ .attr("x2", axisLeft)
480
+ .attr("y1", plot.y + plot.height + 8)
481
+ .attr("y2", plot.y + plot.height + 8)
482
+ .attr("stroke", "#9f8a60")
483
+ .attr("stroke-width", 1);
484
+ svg.append("line")
485
+ .attr("x1", axisRight)
486
+ .attr("x2", rightAreaMaxX)
487
+ .attr("y1", plot.y + plot.height + 8)
488
+ .attr("y2", plot.y + plot.height + 8)
489
+ .attr("stroke", "#9f8a60")
490
+ .attr("stroke-width", 1);
491
+ labelTicks.forEach(tick => {
492
+ const label = chartUtils.format.autoText(tick);
493
+ svg.append("text")
494
+ .attr("x", leftLabelScale(tick))
495
+ .attr("y", plot.y + plot.height + 26)
496
+ .attr("text-anchor", "middle")
497
+ .attr("fill", red)
498
+ .style("font-family", sansFamily)
499
+ .style("font-size", "12px")
500
+ .style("font-weight", 700)
501
+ .text(label);
502
+ svg.append("text")
503
+ .attr("x", rightLabelScale(tick))
504
+ .attr("y", plot.y + plot.height + 26)
505
+ .attr("text-anchor", "middle")
506
+ .attr("fill", green)
507
+ .style("font-family", sansFamily)
508
+ .style("font-size", "12px")
509
+ .style("font-weight", 700)
510
+ .text(label);
511
+ });
512
+
513
+ const leftCaption = `${yLabel}${yUnit ? ` (${yUnit})` : ""}`;
514
+ const rightCaption = leftCaption;
515
+ drawTextBlock(svg, wrapLines(leftCaption, 170, 12, 700, fontFamily, 1), centerX - 145, plot.y + plot.height + 52, {
516
+ fill: "#8e151a",
517
+ fontSize: 12,
518
+ fontWeight: 700,
519
+ family: fontFamily
520
+ });
521
+ drawTextBlock(svg, wrapLines(rightCaption, 170, 12, 700, fontFamily, 1), centerX + 145, plot.y + plot.height + 52, {
522
+ fill: "#27602e",
523
+ fontSize: 12,
524
+ fontWeight: 700,
525
+ family: fontFamily
526
+ });
527
+
528
+ svg.append("line")
529
+ .attr("x1", centerX - 236)
530
+ .attr("x2", centerX - 178)
531
+ .attr("y1", plot.y + plot.height + 48)
532
+ .attr("y2", plot.y + plot.height + 48)
533
+ .attr("stroke", "#a58d5e");
534
+ svg.append("line")
535
+ .attr("x1", centerX + 178)
536
+ .attr("x2", centerX + 236)
537
+ .attr("y1", plot.y + plot.height + 48)
538
+ .attr("y2", plot.y + plot.height + 48)
539
+ .attr("stroke", "#a58d5e");
540
+
541
+ svg.append("g")
542
+ .attr("class", "ignored-wrapper-region")
543
+ .attr("data-ignored-reference-element", "bottom-takeaway-source-footer")
544
+ .attr("data-policy", "no-bottom-narrative-wrapper");
545
+
546
+ return svg.node();
547
+ }
modules/chart_engine/template/d3-js/area_chart/range_area_chart_01.js ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Range Area Chart",
5
+ "chart_name": "range_area_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 50], [0, "inf"], [2, 2]],
9
+ "required_fields_icons": ["group"],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "light",
17
+ "icon_mark": "overlay",
18
+ "icon_label": "side",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes",
21
+ "chart_for": "comparison"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ function makeChart(containerSelector, data) {
27
+ const jsonData = data || {};
28
+ const rows = jsonData.data?.data || [];
29
+ const variables = jsonData.variables || {};
30
+ const dataColumns = chartUtils.schema.columns(jsonData);
31
+ const fontSize = chartUtils.text.fontSize;
32
+
33
+ d3.select(containerSelector).html("");
34
+
35
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
36
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
37
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
38
+ const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
39
+ const yDescription = yColumn.description || yField || "Value";
40
+ const yUnit = yColumn.unit || "";
41
+
42
+ const width = Number(variables.width) || 600;
43
+ const height = Number(variables.height) || 600;
44
+ const navy = "#0f2742";
45
+ const muted = "#6f766f";
46
+ const panelStroke = "#e8ded0";
47
+ const gridStroke = "#eadfce";
48
+ const orange = "#f28a13";
49
+ const green = "#5d8f3b";
50
+ const cream = "#fbf8f1";
51
+
52
+ const svg = d3.select(containerSelector)
53
+ .append("svg")
54
+ .attr("width", "100%")
55
+ .attr("height", height)
56
+ .attr("viewBox", `0 0 ${width} ${height}`)
57
+ .attr("style", "max-width: 100%; height: auto;")
58
+ .attr("xmlns", "http://www.w3.org/2000/svg")
59
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
60
+ .attr("class", "range-area-chart-01-reference-layout")
61
+ .attr("data-no-embedded-raster", "true")
62
+ .attr("data-no-copied-reference-art", "true");
63
+
64
+ const empty = message => {
65
+ svg.append("rect").attr("width", width).attr("height", height).attr("fill", cream);
66
+ svg.append("text")
67
+ .attr("x", width / 2)
68
+ .attr("y", height / 2)
69
+ .attr("text-anchor", "middle")
70
+ .style("font-family", "Arial, sans-serif")
71
+ .style("font-size", `${fontSize(14)}px`)
72
+ .style("fill", navy)
73
+ .text(message);
74
+ return svg.node();
75
+ };
76
+
77
+ if (!xField || !yField || !groupField || rows.length < 4) {
78
+ return empty("Missing range area fields");
79
+ }
80
+
81
+ const parseX = value => chartUtils.format.parseDate(value);
82
+ const validRows = rows
83
+ .map(d => ({ raw: d, x: parseX(d[xField]), y: +d[yField], group: d[groupField] }))
84
+ .filter(d => d.x instanceof Date && !Number.isNaN(d.x.getTime()) && Number.isFinite(d.y) && d.group != null);
85
+ const groups = Array.from(new Set(validRows.map(d => d.group)));
86
+ if (groups.length < 2) return empty("Need two groups");
87
+
88
+ const averages = groups
89
+ .map(group => {
90
+ const values = validRows.filter(d => d.group === group).map(d => d.y);
91
+ return { group, avg: d3.mean(values) };
92
+ })
93
+ .sort((a, b) => d3.descending(a.avg, b.avg));
94
+ const upperGroup = averages[0].group;
95
+ const lowerGroup = averages[averages.length - 1].group;
96
+
97
+ const byGroup = group => validRows
98
+ .filter(d => d.group === group)
99
+ .sort((a, b) => d3.ascending(a.x, b.x));
100
+ const upperData = byGroup(upperGroup);
101
+ const lowerData = byGroup(lowerGroup);
102
+ const lowerByTime = new Map(lowerData.map(d => [d.x.getTime(), d]));
103
+ const areaData = upperData
104
+ .map(top => ({ top, bottom: lowerByTime.get(top.x.getTime()) }))
105
+ .filter(d => d.bottom);
106
+ if (!areaData.length) return empty("No paired range values");
107
+
108
+ const allDates = validRows.map(d => d.x);
109
+ const xExtent = d3.extent(allDates);
110
+ const yExtent = d3.extent(validRows, d => d.y);
111
+ const yPad = Math.max(1, (yExtent[1] - yExtent[0]) * 0.18);
112
+ const yDomain = [Math.max(0, yExtent[0] - yPad), yExtent[1] + yPad];
113
+
114
+ const legendY = 22;
115
+ const panel = {
116
+ x: 56,
117
+ y: 76,
118
+ width: Math.max(420, width - 96),
119
+ height: Math.max(390, height - 138),
120
+ rx: 10
121
+ };
122
+ const margin = { top: 38, right: 42, bottom: 48, left: 56 };
123
+ const plot = {
124
+ x: panel.x + margin.left,
125
+ y: panel.y + margin.top,
126
+ width: panel.width - margin.left - margin.right,
127
+ height: panel.height - margin.top - margin.bottom
128
+ };
129
+
130
+ const xScale = d3.scaleTime().domain(xExtent).range([plot.x, plot.x + plot.width]);
131
+ const yScale = d3.scaleLinear().domain(yDomain).nice(4).range([plot.y + plot.height, plot.y]);
132
+
133
+ const uniqueDates = Array.from(new Map(validRows.map(d => [d.x.getTime(), d.x])).values())
134
+ .sort((a, b) => d3.ascending(a, b));
135
+ const targetTicks = Math.min(5, uniqueDates.length);
136
+ const xTicks = uniqueDates.filter((_, i) => {
137
+ if (i === 0 || i === uniqueDates.length - 1) return true;
138
+ const step = Math.max(1, Math.round((uniqueDates.length - 1) / (targetTicks - 1)));
139
+ return i % step === 0;
140
+ }).slice(0, 6);
141
+ if (xTicks[xTicks.length - 1]?.getTime() !== uniqueDates[uniqueDates.length - 1].getTime()) {
142
+ xTicks.push(uniqueDates[uniqueDates.length - 1]);
143
+ }
144
+ const yTicks = yScale.ticks(5).filter(t => t >= 0);
145
+ const yearFormat = d => chartUtils.format.date(d, { output: "%Y" }).text;
146
+ const valueText = value => chartUtils.format.autoText(+value).text || chartUtils.format.autoText(+value);
147
+ const valueWithUnit = value => `${valueText(value)}${yUnit && !String(valueText(value)).includes(yUnit) ? yUnit : ""}`;
148
+
149
+ const defs = svg.append("defs");
150
+ const fillGradientId = "rangeArea01Fill";
151
+ defs.append("linearGradient")
152
+ .attr("id", fillGradientId)
153
+ .attr("x1", "0%")
154
+ .attr("x2", "0%")
155
+ .attr("y1", "0%")
156
+ .attr("y2", "100%")
157
+ .selectAll("stop")
158
+ .data([
159
+ { offset: "0%", color: orange, opacity: 0.30 },
160
+ { offset: "58%", color: "#f5c57c", opacity: 0.18 },
161
+ { offset: "100%", color: green, opacity: 0.16 }
162
+ ])
163
+ .enter()
164
+ .append("stop")
165
+ .attr("offset", d => d.offset)
166
+ .attr("stop-color", d => d.color)
167
+ .attr("stop-opacity", d => d.opacity);
168
+
169
+ svg.append("rect")
170
+ .attr("x", panel.x)
171
+ .attr("y", panel.y)
172
+ .attr("width", panel.width)
173
+ .attr("height", panel.height)
174
+ .attr("rx", panel.rx)
175
+ .attr("fill", "#fffdf8")
176
+ .attr("stroke", panelStroke)
177
+ .attr("stroke-width", 1.2)
178
+ .attr("filter", "drop-shadow(0px 1px 4px rgba(68, 42, 17, 0.12))");
179
+
180
+ const legendItems = [
181
+ { group: upperGroup, color: orange },
182
+ { group: lowerGroup, color: green }
183
+ ];
184
+ const legend = svg.append("g")
185
+ .attr("class", "legend-pills")
186
+ .attr("data-relationship", "independent-centered-row")
187
+ .attr("transform", `translate(${width / 2}, ${legendY})`);
188
+ const pillWidth = Math.min(188, Math.max(130, width * 0.28));
189
+ const pillGap = 14;
190
+ legendItems.forEach((item, i) => {
191
+ const x = (i - 0.5) * (pillWidth + pillGap) - pillWidth / 2;
192
+ const pill = legend.append("g").attr("transform", `translate(${x}, 0)`);
193
+ pill.append("rect")
194
+ .attr("x", 0)
195
+ .attr("y", 0)
196
+ .attr("width", pillWidth)
197
+ .attr("height", 30)
198
+ .attr("rx", 15)
199
+ .attr("fill", "#fffefa")
200
+ .attr("stroke", panelStroke)
201
+ .attr("stroke-width", 1);
202
+ pill.append("circle")
203
+ .attr("cx", 16)
204
+ .attr("cy", 15)
205
+ .attr("r", 7)
206
+ .attr("fill", item.color);
207
+ pill.append("line")
208
+ .attr("x1", 32)
209
+ .attr("x2", 58)
210
+ .attr("y1", 15)
211
+ .attr("y2", 15)
212
+ .attr("stroke", item.color)
213
+ .attr("stroke-width", 3)
214
+ .attr("stroke-linecap", "round");
215
+ pill.append("text")
216
+ .attr("x", 66)
217
+ .attr("y", 16)
218
+ .attr("dominant-baseline", "middle")
219
+ .style("font-family", "Arial, sans-serif")
220
+ .style("font-size", `${fontSize(10)}px`)
221
+ .style("font-weight", "700")
222
+ .style("fill", navy)
223
+ .text(`${String(item.group).slice(0, 25)}${yUnit ? ` (${yUnit})` : ""}`);
224
+ });
225
+
226
+ yTicks.forEach(tick => {
227
+ const y = yScale(tick);
228
+ svg.append("line")
229
+ .attr("class", "gridline")
230
+ .attr("x1", plot.x)
231
+ .attr("x2", plot.x + plot.width)
232
+ .attr("y1", y)
233
+ .attr("y2", y)
234
+ .attr("stroke", gridStroke)
235
+ .attr("stroke-width", 1)
236
+ .attr("stroke-dasharray", "2 4");
237
+ svg.append("text")
238
+ .attr("x", plot.x - 12)
239
+ .attr("y", y)
240
+ .attr("text-anchor", "end")
241
+ .attr("dominant-baseline", "middle")
242
+ .style("font-family", "Arial, sans-serif")
243
+ .style("font-size", `${fontSize(10)}px`)
244
+ .style("fill", muted)
245
+ .text(valueText(tick));
246
+ });
247
+
248
+ xTicks.forEach(tick => {
249
+ const x = xScale(tick);
250
+ svg.append("line")
251
+ .attr("x1", x)
252
+ .attr("x2", x)
253
+ .attr("y1", plot.y)
254
+ .attr("y2", plot.y + plot.height)
255
+ .attr("stroke", gridStroke)
256
+ .attr("stroke-width", 1)
257
+ .attr("stroke-dasharray", "2 4");
258
+ svg.append("text")
259
+ .attr("x", x)
260
+ .attr("y", plot.y + plot.height + 24)
261
+ .attr("text-anchor", "middle")
262
+ .style("font-family", "Arial, sans-serif")
263
+ .style("font-size", `${fontSize(11)}px`)
264
+ .style("fill", "#4f554e")
265
+ .text(yearFormat(tick));
266
+ });
267
+
268
+ svg.append("line")
269
+ .attr("x1", plot.x)
270
+ .attr("x2", plot.x + plot.width)
271
+ .attr("y1", plot.y + plot.height)
272
+ .attr("y2", plot.y + plot.height)
273
+ .attr("stroke", "#9a9a90")
274
+ .attr("stroke-width", 1.2);
275
+ svg.append("line")
276
+ .attr("x1", plot.x)
277
+ .attr("x2", plot.x)
278
+ .attr("y1", plot.y)
279
+ .attr("y2", plot.y + plot.height)
280
+ .attr("stroke", "#9a9a90")
281
+ .attr("stroke-width", 1.2);
282
+ svg.append("text")
283
+ .attr("x", plot.x)
284
+ .attr("y", panel.y + 20)
285
+ .style("font-family", "Arial, sans-serif")
286
+ .style("font-size", `${fontSize(10)}px`)
287
+ .style("font-weight", "700")
288
+ .style("fill", "#757a73")
289
+ .text(yDescription.length > 42 ? `${yDescription.slice(0, 39)}...` : yDescription);
290
+
291
+ const line = d3.line()
292
+ .x(d => xScale(d.x))
293
+ .y(d => yScale(d.y))
294
+ .curve(d3.curveMonotoneX);
295
+ const area = d3.area()
296
+ .x(d => xScale(d.top.x))
297
+ .y0(d => yScale(Math.min(d.top.y, d.bottom.y)))
298
+ .y1(d => yScale(Math.max(d.top.y, d.bottom.y)))
299
+ .curve(d3.curveMonotoneX);
300
+
301
+ svg.append("path")
302
+ .datum(areaData)
303
+ .attr("class", "range-area-fill")
304
+ .attr("d", area)
305
+ .attr("fill", `url(#${fillGradientId})`);
306
+ svg.append("path")
307
+ .datum(upperData)
308
+ .attr("class", "upper-range-boundary")
309
+ .attr("d", line)
310
+ .attr("fill", "none")
311
+ .attr("stroke", orange)
312
+ .attr("stroke-width", 3)
313
+ .attr("stroke-linecap", "round")
314
+ .attr("stroke-linejoin", "round");
315
+ svg.append("path")
316
+ .datum(lowerData)
317
+ .attr("class", "lower-range-boundary")
318
+ .attr("d", line)
319
+ .attr("fill", "none")
320
+ .attr("stroke", green)
321
+ .attr("stroke-width", 3)
322
+ .attr("stroke-linecap", "round")
323
+ .attr("stroke-linejoin", "round");
324
+
325
+ const endpointGroups = [
326
+ { role: "start-upper", d: upperData[0], color: orange, dx: 16, dy: -22, anchor: "start" },
327
+ { role: "end-upper", d: upperData[upperData.length - 1], color: orange, dx: 16, dy: -12, anchor: "start" },
328
+ { role: "start-lower", d: lowerData[0], color: green, dx: -16, dy: 4, anchor: "end" },
329
+ { role: "end-lower", d: lowerData[lowerData.length - 1], color: green, dx: 16, dy: 12, anchor: "start" }
330
+ ];
331
+ endpointGroups.forEach(item => {
332
+ const x = xScale(item.d.x);
333
+ const y = yScale(item.d.y);
334
+ svg.append("circle")
335
+ .attr("class", `endpoint-marker ${item.role}`)
336
+ .attr("cx", x)
337
+ .attr("cy", y)
338
+ .attr("r", 6)
339
+ .attr("fill", "#fffefa")
340
+ .attr("stroke", item.color)
341
+ .attr("stroke-width", 3);
342
+ svg.append("text")
343
+ .attr("class", `endpoint-label ${item.role}`)
344
+ .attr("x", Math.max(panel.x + 14, Math.min(panel.x + panel.width - 14, x + item.dx)))
345
+ .attr("y", Math.max(panel.y + 18, Math.min(panel.y + panel.height - 18, y + item.dy)))
346
+ .attr("text-anchor", item.anchor)
347
+ .attr("dominant-baseline", "middle")
348
+ .style("font-family", "Arial, sans-serif")
349
+ .style("font-size", `${fontSize(19)}px`)
350
+ .style("font-weight", "800")
351
+ .style("fill", item.color)
352
+ .text(valueWithUnit(item.d.y));
353
+ });
354
+
355
+ const drawCallout = (top, bottom, side) => {
356
+ const ratio = bottom.y === 0 ? null : top.y / bottom.y;
357
+ if (!ratio || !Number.isFinite(ratio)) return;
358
+ const anchorX = xScale(top.x);
359
+ const midY = (yScale(top.y) + yScale(bottom.y)) / 2;
360
+ const cardW = 68;
361
+ const cardH = 70;
362
+ const clampX = x => Math.max(panel.x + 8, Math.min(panel.x + panel.width - cardW - 8, x));
363
+ const clampY = y => Math.max(panel.y + 48, Math.min(panel.y + panel.height - cardH - 38, y));
364
+ const preferredX = side === "start" ? anchorX + 16 : anchorX - cardW - 16;
365
+ const gapTop = Math.min(yScale(top.y), yScale(bottom.y));
366
+ const gapBottom = Math.max(yScale(top.y), yScale(bottom.y));
367
+ const candidates = [
368
+ { x: clampX(preferredX), y: clampY(midY - cardH / 2) },
369
+ { x: clampX(preferredX), y: clampY(gapTop + 12) },
370
+ { x: clampX(preferredX), y: clampY(gapBottom - cardH - 12) },
371
+ { x: clampX(side === "start" ? anchorX + 32 : anchorX - cardW - 32), y: clampY(midY - cardH / 2) },
372
+ { x: clampX(side === "start" ? anchorX - cardW - 16 : anchorX + 16), y: clampY(midY - cardH / 2) }
373
+ ];
374
+ const linePoints = [];
375
+ const addLineSamples = series => {
376
+ const points = series.map(d => ({ x: xScale(d.x), y: yScale(d.y) }));
377
+ points.forEach(p => linePoints.push(p));
378
+ for (let i = 0; i < points.length - 1; i += 1) {
379
+ const a = points[i];
380
+ const b = points[i + 1];
381
+ const steps = Math.max(4, Math.ceil(Math.abs(b.x - a.x) / 18));
382
+ for (let j = 1; j < steps; j += 1) {
383
+ const t = j / steps;
384
+ linePoints.push({
385
+ x: a.x + (b.x - a.x) * t,
386
+ y: a.y + (b.y - a.y) * t
387
+ });
388
+ }
389
+ }
390
+ };
391
+ addLineSamples(upperData);
392
+ addLineSamples(lowerData);
393
+ const markerPad = 10;
394
+ const scoreCandidate = c => {
395
+ const x0 = c.x - markerPad;
396
+ const x1 = c.x + cardW + markerPad;
397
+ const y0 = c.y - markerPad;
398
+ const y1 = c.y + cardH + markerPad;
399
+ const overlaps = linePoints.filter(p => p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1).length;
400
+ const edgePenalty = Math.abs(c.x - preferredX) * 0.01 + Math.abs((c.y + cardH / 2) - midY) * 0.01;
401
+ return overlaps * 1000 + edgePenalty;
402
+ };
403
+ const ranked = candidates
404
+ .map(c => ({ ...c, score: scoreCandidate(c) }))
405
+ .sort((a, b) => d3.ascending(a.score, b.score));
406
+ if (!ranked.length || ranked[0].score >= 1000) return;
407
+ const x = ranked[0].x;
408
+ const y = ranked[0].y;
409
+ const connectorX = side === "start" ? x : x + cardW;
410
+ const connectorY = y + cardH / 2;
411
+ svg.append("line")
412
+ .attr("class", "ratio-callout-leader")
413
+ .attr("x1", connectorX)
414
+ .attr("y1", connectorY)
415
+ .attr("x2", anchorX)
416
+ .attr("y2", midY)
417
+ .attr("stroke", "#e2a35e")
418
+ .attr("stroke-width", 1.3)
419
+ .attr("stroke-dasharray", "3 3");
420
+ const g = svg.append("g")
421
+ .attr("class", "ratio-callout")
422
+ .attr("data-relationship", "floating-gap-midpoint")
423
+ .attr("transform", `translate(${x}, ${y})`);
424
+ g.append("rect")
425
+ .attr("width", cardW)
426
+ .attr("height", cardH)
427
+ .attr("rx", 9)
428
+ .attr("fill", "#fffefa")
429
+ .attr("stroke", "#e7dccc")
430
+ .attr("stroke-width", 1.1);
431
+ g.append("text")
432
+ .attr("x", cardW / 2)
433
+ .attr("y", 20)
434
+ .attr("text-anchor", "middle")
435
+ .style("font-family", "Arial, sans-serif")
436
+ .style("font-size", `${fontSize(18)}px`)
437
+ .style("font-weight", "800")
438
+ .style("fill", side === "start" ? orange : green)
439
+ .text(chartUtils.format.fixed(ratio, 1).text);
440
+ g.append("text")
441
+ .attr("x", cardW / 2)
442
+ .attr("y", 38)
443
+ .attr("text-anchor", "middle")
444
+ .style("font-family", "Arial, sans-serif")
445
+ .style("font-size", `${fontSize(9)}px`)
446
+ .style("font-weight", "700")
447
+ .style("fill", "#4f554e")
448
+ .text("Ratio");
449
+ g.append("text")
450
+ .attr("x", cardW / 2)
451
+ .attr("y", 52)
452
+ .attr("text-anchor", "middle")
453
+ .style("font-family", "Arial, sans-serif")
454
+ .style("font-size", `${fontSize(7)}px`)
455
+ .style("fill", "#686d66")
456
+ .text("upper/lower");
457
+ };
458
+ drawCallout(upperData[0], lowerByTime.get(upperData[0].x.getTime()), "start");
459
+ drawCallout(upperData[upperData.length - 1], lowerByTime.get(upperData[upperData.length - 1].x.getTime()), "end");
460
+
461
+ svg.append("g")
462
+ .attr("class", "reserved-asset-slot title-art-slot")
463
+ .attr("data-type", "image-slot")
464
+ .attr("data-asset-slot", "range-area-title-art")
465
+ .attr("data-slot-policy", "image2_asset_or_neutral_placeholder")
466
+ .attr("data-asset-source-policy", "image2_asset_or_neutral_placeholder")
467
+ .attr("data-slot-anchor", "top-center-title-zone")
468
+ .attr("data-slot-collision", "stay-above-title-and-do-not-overlap-legend-or-chart-panel")
469
+ .attr("data-bbox", `${(width / 2 - 18).toFixed(1)},0,36,20`)
470
+ .attr("data-no-embedded-raster", "true")
471
+ .attr("data-no-copied-reference-art", "true")
472
+ .attr("opacity", 0);
473
+
474
+ return svg.node();
475
+ }
modules/chart_engine/template/d3-js/area_chart/small_multiple_area_chart_01.js ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Small Multiple Area Chart",
5
+ "chart_name": "small_multiple_area_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 50], [0, "inf"], [2, 20]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": ["background"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "dark",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
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_dark || {};
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 xField = chartUtils.schema.columnField(dataColumns, 0);
41
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
42
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
43
+
44
+ // 获取唯一的组值
45
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
46
+
47
+ // 计算最长组名的宽度,用于动态调整左边距
48
+ const maxGroupNameWidth = d3.max(groups, group => chartUtils.text.measure(null, group, { fontSize: 14, fontWeight: "bold" }).width) || 0;
49
+ const dynamicLeftMargin = Math.max(120, maxGroupNameWidth + 40);
50
+
51
+ // 设置尺寸和边距 - 动态调整左侧边距和右侧边距(为Y轴留出空间)
52
+ const width = variables.width;
53
+ const height = variables.height;
54
+ const margin = {
55
+ top: 60,
56
+ right: 60, // 增加右边距为Y轴留出空间
57
+ bottom: 60,
58
+ left: dynamicLeftMargin
59
+ };
60
+
61
+ // 创建SVG
62
+ const svg = d3.select(containerSelector)
63
+ .append("svg")
64
+ .attr("width", "100%")
65
+ .attr("height", height)
66
+ .attr("viewBox", `0 0 ${width} ${height}`)
67
+ .attr("style", "max-width: 100%; height: auto;")
68
+ .attr("xmlns", "http://www.w3.org/2000/svg")
69
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
70
+
71
+ // 创建图表区域
72
+ const chartWidth = width - margin.left - margin.right;
73
+ const chartHeight = height - margin.top - margin.bottom;
74
+
75
+ const g = svg.append("g")
76
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
77
+
78
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
79
+
80
+ // 为每个组计算最大值
81
+ const groupMaxValues = {};
82
+ groups.forEach(group => {
83
+ const groupData = chartData.filter(d => d[groupField] === group);
84
+ groupMaxValues[group] = d3.max(groupData, d => +d[yField]);
85
+ });
86
+
87
+ // 计算每个组的垂直位置(上下错开)
88
+ const groupHeight = chartHeight / (groups.length * 1.5); // 每个组的高度,留出一些间距
89
+ const groupPositions = {};
90
+ groups.forEach((group, i) => {
91
+ groupPositions[group] = i * groupHeight * 1.5; // 乘以1.5是为了留出间距
92
+ });
93
+
94
+ // 创建y轴比例尺(每个组有自己的比例尺)
95
+ const yScales = {};
96
+ groups.forEach(group => {
97
+ yScales[group] = d3.scaleLinear()
98
+ .domain([0, groupMaxValues[group]])
99
+ .range([groupHeight, 0]); // 从下到上
100
+ });
101
+
102
+ // 添加网格线和x轴刻度
103
+
104
+ // 添加垂直网格线
105
+ xTicks.forEach(tick => {
106
+ g.append("line")
107
+ .attr("x1", xScale(tick))
108
+ .attr("y1", 0)
109
+ .attr("x2", xScale(tick))
110
+ .attr("y2", chartHeight)
111
+ .attr("stroke", "#ffffff")
112
+ .attr("stroke-opacity", 0.1)
113
+ .attr("stroke-width", 1);
114
+ });
115
+
116
+ xTicks.forEach(tick => {
117
+ // 添加上方x轴年份标签
118
+ g.append("text")
119
+ .attr("x", xScale(tick))
120
+ .attr("y", -10)
121
+ .attr("text-anchor", "middle")
122
+ .attr("dominant-baseline", "bottom")
123
+ .attr("fill", "#ffffff")
124
+ .style("font-size", "12px")
125
+ .text(xFormat(tick));
126
+
127
+ // 添加下方x轴年份标签
128
+ g.append("text")
129
+ .attr("x", xScale(tick))
130
+ .attr("y", chartHeight + 15)
131
+ .attr("text-anchor", "middle")
132
+ .attr("dominant-baseline", "hanging")
133
+ .attr("fill", "#ffffff")
134
+ .style("font-size", "12px")
135
+ .text(xFormat(tick));
136
+ });
137
+
138
+ // 创建面积生成器
139
+ const area = (group) => d3.area()
140
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
141
+ .y0(groupPositions[group] + groupHeight) // 底部是固定的
142
+ .y1(d => groupPositions[group] + yScales[group](d[yField])) // 顶部根据数据变化
143
+ .curve(d3.curveBasis); // 使用平滑曲线
144
+
145
+ // 为每个组创建面积图和Y轴
146
+ groups.forEach(group => {
147
+ // 过滤该组的数据
148
+ const groupData = chartData.filter(d => d[groupField] === group);
149
+
150
+ // 确保数据按日期排序
151
+ groupData.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
152
+
153
+ // 获取颜色
154
+ const color = colorResolver.field(group, groups.indexOf(group), { palette: "category10" }).value;
155
+
156
+ // 添加0线 - 水平线位于组的底部位置,延伸到图表结束
157
+ g.append("line")
158
+ .attr("x1", -margin.left + 20) // 从左侧边缘开始
159
+ .attr("y1", groupPositions[group] + groupHeight) // y0位置
160
+ .attr("x2", chartWidth) // 延伸到图表区域结束
161
+ .attr("y2", groupPositions[group] + groupHeight)
162
+ .attr("stroke", color)
163
+ .attr("stroke-width", 1.5)
164
+ .attr("stroke-opacity", 0.8);
165
+
166
+ // 绘制面积
167
+ g.append("path")
168
+ .datum(groupData)
169
+ .attr("fill", color)
170
+ .attr("d", area(group));
171
+
172
+ // 添加组名标签 - 放在面积图左侧,距离更远
173
+ g.append("text")
174
+ .attr("x", -margin.left + 20) // 增加与图表的距离
175
+ .attr("y", groupPositions[group] + groupHeight - 10)
176
+ .attr("text-anchor", "start") // 左对齐
177
+ .attr("dominant-baseline", "middle")
178
+ .attr("fill", color)
179
+ .attr("font-weight", "bold")
180
+ .style("font-size", "14px")
181
+ .text(group);
182
+
183
+ // 创建Y轴 - 在右侧
184
+ const yAxis = d3.axisRight(yScales[group])
185
+ .ticks(3) // 减少刻度数量,避免拥挤
186
+ .tickSize(0) // 不显示刻度线
187
+ .tickFormat(d => chartUtils.format.autoText(+(d))); // 使用简洁格式
188
+
189
+ // 添加Y轴
190
+ const yAxisGroup = g.append("g")
191
+ .attr("transform", `translate(${chartWidth}, ${groupPositions[group]})`)
192
+ .call(yAxis);
193
+
194
+ // 设置Y轴样式
195
+ yAxisGroup.selectAll("path")
196
+ .attr("stroke", color);
197
+
198
+ yAxisGroup.selectAll("text")
199
+ .attr("fill", color)
200
+ .attr("font-size", "10px")
201
+ .attr("dx", "0.5em"); // 向右移动文本
202
+
203
+ // 添加水平辅助线
204
+ yScales[group].ticks(3).forEach(tick => {
205
+ if (tick > 0) { // 跳过0线,因为已经单独添加了
206
+ g.append("line")
207
+ .attr("x1", 0)
208
+ .attr("y1", groupPositions[group] + yScales[group](tick))
209
+ .attr("x2", chartWidth)
210
+ .attr("y2", groupPositions[group] + yScales[group](tick))
211
+ .attr("stroke", color)
212
+ .attr("stroke-opacity", 0.2)
213
+ .attr("stroke-width", 0.5)
214
+ .attr("stroke-dasharray", "2,2"); // 虚线
215
+ }
216
+ });
217
+ });
218
+
219
+ return svg.node();
220
+ }
modules/chart_engine/template/d3-js/area_chart/small_multiple_area_chart_02.js ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Small Multiple Area Chart",
5
+ "chart_name": "small_multiple_area_chart_02",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 50], [0, "inf"], [2, 20]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": ["background"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
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 xField = chartUtils.schema.columnField(dataColumns, 0);
41
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
42
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
43
+
44
+ // 获取唯一的组值
45
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
46
+
47
+ // 计算最长组名的宽度,用于动态调整左边距
48
+ const maxGroupNameWidth = d3.max(groups, group => chartUtils.text.measure(null, group, { fontSize: 14, fontWeight: "bold" }).width) || 0;
49
+ const dynamicLeftMargin = Math.max(120, maxGroupNameWidth + 40);
50
+
51
+ // 设置尺寸和边距 - 动态调整左侧边距和右侧边距(为Y轴留出空间)
52
+ const width = variables.width;
53
+ const height = variables.height;
54
+ const margin = {
55
+ top: 60,
56
+ right: 60, // 增加右边距为Y轴留出空间
57
+ bottom: 60,
58
+ left: dynamicLeftMargin
59
+ };
60
+
61
+ // 创建SVG
62
+ const svg = d3.select(containerSelector)
63
+ .append("svg")
64
+ .attr("width", "100%")
65
+ .attr("height", height)
66
+ .attr("viewBox", `0 0 ${width} ${height}`)
67
+ .attr("style", "max-width: 100%; height: auto;")
68
+ .attr("xmlns", "http://www.w3.org/2000/svg")
69
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
70
+
71
+ // 创建图表区域
72
+ const chartWidth = width - margin.left - margin.right;
73
+ const chartHeight = height - margin.top - margin.bottom;
74
+
75
+ const g = svg.append("g")
76
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
77
+
78
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
79
+
80
+ // 为每个组计算最大值
81
+ const groupMaxValues = {};
82
+ groups.forEach(group => {
83
+ const groupData = chartData.filter(d => d[groupField] === group);
84
+ groupMaxValues[group] = d3.max(groupData, d => +d[yField]);
85
+ });
86
+
87
+ // 计算每个组的垂直位置(上下错开)
88
+ const groupHeight = chartHeight / (groups.length * 1.5); // 每个组的高度,留出一些间距
89
+ const groupPositions = {};
90
+ groups.forEach((group, i) => {
91
+ groupPositions[group] = i * groupHeight * 1.5; // 乘以1.5是为了留出间距
92
+ });
93
+
94
+ // 创建y轴比例尺(每个组有自己的比例尺)
95
+ const yScales = {};
96
+ groups.forEach(group => {
97
+ yScales[group] = d3.scaleLinear()
98
+ .domain([0, groupMaxValues[group]])
99
+ .range([groupHeight, 0]); // 从下到上
100
+ });
101
+
102
+ // 添加网格线和x轴刻度
103
+
104
+ // 添加垂直网格线
105
+ xTicks.forEach(tick => {
106
+ g.append("line")
107
+ .attr("x1", xScale(tick))
108
+ .attr("y1", 0)
109
+ .attr("x2", xScale(tick))
110
+ .attr("y2", chartHeight)
111
+ .attr("stroke", "#e0e0e0") // 改为更明显的灰色网格线
112
+ .attr("stroke-opacity", 0.5) // 增加不透明度
113
+ .attr("stroke-width", 1);
114
+ });
115
+
116
+ xTicks.forEach(tick => {
117
+ // 添加上方x轴年份标签
118
+ g.append("text")
119
+ .attr("x", xScale(tick))
120
+ .attr("y", -10)
121
+ .attr("text-anchor", "middle")
122
+ .attr("dominant-baseline", "bottom")
123
+ .attr("fill", "#000000") // 改为黑色文字
124
+ .style("font-size", "12px")
125
+ .text(xFormat(tick));
126
+
127
+ // 添加下方x轴年份标签
128
+ g.append("text")
129
+ .attr("x", xScale(tick))
130
+ .attr("y", chartHeight + 15)
131
+ .attr("text-anchor", "middle")
132
+ .attr("dominant-baseline", "hanging")
133
+ .attr("fill", "#000000") // 改为黑色文字
134
+ .style("font-size", "12px")
135
+ .text(xFormat(tick));
136
+ });
137
+
138
+ // 创建面积生成器
139
+ const area = (group) => d3.area()
140
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
141
+ .y0(groupPositions[group] + groupHeight) // 底部是固定的
142
+ .y1(d => groupPositions[group] + yScales[group](d[yField])) // 顶部根据数据变化
143
+ .curve(d3.curveBasis); // 使用平滑曲线
144
+
145
+ // 为每个组创建面积图和Y轴
146
+ groups.forEach(group => {
147
+ // 过滤该组的数据
148
+ const groupData = chartData.filter(d => d[groupField] === group);
149
+
150
+ // 确保数据按日期排序
151
+ groupData.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
152
+
153
+ // 获取颜色
154
+ const color = colorResolver.field(group, groups.indexOf(group), { palette: "category10" }).value;
155
+
156
+ // 添加0线 - 水平线位于组的底部位置,延伸到图表结束
157
+ g.append("line")
158
+ .attr("x1", -margin.left + 20) // 从左侧边缘开始
159
+ .attr("y1", groupPositions[group] + groupHeight) // y0位置
160
+ .attr("x2", chartWidth) // 延伸到图表区域结束
161
+ .attr("y2", groupPositions[group] + groupHeight)
162
+ .attr("stroke", color)
163
+ .attr("stroke-width", 1.5)
164
+ .attr("stroke-opacity", 0.8);
165
+
166
+ // 绘制面积
167
+ g.append("path")
168
+ .datum(groupData)
169
+ .attr("fill", color)
170
+ .attr("d", area(group));
171
+
172
+ // 添加组名标签 - 放在面积图左侧,距离更远
173
+ g.append("text")
174
+ .attr("x", -margin.left + 20) // 增加与图表的距离
175
+ .attr("y", groupPositions[group] + groupHeight - 10)
176
+ .attr("text-anchor", "start") // 左对齐
177
+ .attr("dominant-baseline", "middle")
178
+ .attr("fill", color)
179
+ .attr("font-weight", "bold")
180
+ .style("font-size", "14px")
181
+ .text(group);
182
+
183
+ // 创建Y轴 - 在右侧
184
+ const yAxis = d3.axisRight(yScales[group])
185
+ .ticks(3) // 减少刻度数量,避免拥挤
186
+ .tickSize(0) // 不显示刻度线
187
+ .tickFormat(d => chartUtils.format.autoText(+(d))); // 使用简洁格式
188
+
189
+ // 添加Y轴
190
+ const yAxisGroup = g.append("g")
191
+ .attr("transform", `translate(${chartWidth}, ${groupPositions[group]})`)
192
+ .call(yAxis);
193
+
194
+ // 设置Y轴样式
195
+ yAxisGroup.selectAll("path")
196
+ .attr("stroke", color);
197
+
198
+ yAxisGroup.selectAll("text")
199
+ .attr("fill", "#000000") // Y轴文字改为黑色
200
+ .attr("font-size", "10px")
201
+ .attr("dx", "0.5em"); // 向右移动文本
202
+
203
+ // 添加水平辅助线
204
+ yScales[group].ticks(3).forEach(tick => {
205
+ if (tick > 0) { // 跳过0线,因为已经单独添加了
206
+ g.append("line")
207
+ .attr("x1", 0)
208
+ .attr("y1", groupPositions[group] + yScales[group](tick))
209
+ .attr("x2", chartWidth)
210
+ .attr("y2", groupPositions[group] + yScales[group](tick))
211
+ .attr("stroke", color)
212
+ .attr("stroke-opacity", 0.2)
213
+ .attr("stroke-width", 0.5)
214
+ .attr("stroke-dasharray", "2,2"); // 虚线
215
+ }
216
+ });
217
+ });
218
+
219
+ return svg.node();
220
+ }
modules/chart_engine/template/d3-js/area_chart/spline_area_chart_01.js ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Spline Area Chart",
5
+ "chart_name": "spline_area_chart_01",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["temporal"], ["numerical"]],
8
+ "required_fields_range": [[5, 30], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": ["primary"],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 400,
15
+ "min_width": 800,
16
+ "background": "dark",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "no",
21
+ "chart_for": "trend"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ function makeChart(containerSelector, data) {
27
+ // 提取数据
28
+ const jsonData = data;
29
+ const chartData = jsonData.data.data;
30
+ const variables = jsonData.variables;
31
+ const typography = jsonData.typography;
32
+ const colors = jsonData.colors_dark || {};
33
+ const colorResolver = chartUtils.color.resolver(jsonData);
34
+ const dataColumns = chartUtils.schema.columns(jsonData);
35
+ const images = jsonData.images || {};
36
+
37
+ // 清空容器
38
+ d3.select(containerSelector).html("");
39
+
40
+ // 获取字段名
41
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
42
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
43
+
44
+ // 设置尺寸和边距
45
+ const width = variables.width;
46
+ const height = variables.height;
47
+ const margin = { top: 120, right: 30, bottom: 120, left: 60 };
48
+ const chartWidth = width - margin.left - margin.right;
49
+ const chartHeight = height - margin.top - margin.bottom;
50
+
51
+ // 创建SVG
52
+ const svg = d3.select(containerSelector)
53
+ .append("svg")
54
+ .attr("width", "100%")
55
+ .attr("height", height)
56
+ .attr("viewBox", `0 0 ${width} ${height}`)
57
+ .attr("style", "max-width: 100%; height: auto;")
58
+ .attr("xmlns", "http://www.w3.org/2000/svg")
59
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
60
+
61
+ // 创建图表组
62
+ const g = svg.append("g")
63
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
64
+
65
+ // 确保数据按日期排序
66
+ chartData.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
67
+
68
+ // 创建x轴比例尺
69
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
70
+
71
+ // 创建y轴比例尺 - 从0开始
72
+ const yMax = d3.max(chartData, d => +d[yField]);
73
+ const yScale = d3.scaleLinear()
74
+ .domain([0, yMax * 1.4]) // 顶部留出10%的空间
75
+ .range([chartHeight, 0]);
76
+
77
+ // 获取主色调
78
+ const primaryColor = colorResolver.other("primary", { fallback: "#a67eb7" }).value;
79
+
80
+ // 创建渐变
81
+ const defs = svg.append("defs");
82
+ const gradientId = "area-gradient";
83
+ const gradient = defs.append("linearGradient")
84
+ .attr("id", gradientId)
85
+ .attr("x1", "0%")
86
+ .attr("y1", "0%")
87
+ .attr("x2", "0%")
88
+ .attr("y2", "100%");
89
+
90
+ gradient.append("stop")
91
+ .attr("offset", "0%")
92
+ .attr("stop-color", primaryColor)
93
+ .attr("stop-opacity", 0.7);
94
+
95
+ gradient.append("stop")
96
+ .attr("offset", "100%")
97
+ .attr("stop-color", primaryColor)
98
+ .attr("stop-opacity", 0.0);
99
+
100
+
101
+ // 创建面积生成器
102
+ const area = d3.area()
103
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
104
+ .y0(chartHeight)
105
+ .y1(d => yScale(+d[yField]))
106
+ .curve(d3.curveBasis);
107
+
108
+ // 绘制面积
109
+ g.append("path")
110
+ .datum(chartData)
111
+ .attr("fill", `url(#${gradientId})`)
112
+ .attr("d", area);
113
+
114
+ // 绘制线条
115
+ const line = d3.line()
116
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
117
+ .y(d => yScale(+d[yField]))
118
+ .curve(d3.curveBasis);
119
+
120
+ g.append("path")
121
+ .datum(chartData)
122
+ .attr("fill", "none")
123
+ .attr("stroke", primaryColor)
124
+ .attr("stroke-width", 3)
125
+ .attr("d", line);
126
+
127
+ const sampleLabelIndex = sampleLabels(chartData.length);
128
+
129
+ // 添加数据点和标签 - 优化标签位置,包括首尾点
130
+ chartData.forEach((d, i) => {
131
+ if (!sampleLabelIndex.includes(i)) {
132
+ return;
133
+ }
134
+
135
+ const x = xScale(chartUtils.format.parseDate(d[xField]));
136
+ const y = yScale(+d[yField]);
137
+
138
+ // 计算更好的标签位置,避免遮挡线条
139
+ let labelY = y;
140
+
141
+ // 根据点的位置计算标签位置
142
+ if (i === 0) {
143
+ // 第一个点 - 考虑后一个点
144
+ if (chartData.length > 1) {
145
+ const nextPoint = chartData[i+1];
146
+ const nextY = yScale(nextPoint[yField]);
147
+
148
+ // 计算向前20%的插值
149
+ const forwardY = y + (nextY - y) * 0.2;
150
+
151
+ // 取���个值中的最小值
152
+ labelY = Math.min(y, forwardY) - 4;
153
+ }
154
+ // 确保标签至少在数据点上方30像素
155
+ labelY = Math.min(labelY, y - 4);
156
+ } else if (i === chartData.length - 1) {
157
+ // 最后一个点 - 考虑前一个点
158
+ const prevPoint = chartData[i-1];
159
+ const prevY = yScale(prevPoint[yField]);
160
+
161
+ // 计算向后20%的插值
162
+ const backwardY = y + (prevY - y) * 0.2;
163
+
164
+ // 取两个值中的最小值
165
+ labelY = Math.min(y, backwardY) - 4;
166
+
167
+ // 确保标签至少在数据点上方30像素
168
+ labelY = Math.min(labelY, y - 4);
169
+ } else {
170
+ // 中间点 - 考虑前后两个点
171
+ const prevPoint = chartData[i-1];
172
+ const nextPoint = chartData[i+1];
173
+
174
+ // 计算前一个点的y值
175
+ const prevY = yScale(prevPoint[yField]);
176
+
177
+ // 计算后一个点的y值
178
+ const nextY = yScale(nextPoint[yField]);
179
+
180
+ // 计算向前20%的插值
181
+ const forwardY = y + (nextY - y) * 0.2;
182
+
183
+ // 计算向后20%的插值
184
+ const backwardY = y + (prevY - y) * 0.2;
185
+
186
+ // 取三个值中的最小值(在SVG中,较小的y值表示较高的位置)
187
+ labelY = Math.min(y, forwardY, backwardY) - 4;
188
+
189
+ // 确保标签至少在数据点上方30像素
190
+ labelY = Math.min(labelY, y - 4);
191
+ }
192
+
193
+ // 添加标签背景
194
+ const labelValue = chartUtils.format.autoText(+d[yField]);
195
+ const labelWidth = chartUtils.text.measure(null, labelValue, { fontSize: 14 }).width + 20;
196
+ const labelHeight = 25;
197
+
198
+ g.append("rect")
199
+ .attr("x", x - labelWidth / 2)
200
+ .attr("y", labelY - labelHeight - 15)
201
+ .attr("width", labelWidth)
202
+ .attr("height", labelHeight)
203
+ .attr("rx", 5)
204
+ .attr("ry", 5)
205
+ .attr("fill", primaryColor)
206
+ .attr("opacity", 0.9);
207
+
208
+ // 添加标签文本
209
+ g.append("text")
210
+ .attr("x", x)
211
+ .attr("y", labelY - 15 - labelHeight / 2)
212
+ .attr("text-anchor", "middle")
213
+ .attr("dominant-baseline", "middle")
214
+ .attr("fill", "#fff")
215
+ .attr("font-weight", "bold")
216
+ .style("font-size", "14px")
217
+ .text(labelValue);
218
+
219
+ // 添加倒三角形 - 连接到标签
220
+ const triangleSize = 8;
221
+ g.append("path")
222
+ .attr("d", `M${x-triangleSize/2},${labelY-15} L${x+triangleSize/2},${labelY-15} L${x},${labelY-15+triangleSize} Z`)
223
+ .attr("fill", primaryColor);
224
+
225
+ });
226
+
227
+ // 添加X轴刻度文本
228
+ xTicks.forEach(tick => {
229
+ const x = xScale(tick);
230
+
231
+ g.append("text")
232
+ .attr("x", x)
233
+ .attr("y", chartHeight + 25)
234
+ .attr("text-anchor", "middle")
235
+ .attr("fill", "#aaa")
236
+ .style("font-size", "14px")
237
+ .text(xFormat(tick));
238
+ });
239
+
240
+ return svg.node();
241
+ }
modules/chart_engine/template/d3-js/area_chart/spline_area_chart_02.js ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Spline Area Chart",
5
+ "chart_name": "spline_area_chart_02",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["temporal"], ["numerical"]],
8
+ "required_fields_range": [[5, 30], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": ["primary"],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 400,
15
+ "min_width": 800,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "no",
21
+ "chart_for": "trend"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ function makeChart(containerSelector, data) {
27
+ // 提取数据
28
+ const jsonData = data;
29
+ const chartData = jsonData.data.data;
30
+ const variables = jsonData.variables;
31
+ const typography = jsonData.typography;
32
+ const colors = jsonData.colors || {};
33
+ const colorResolver = chartUtils.color.resolver(jsonData);
34
+ const dataColumns = chartUtils.schema.columns(jsonData);
35
+ const images = jsonData.images || {};
36
+
37
+ // 清空容器
38
+ d3.select(containerSelector).html("");
39
+
40
+ // 获取字段名
41
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
42
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
43
+
44
+ // 设置尺寸和边距
45
+ const width = variables.width;
46
+ const height = variables.height;
47
+ const margin = { top: 120, right: 30, bottom: 120, left: 60 };
48
+ const chartWidth = width - margin.left - margin.right;
49
+ const chartHeight = height - margin.top - margin.bottom;
50
+
51
+ // 创建SVG
52
+ const svg = d3.select(containerSelector)
53
+ .append("svg")
54
+ .attr("width", "100%")
55
+ .attr("height", height)
56
+ .attr("viewBox", `0 0 ${width} ${height}`)
57
+ .attr("style", "max-width: 100%; height: auto;")
58
+ .attr("xmlns", "http://www.w3.org/2000/svg")
59
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
60
+
61
+ // 创建图表组
62
+ const g = svg.append("g")
63
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
64
+
65
+ // 确保数据按日期排序
66
+ chartData.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
67
+
68
+ // 创建x轴比例尺
69
+ const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
70
+
71
+ // 创建y轴比例尺 - 从0开始
72
+ const yMax = d3.max(chartData, d => +d[yField]);
73
+ const yScale = d3.scaleLinear()
74
+ .domain([0, yMax * 1.4]) // 顶部留出10%的空间
75
+ .range([chartHeight, 0]);
76
+
77
+ // 获取主色调
78
+ const primaryColor = colorResolver.other("primary", { fallback: "#a67eb7" }).value;
79
+
80
+ // 创建渐变
81
+ const defs = svg.append("defs");
82
+ const gradientId = "area-gradient";
83
+ const gradient = defs.append("linearGradient")
84
+ .attr("id", gradientId)
85
+ .attr("x1", "0%")
86
+ .attr("y1", "0%")
87
+ .attr("x2", "0%")
88
+ .attr("y2", "100%");
89
+
90
+ gradient.append("stop")
91
+ .attr("offset", "0%")
92
+ .attr("stop-color", primaryColor)
93
+ .attr("stop-opacity", 0.7);
94
+
95
+ gradient.append("stop")
96
+ .attr("offset", "100%")
97
+ .attr("stop-color", primaryColor)
98
+ .attr("stop-opacity", 0);
99
+
100
+
101
+ // 创建面积生成器
102
+ const area = d3.area()
103
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
104
+ .y0(chartHeight)
105
+ .y1(d => yScale(+d[yField]))
106
+ .curve(d3.curveBasis);
107
+
108
+ // 绘制面积
109
+ g.append("path")
110
+ .datum(chartData)
111
+ .attr("fill", `url(#${gradientId})`)
112
+ .attr("d", area);
113
+
114
+ // 绘制线条
115
+ const line = d3.line()
116
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
117
+ .y(d => yScale(+d[yField]))
118
+ .curve(d3.curveBasis);
119
+
120
+ g.append("path")
121
+ .datum(chartData)
122
+ .attr("fill", "none")
123
+ .attr("stroke", primaryColor)
124
+ .attr("stroke-width", 3)
125
+ .attr("d", line);
126
+
127
+ const sampleLabelIndex = sampleLabels(chartData.length);
128
+
129
+ // 添加数据点和标签 - 优化标签位置,包括首尾点
130
+ chartData.forEach((d, i) => {
131
+ if (!sampleLabelIndex.includes(i)) {
132
+ return;
133
+ }
134
+
135
+ const x = xScale(chartUtils.format.parseDate(d[xField]));
136
+ const y = yScale(+d[yField]);
137
+
138
+ // 计算更好的标签位置,避免遮挡线条
139
+ let labelY = y;
140
+
141
+ // 根据点的位置计算标签位置
142
+ if (i === 0) {
143
+ // 第一个点 - 考虑后一个点
144
+ if (chartData.length > 1) {
145
+ const nextPoint = chartData[i+1];
146
+ const nextY = yScale(nextPoint[yField]);
147
+
148
+ // 计算向前20%的插值
149
+ const forwardY = y + (nextY - y) * 0.2;
150
+
151
+ // 取两个���中的最小值
152
+ labelY = Math.min(y, forwardY) - 4;
153
+ }
154
+ // 确保标签至少在数据点上方30像素
155
+ labelY = Math.min(labelY, y - 4);
156
+ } else if (i === chartData.length - 1) {
157
+ // 最后一个点 - 考虑前一个点
158
+ const prevPoint = chartData[i-1];
159
+ const prevY = yScale(prevPoint[yField]);
160
+
161
+ // 计算向后20%的插值
162
+ const backwardY = y + (prevY - y) * 0.2;
163
+
164
+ // 取两个值中的最小值
165
+ labelY = Math.min(y, backwardY) - 4;
166
+
167
+ // 确保标签至少在数据点上方30像素
168
+ labelY = Math.min(labelY, y - 4);
169
+ } else {
170
+ // 中间点 - 考虑前后两个点
171
+ const prevPoint = chartData[i-1];
172
+ const nextPoint = chartData[i+1];
173
+
174
+ // 计算前一个点的y值
175
+ const prevY = yScale(prevPoint[yField]);
176
+
177
+ // 计算后一个点的y值
178
+ const nextY = yScale(nextPoint[yField]);
179
+
180
+ // 计算向前20%的插值
181
+ const forwardY = y + (nextY - y) * 0.2;
182
+
183
+ // 计算向后20%的插值
184
+ const backwardY = y + (prevY - y) * 0.2;
185
+
186
+ // 取三个值中的最小值(在SVG中,较小的y值表示较高的位置)
187
+ labelY = Math.min(y, forwardY, backwardY) - 4;
188
+
189
+ // 确保标签至少在数据点上方30像素
190
+ labelY = Math.min(labelY, y - 4);
191
+ }
192
+
193
+ // 添加标签背景
194
+ const labelValue = chartUtils.format.integer(d[yField]).text;
195
+ const labelWidth = chartUtils.text.measure(null, labelValue, { fontSize: 14 }).width + 20;
196
+ const labelHeight = 25;
197
+
198
+ g.append("rect")
199
+ .attr("x", x - labelWidth / 2)
200
+ .attr("y", labelY - labelHeight - 15)
201
+ .attr("width", labelWidth)
202
+ .attr("height", labelHeight)
203
+ .attr("rx", 5)
204
+ .attr("ry", 5)
205
+ .attr("fill", primaryColor)
206
+ .attr("opacity", 0.9);
207
+
208
+ // 添加标签文本
209
+ g.append("text")
210
+ .attr("x", x)
211
+ .attr("y", labelY - 15 - labelHeight / 2)
212
+ .attr("text-anchor", "middle")
213
+ .attr("dominant-baseline", "middle")
214
+ .attr("fill", "#fff")
215
+ .attr("font-weight", "bold")
216
+ .style("font-size", "14px")
217
+ .text(labelValue);
218
+
219
+ // 添加倒三角形 - 连接到标签
220
+ const triangleSize = 8;
221
+ g.append("path")
222
+ .attr("d", `M${x-triangleSize/2},${labelY-15} L${x+triangleSize/2},${labelY-15} L${x},${labelY-15+triangleSize} Z`)
223
+ .attr("fill", primaryColor);
224
+
225
+ });
226
+
227
+ // 添加X轴刻度文本
228
+ xTicks.forEach(tick => {
229
+ const x = xScale(tick);
230
+
231
+ g.append("text")
232
+ .attr("x", x)
233
+ .attr("y", chartHeight + 25)
234
+ .attr("text-anchor", "middle")
235
+ .attr("fill", colorResolver.text({ fallback: "#333333" }).value)
236
+ .style("font-size", "14px")
237
+ .text(xFormat(tick));
238
+ });
239
+
240
+ return svg.node();
241
+ }
modules/chart_engine/template/d3-js/area_chart/spline_layered_area_chart_01.js ADDED
@@ -0,0 +1,595 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Layered Area Chart",
5
+ "chart_name": "spline_layered_area_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 50], [0, "inf"], [2, 6]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "dark",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ const jsonData = data;
27
+ const chartData = jsonData.data.data;
28
+ const variables = jsonData.variables;
29
+ const colorResolver = chartUtils.color.resolver(jsonData);
30
+ const dataColumns = chartUtils.schema.columns(jsonData);
31
+ const fontSize = chartUtils.text.fontSize;
32
+
33
+ d3.select(containerSelector).html("");
34
+
35
+ const columnName = column => column && (column.field || column.name || column.key);
36
+ let xField = chartUtils.schema.columnField(dataColumns, 0);
37
+ let yField = chartUtils.schema.columnField(dataColumns, 1);
38
+ let groupField = chartUtils.schema.columnField(dataColumns, 2);
39
+ if (!chartData.length || chartData[0][xField] === undefined) xField = columnName(dataColumns[0]);
40
+ if (!chartData.length || chartData[0][yField] === undefined) yField = columnName(dataColumns[1]);
41
+ if (!chartData.length || chartData[0][groupField] === undefined) groupField = columnName(dataColumns[2]);
42
+
43
+ const width = Math.max(620, +(variables.width || 650));
44
+ const height = Math.max(900, +(variables.height || 975));
45
+ const title = (jsonData.titles && jsonData.titles.main_title)
46
+ || (jsonData.metadata && jsonData.metadata.title)
47
+ || "";
48
+ const subtitle = (jsonData.titles && jsonData.titles.sub_title)
49
+ || (jsonData.metadata && (jsonData.metadata.description || jsonData.metadata.main_insight))
50
+ || "";
51
+
52
+ const svg = d3.select(containerSelector)
53
+ .append("svg")
54
+ .attr("width", "100%")
55
+ .attr("height", height)
56
+ .attr("viewBox", `0 0 ${width} ${height}`)
57
+ .attr("style", "max-width: 100%; height: auto;")
58
+ .attr("xmlns", "http://www.w3.org/2000/svg")
59
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
60
+
61
+ const defs = svg.append("defs");
62
+ const bg = defs.append("radialGradient")
63
+ .attr("id", "spline-layered-bg")
64
+ .attr("cx", "54%")
65
+ .attr("cy", "32%")
66
+ .attr("r", "80%");
67
+ bg.append("stop").attr("offset", "0%").attr("stop-color", "#082748");
68
+ bg.append("stop").attr("offset", "58%").attr("stop-color", "#041A31");
69
+ bg.append("stop").attr("offset", "100%").attr("stop-color", "#020B17");
70
+
71
+ const glowFilter = defs.append("filter")
72
+ .attr("id", "spline-layered-glow")
73
+ .attr("x", "-60%")
74
+ .attr("y", "-60%")
75
+ .attr("width", "220%")
76
+ .attr("height", "220%");
77
+ glowFilter.append("feGaussianBlur")
78
+ .attr("stdDeviation", 8)
79
+ .attr("result", "blur");
80
+ const glowMerge = glowFilter.append("feMerge");
81
+ glowMerge.append("feMergeNode").attr("in", "blur");
82
+ glowMerge.append("feMergeNode").attr("in", "SourceGraphic");
83
+
84
+ const shadowFilter = defs.append("filter")
85
+ .attr("id", "spline-layered-card-shadow")
86
+ .attr("x", "-40%")
87
+ .attr("y", "-40%")
88
+ .attr("width", "180%")
89
+ .attr("height", "180%");
90
+ shadowFilter.append("feDropShadow")
91
+ .attr("dx", 0)
92
+ .attr("dy", 10)
93
+ .attr("stdDeviation", 10)
94
+ .attr("flood-color", "#000000")
95
+ .attr("flood-opacity", 0.28);
96
+
97
+ svg.append("rect")
98
+ .attr("width", width)
99
+ .attr("height", height)
100
+ .attr("fill", "url(#spline-layered-bg)");
101
+ svg.append("circle")
102
+ .attr("cx", width * 0.82)
103
+ .attr("cy", height * 0.15)
104
+ .attr("r", width * 0.30)
105
+ .attr("fill", "#154D85")
106
+ .attr("opacity", 0.08);
107
+
108
+ const headerSlot = {
109
+ x: width * 0.055,
110
+ y: height * 0.045,
111
+ size: Math.min(150, width * 0.22)
112
+ };
113
+ const slot = svg.append("g")
114
+ .attr("class", "reserved-asset-slot spline-layered-header-topic-icon-slot")
115
+ .attr("data-asset-slot", "header_topic_icon_slot")
116
+ .attr("data-reserved-slot", "header_topic_icon_slot")
117
+ .attr("data-slot-policy", "image2_asset_or_input_provided")
118
+ .attr("data-asset-source-policy", "image2_asset_or_input_provided")
119
+ .attr("data-anchor", "upper-left-header")
120
+ .attr("data-collision-rule", "avoid title subtitle metric pill and chart panel")
121
+ .attr("data-bbox", `${headerSlot.x.toFixed(1)} ${headerSlot.y.toFixed(1)} ${headerSlot.size.toFixed(1)} ${headerSlot.size.toFixed(1)}`)
122
+ .attr("transform", `translate(${headerSlot.x},${headerSlot.y})`);
123
+ slot.append("circle")
124
+ .attr("cx", headerSlot.size / 2)
125
+ .attr("cy", headerSlot.size / 2)
126
+ .attr("r", headerSlot.size * 0.47)
127
+ .attr("fill", "#061B36")
128
+ .attr("stroke", "#3E8CFF")
129
+ .attr("stroke-width", 3)
130
+ .attr("opacity", 0.95)
131
+ .style("filter", "url(#spline-layered-glow)");
132
+ slot.append("circle")
133
+ .attr("cx", headerSlot.size / 2)
134
+ .attr("cy", headerSlot.size / 2)
135
+ .attr("r", headerSlot.size * 0.34)
136
+ .attr("fill", "none")
137
+ .attr("stroke", "#9DC8FF")
138
+ .attr("stroke-width", 2)
139
+ .attr("stroke-dasharray", "6 8")
140
+ .attr("opacity", 0.58);
141
+ slot.append("path")
142
+ .attr("class", "neutral-asset-placeholder")
143
+ .attr("d", `M${headerSlot.size * 0.31},${headerSlot.size * 0.60} L${headerSlot.size * 0.43},${headerSlot.size * 0.49} L${headerSlot.size * 0.52},${headerSlot.size * 0.56} L${headerSlot.size * 0.69},${headerSlot.size * 0.39}`)
144
+ .attr("fill", "none")
145
+ .attr("stroke", "#BFD9FF")
146
+ .attr("stroke-width", 5)
147
+ .attr("stroke-linecap", "round")
148
+ .attr("stroke-linejoin", "round")
149
+ .attr("opacity", 0.72);
150
+
151
+ function wrapText(text, maxChars, maxLines) {
152
+ const words = String(text || "").replace(/^\"|\"$/g, "").split(/\s+/).filter(Boolean);
153
+ const lines = [];
154
+ let line = "";
155
+ words.forEach(word => {
156
+ const test = line ? `${line} ${word}` : word;
157
+ if (test.length > maxChars && line) {
158
+ lines.push(line);
159
+ line = word;
160
+ } else {
161
+ line = test;
162
+ }
163
+ });
164
+ if (line) lines.push(line);
165
+ if (lines.length > maxLines) {
166
+ const kept = lines.slice(0, maxLines);
167
+ const overflowWords = lines.slice(maxLines).join(" ").split(/\s+/).filter(Boolean);
168
+ while (overflowWords.length && `${kept[maxLines - 1]} ${overflowWords[0]}`.length <= maxChars) {
169
+ kept[maxLines - 1] = `${kept[maxLines - 1]} ${overflowWords.shift()}`;
170
+ }
171
+ return kept;
172
+ }
173
+ return lines.length ? lines : [""];
174
+ }
175
+
176
+ const titleX = width * 0.29;
177
+ const titleSafeWidth = width - titleX - 30;
178
+ const titleLines = wrapText(title, width < 660 ? 24 : 26, 3);
179
+ const titleSize = titleLines.length > 2 ? 33 : 39;
180
+ const titleGroup = svg.append("g")
181
+ .attr("class", "spline-layered-title-stack")
182
+ .attr("data-reserved-slot", "header-title-typography")
183
+ .attr("data-asset-source-policy", "input-text")
184
+ .attr("data-anchor", "top-right-header")
185
+ .attr("data-collision-rule", "stay right of header topic slot and above metric pill");
186
+ titleLines.forEach((line, i) => {
187
+ const isLast = i === titleLines.length - 1;
188
+ titleGroup.append("text")
189
+ .attr("x", titleX)
190
+ .attr("y", height * 0.075 + i * titleSize * 1.08)
191
+ .attr("font-family", "Georgia, 'Times New Roman', serif")
192
+ .attr("font-size", titleSize)
193
+ .attr("font-weight", 700)
194
+ .attr("fill", isLast ? "#74AFFF" : "#FFFFFF")
195
+ .attr("letter-spacing", 0)
196
+ .attr("textLength", Math.min(titleSafeWidth, Math.max(0, line.length * titleSize * 0.56)) || null)
197
+ .attr("lengthAdjust", "spacingAndGlyphs")
198
+ .text(line);
199
+ });
200
+
201
+ wrapText(subtitle, 45, 3).forEach((line, i) => {
202
+ titleGroup.append("text")
203
+ .attr("x", titleX)
204
+ .attr("y", height * 0.20 + i * 24)
205
+ .attr("font-family", "Arial, Helvetica, sans-serif")
206
+ .attr("font-size", 18)
207
+ .attr("font-weight", 400)
208
+ .attr("fill", "#C9D6E8")
209
+ .attr("opacity", 0.94)
210
+ .attr("textLength", Math.min(titleSafeWidth, Math.max(0, line.length * 18 * 0.54)) || null)
211
+ .attr("lengthAdjust", "spacingAndGlyphs")
212
+ .text(line);
213
+ });
214
+
215
+ const plot = {
216
+ x: width * 0.085,
217
+ y: height * 0.365,
218
+ width: width * 0.81,
219
+ height: height * 0.43
220
+ };
221
+ const metricW = width * 0.58;
222
+ const metricX = plot.x + (plot.width - metricW) / 2;
223
+ const metricY = plot.y - 58;
224
+ const yColumn = dataColumns.find(c => chartUtils.schema.columnField([c], 0) === yField) || dataColumns[1] || {};
225
+ const yLabel = yColumn.description || yField;
226
+ svg.append("rect")
227
+ .attr("class", "spline-layered-metric-pill")
228
+ .attr("x", metricX)
229
+ .attr("y", metricY)
230
+ .attr("width", metricW)
231
+ .attr("height", 40)
232
+ .attr("rx", 20)
233
+ .attr("fill", "#08264B")
234
+ .attr("stroke", "#1D68B7")
235
+ .attr("stroke-width", 1.4)
236
+ .attr("opacity", 0.94);
237
+ svg.append("text")
238
+ .attr("x", metricX + metricW / 2)
239
+ .attr("y", metricY + 18)
240
+ .attr("text-anchor", "middle")
241
+ .attr("font-family", "Arial, Helvetica, sans-serif")
242
+ .attr("font-size", 13)
243
+ .attr("font-weight", 800)
244
+ .attr("letter-spacing", 1.5)
245
+ .attr("fill", "#DDEBFF")
246
+ .attr("textLength", metricW - 34)
247
+ .attr("lengthAdjust", "spacingAndGlyphs")
248
+ .text(String(yLabel).toUpperCase().slice(0, 58));
249
+ svg.append("text")
250
+ .attr("x", metricX + metricW / 2)
251
+ .attr("y", metricY + 33)
252
+ .attr("text-anchor", "middle")
253
+ .attr("font-family", "Arial, Helvetica, sans-serif")
254
+ .attr("font-size", 12)
255
+ .attr("fill", "#B8C9DE")
256
+ .text(`(${yField})`);
257
+
258
+ const parseX = value => {
259
+ const parsed = chartUtils.format.parseDate(value);
260
+ if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed;
261
+ const numeric = +value;
262
+ if (Number.isFinite(numeric)) return new Date(numeric, 0, 1);
263
+ return new Date(String(value));
264
+ };
265
+ const xValues = Array.from(new Set(chartData.map(d => d[xField])))
266
+ .map(value => ({ value, date: parseX(value) }))
267
+ .sort((a, b) => a.date - b.date);
268
+ const xValueByKey = new Map(xValues.map(d => [String(d.value), d]));
269
+ const xDomain = d3.extent(xValues, d => d.date);
270
+ const xScale = d3.scaleTime()
271
+ .domain(xDomain)
272
+ .range([plot.x, plot.x + plot.width]);
273
+
274
+ const grouped = d3.group(chartData, d => d[groupField]);
275
+ const groups = Array.from(grouped.keys()).map((group, idx) => {
276
+ const values = grouped.get(group)
277
+ .map(d => ({ ...d, __date: parseX(d[xField]), __y: +d[yField] }))
278
+ .sort((a, b) => a.__date - b.__date);
279
+ const latest = values[values.length - 1] || {};
280
+ return { group, idx, values, latestValue: +latest.__y || 0, latest };
281
+ }).sort((a, b) => b.latestValue - a.latestValue);
282
+
283
+ const maxY = d3.max(chartData, d => +d[yField]) || 1;
284
+ const yMax = Math.max(1, Math.ceil(maxY * 1.12 / 5) * 5);
285
+ const yScale = d3.scaleLinear()
286
+ .domain([0, yMax])
287
+ .range([plot.y + plot.height, plot.y]);
288
+
289
+ const palette = ["#3F8CFF", "#F25A9D", "#2FD0C6", "#FFC94A", "#8A7CFF", "#54E6A0"];
290
+ const colorFor = (group, idx) => {
291
+ if (idx < 2) return palette[idx];
292
+ return colorResolver.field(group, idx, { fallbackKey: "primary" }).value || palette[idx % palette.length];
293
+ };
294
+ const softColorFor = (group, idx) => {
295
+ const c = d3.color(colorFor(group, idx));
296
+ return c ? c.formatHex() : palette[idx % palette.length];
297
+ };
298
+
299
+ groups.forEach((series, idx) => {
300
+ const gradientId = `spline-layered-area-gradient-${idx}`;
301
+ const gradient = defs.append("linearGradient")
302
+ .attr("id", gradientId)
303
+ .attr("x1", "0%")
304
+ .attr("y1", "0%")
305
+ .attr("x2", "0%")
306
+ .attr("y2", "100%");
307
+ gradient.append("stop")
308
+ .attr("offset", "0%")
309
+ .attr("stop-color", softColorFor(series.group, idx))
310
+ .attr("stop-opacity", 0.88);
311
+ gradient.append("stop")
312
+ .attr("offset", "100%")
313
+ .attr("stop-color", softColorFor(series.group, idx))
314
+ .attr("stop-opacity", 0.20);
315
+ });
316
+
317
+ const yTicks = d3.ticks(0, yMax, 6);
318
+ yTicks.forEach(tick => {
319
+ const y = yScale(tick);
320
+ svg.append("line")
321
+ .attr("x1", plot.x)
322
+ .attr("x2", plot.x + plot.width)
323
+ .attr("y1", y)
324
+ .attr("y2", y)
325
+ .attr("stroke", "#55718F")
326
+ .attr("stroke-width", 1)
327
+ .attr("stroke-dasharray", tick === 0 ? null : "3 5")
328
+ .attr("opacity", tick === 0 ? 0.80 : 0.28);
329
+ svg.append("text")
330
+ .attr("x", plot.x - 16)
331
+ .attr("y", y + 5)
332
+ .attr("text-anchor", "end")
333
+ .attr("font-family", "Arial, Helvetica, sans-serif")
334
+ .attr("font-size", 17)
335
+ .attr("font-weight", tick === 0 ? 700 : 500)
336
+ .attr("fill", "#B2C2D8")
337
+ .attr("opacity", tick === 0 ? 0.94 : 0.86)
338
+ .text(tick);
339
+ });
340
+
341
+ const preferredTickIndexes = new Set();
342
+ const targetTickCount = xValues.length > 8 ? 6 : xValues.length;
343
+ const tickStep = Math.max(1, Math.ceil(xValues.length / targetTickCount));
344
+ xValues.forEach((_, i) => {
345
+ if (i % tickStep === 0) preferredTickIndexes.add(i);
346
+ });
347
+ preferredTickIndexes.add(0);
348
+ preferredTickIndexes.add(xValues.length - 1);
349
+ const xTickLabelIndexes = [];
350
+ const minTickLabelGap = 58;
351
+ Array.from(preferredTickIndexes).sort((a, b) => a - b).forEach(i => {
352
+ const x = xScale(xValues[i].date);
353
+ const previousIndex = xTickLabelIndexes[xTickLabelIndexes.length - 1];
354
+ if (previousIndex === undefined) {
355
+ xTickLabelIndexes.push(i);
356
+ return;
357
+ }
358
+ const previousX = xScale(xValues[previousIndex].date);
359
+ if (x - previousX >= minTickLabelGap) {
360
+ xTickLabelIndexes.push(i);
361
+ return;
362
+ }
363
+ if (i === xValues.length - 1 && previousIndex !== 0) {
364
+ xTickLabelIndexes[xTickLabelIndexes.length - 1] = i;
365
+ }
366
+ });
367
+ const xTickLabelIndexSet = new Set(xTickLabelIndexes);
368
+
369
+ xValues.forEach((tick, i) => {
370
+ const x = xScale(tick.date);
371
+ svg.append("line")
372
+ .attr("x1", x)
373
+ .attr("x2", x)
374
+ .attr("y1", plot.y)
375
+ .attr("y2", plot.y + plot.height)
376
+ .attr("stroke", "#55718F")
377
+ .attr("stroke-width", 1)
378
+ .attr("stroke-dasharray", "4 5")
379
+ .attr("opacity", 0.24);
380
+ if (xTickLabelIndexSet.has(i)) {
381
+ svg.append("text")
382
+ .attr("x", x)
383
+ .attr("y", plot.y + plot.height + 31)
384
+ .attr("text-anchor", "middle")
385
+ .attr("font-family", "Arial, Helvetica, sans-serif")
386
+ .attr("font-size", 18)
387
+ .attr("font-weight", 800)
388
+ .attr("fill", i === xValues.length - 1 ? "#54A2FF" : "#C7D3E5")
389
+ .text(tick.date instanceof Date && !Number.isNaN(tick.date.getTime()) && /^\d{4}$/.test(String(tick.value))
390
+ ? String(tick.value)
391
+ : String(tick.value == null ? "" : tick.value));
392
+ }
393
+ });
394
+
395
+ svg.append("line")
396
+ .attr("x1", plot.x)
397
+ .attr("y1", plot.y)
398
+ .attr("x2", plot.x)
399
+ .attr("y2", plot.y + plot.height)
400
+ .attr("stroke", "#D9E7F9")
401
+ .attr("stroke-width", 1.2)
402
+ .attr("opacity", 0.72);
403
+ svg.append("line")
404
+ .attr("x1", plot.x)
405
+ .attr("y1", plot.y + plot.height)
406
+ .attr("x2", plot.x + plot.width)
407
+ .attr("y2", plot.y + plot.height)
408
+ .attr("stroke", "#D9E7F9")
409
+ .attr("stroke-width", 1.2)
410
+ .attr("opacity", 0.72);
411
+
412
+ const area = d3.area()
413
+ .x(d => xScale(d.__date))
414
+ .y0(plot.y + plot.height)
415
+ .y1(d => yScale(d.__y))
416
+ .curve(d3.curveMonotoneX);
417
+
418
+ groups.forEach((series, idx) => {
419
+ svg.append("path")
420
+ .datum(series.values)
421
+ .attr("class", "spline-layered-area-mark")
422
+ .attr("data-series", series.group)
423
+ .attr("fill", `url(#spline-layered-area-gradient-${idx})`)
424
+ .attr("stroke", colorFor(series.group, idx))
425
+ .attr("stroke-width", 2.4)
426
+ .attr("stroke-linejoin", "round")
427
+ .attr("opacity", idx === 0 ? 0.92 : 0.86)
428
+ .attr("d", area)
429
+ .style("filter", "url(#spline-layered-card-shadow)");
430
+ });
431
+
432
+ const visibleSeries = groups.slice(0, Math.min(2, groups.length));
433
+ visibleSeries.forEach((series, idx) => {
434
+ const color = colorFor(series.group, idx);
435
+ series.values.forEach((d, pointIdx) => {
436
+ const x = xScale(d.__date);
437
+ const y = yScale(d.__y);
438
+ svg.append("circle")
439
+ .attr("class", "spline-layered-point-marker")
440
+ .attr("data-series", series.group)
441
+ .attr("data-x", d[xField])
442
+ .attr("data-y", d[yField])
443
+ .attr("cx", x)
444
+ .attr("cy", y)
445
+ .attr("r", 5.6)
446
+ .attr("fill", color)
447
+ .attr("stroke", "#EAF4FF")
448
+ .attr("stroke-width", 2.2)
449
+ .style("filter", "url(#spline-layered-glow)");
450
+ const showEvery = series.values.length > 8 ? Math.ceil(series.values.length / 5) : 1;
451
+ if (pointIdx % showEvery === 0 || pointIdx === series.values.length - 1) {
452
+ svg.append("text")
453
+ .attr("x", x)
454
+ .attr("y", y - 14 - idx * 2)
455
+ .attr("text-anchor", "middle")
456
+ .attr("font-family", "Arial, Helvetica, sans-serif")
457
+ .attr("font-size", 16)
458
+ .attr("font-weight", 900)
459
+ .attr("fill", color)
460
+ .text(chartUtils.format.integer(d.__y).text);
461
+ }
462
+ });
463
+ });
464
+
465
+ const lastX = xScale(xValues[xValues.length - 1].date);
466
+ svg.append("line")
467
+ .attr("x1", lastX)
468
+ .attr("x2", lastX)
469
+ .attr("y1", plot.y)
470
+ .attr("y2", plot.y + plot.height)
471
+ .attr("stroke", "#D6E8FF")
472
+ .attr("stroke-width", 1.2)
473
+ .attr("stroke-dasharray", "4 5")
474
+ .attr("opacity", 0.72);
475
+
476
+ const placedCallouts = [];
477
+ visibleSeries.forEach((series, idx) => {
478
+ const d = series.latest;
479
+ const color = colorFor(series.group, idx);
480
+ const pointX = xScale(d.__date);
481
+ const pointY = yScale(d.__y);
482
+ const labelText = String(series.group).toUpperCase();
483
+ const boxW = Math.min(142, Math.max(92, 48 + labelText.length * 4.2));
484
+ const boxH = 58;
485
+ let boxX = Math.min(width - boxW - 22, pointX + 18);
486
+ let boxY = pointY - (idx === 0 ? 57 : -22);
487
+ placedCallouts.forEach(prev => {
488
+ if (Math.abs((boxY + boxH / 2) - (prev.y + prev.h / 2)) < boxH + 8) {
489
+ boxY = prev.y + prev.h + 12;
490
+ }
491
+ });
492
+ boxY = Math.max(plot.y + 8, Math.min(plot.y + plot.height - boxH - 8, boxY));
493
+ placedCallouts.push({ x: boxX, y: boxY, w: boxW, h: boxH });
494
+
495
+ svg.append("path")
496
+ .attr("class", "spline-layered-endpoint-leader")
497
+ .attr("data-series", series.group)
498
+ .attr("d", `M${pointX},${pointY} L${boxX},${boxY + boxH * 0.50} L${boxX},${boxY + boxH * 0.68} Z`)
499
+ .attr("fill", color)
500
+ .attr("opacity", 0.92);
501
+ svg.append("rect")
502
+ .attr("class", "spline-layered-endpoint-callout")
503
+ .attr("data-series", series.group)
504
+ .attr("x", boxX)
505
+ .attr("y", boxY)
506
+ .attr("width", boxW)
507
+ .attr("height", boxH)
508
+ .attr("rx", 10)
509
+ .attr("fill", color)
510
+ .attr("stroke", "#BBD8FF")
511
+ .attr("stroke-width", idx === 0 ? 1.6 : 1.0)
512
+ .style("filter", "url(#spline-layered-card-shadow)");
513
+ const labelFontSize = Math.min(12, Math.max(8, (boxW - 16) / Math.max(1, labelText.length) * 1.55));
514
+ const shouldFitLabel = labelText.length * labelFontSize * 0.62 > boxW - 16;
515
+ const label = svg.append("text")
516
+ .attr("x", boxX + boxW / 2)
517
+ .attr("y", boxY + 18)
518
+ .attr("text-anchor", "middle")
519
+ .attr("font-family", "Arial, Helvetica, sans-serif")
520
+ .attr("font-size", labelFontSize)
521
+ .attr("font-weight", 800)
522
+ .attr("fill", "#FFFFFF")
523
+ .attr("opacity", 0.92);
524
+ if (shouldFitLabel) {
525
+ label.attr("textLength", boxW - 16)
526
+ .attr("lengthAdjust", "spacingAndGlyphs");
527
+ }
528
+ label.text(labelText);
529
+ svg.append("text")
530
+ .attr("x", boxX + boxW / 2)
531
+ .attr("y", boxY + 48)
532
+ .attr("text-anchor", "middle")
533
+ .attr("font-family", "Arial, Helvetica, sans-serif")
534
+ .attr("font-size", 29)
535
+ .attr("font-weight", 900)
536
+ .attr("fill", "#FFFFFF")
537
+ .text(chartUtils.format.integer(d.__y).text);
538
+ });
539
+
540
+ if (visibleSeries.length >= 2 && visibleSeries[1].latestValue > 0) {
541
+ const gain = Math.round(((visibleSeries[0].latestValue - visibleSeries[1].latestValue) / visibleSeries[1].latestValue) * 100);
542
+ const topY = yScale(visibleSeries[0].latestValue);
543
+ const lowY = yScale(visibleSeries[1].latestValue);
544
+ const gainR = 58;
545
+ const candidates = [
546
+ { x: plot.x + plot.width * 0.88, y: (topY + lowY) / 2 },
547
+ { x: plot.x + plot.width * 0.78, y: plot.y + plot.height * 0.48 },
548
+ { x: plot.x + plot.width * 0.72, y: plot.y + plot.height * 0.32 }
549
+ ];
550
+ const collides = c => placedCallouts.some(b =>
551
+ c.x + gainR > b.x && c.x - gainR < b.x + b.w && c.y + gainR > b.y && c.y - gainR < b.y + b.h
552
+ );
553
+ const chosen = candidates.find(c => !collides(c)) || candidates[candidates.length - 1];
554
+ svg.append("g")
555
+ .attr("class", "spline-layered-gain-callout")
556
+ .attr("data-annotation", "latest-relative-gain")
557
+ .attr("data-anchor", "latest-series-gap")
558
+ .attr("data-collision-rule", "avoid endpoint callouts markers axes metric pill and plot edge")
559
+ .attr("transform", `translate(${chosen.x},${chosen.y})`)
560
+ .call(gainG => {
561
+ gainG.append("circle")
562
+ .attr("r", gainR)
563
+ .attr("fill", "#082349")
564
+ .attr("stroke", "#3F8CFF")
565
+ .attr("stroke-width", 3)
566
+ .style("filter", "url(#spline-layered-glow)");
567
+ gainG.append("circle")
568
+ .attr("r", gainR - 10)
569
+ .attr("fill", "none")
570
+ .attr("stroke", "#75B4FF")
571
+ .attr("stroke-width", 1.3)
572
+ .attr("opacity", 0.65);
573
+ gainG.append("text")
574
+ .attr("x", 0)
575
+ .attr("y", 2)
576
+ .attr("text-anchor", "middle")
577
+ .attr("font-family", "Arial, Helvetica, sans-serif")
578
+ .attr("font-size", String(gain).length > 3 ? 28 : 37)
579
+ .attr("font-weight", 900)
580
+ .attr("fill", "#FFFFFF")
581
+ .text(`${gain >= 0 ? "+" : ""}${gain}%`);
582
+ gainG.append("text")
583
+ .attr("x", 0)
584
+ .attr("y", 28)
585
+ .attr("text-anchor", "middle")
586
+ .attr("font-family", "Arial, Helvetica, sans-serif")
587
+ .attr("font-size", 11)
588
+ .attr("font-weight", 700)
589
+ .attr("fill", "#DDEBFF")
590
+ .text("LATEST GAP");
591
+ });
592
+ }
593
+
594
+ return svg.node();
595
+ }
modules/chart_engine/template/d3-js/area_chart/spline_multiple_area_chart_01.js ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Multiple Area Chart",
5
+ "chart_name": "spline_multiple_area_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 50], [0, "inf"], [2, 2]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "dark",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ const jsonData = data;
27
+ const chartData = jsonData.data.data;
28
+ const variables = jsonData.variables;
29
+ const colors = jsonData.colors_dark || {};
30
+ const colorResolver = chartUtils.color.resolver(jsonData);
31
+ const dataColumns = chartUtils.schema.columns(jsonData);
32
+
33
+ d3.select(containerSelector).html("");
34
+
35
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
36
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
37
+ const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
38
+ const yLabel = yColumn.rawLabel || yField;
39
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
40
+
41
+ let groups = [...new Set(chartData.map(d => d[groupField]))].slice(0, 2);
42
+ if (groups.length < 2) {
43
+ groups = groups.concat(["Series B"]).slice(0, 2);
44
+ }
45
+
46
+ const width = +variables.width || 800;
47
+ const inputHeight = +variables.height || 600;
48
+ const height = Math.max(inputHeight, Math.round(width * 1.15));
49
+ const uid = `spline-area-${Math.floor(Math.random() * 1e9)}`;
50
+
51
+ const svg = d3.select(containerSelector)
52
+ .append("svg")
53
+ .attr("width", "100%")
54
+ .attr("height", height)
55
+ .attr("viewBox", `0 0 ${width} ${height}`)
56
+ .attr("style", "max-width: 100%; height: auto;")
57
+ .attr("xmlns", "http://www.w3.org/2000/svg")
58
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
59
+
60
+ const defs = svg.append("defs");
61
+ defs.append("linearGradient")
62
+ .attr("id", `${uid}-panel`)
63
+ .attr("x1", "0%")
64
+ .attr("y1", "0%")
65
+ .attr("x2", "100%")
66
+ .attr("y2", "100%")
67
+ .call(grad => {
68
+ grad.append("stop").attr("offset", "0%").attr("stop-color", "#052742");
69
+ grad.append("stop").attr("offset", "56%").attr("stop-color", "#062033");
70
+ grad.append("stop").attr("offset", "100%").attr("stop-color", "#031524");
71
+ });
72
+
73
+ defs.append("radialGradient")
74
+ .attr("id", `${uid}-light`)
75
+ .attr("cx", "82%")
76
+ .attr("cy", "0%")
77
+ .attr("r", "80%")
78
+ .call(grad => {
79
+ grad.append("stop").attr("offset", "0%").attr("stop-color", "#2ed1d0").attr("stop-opacity", 0.28);
80
+ grad.append("stop").attr("offset", "46%").attr("stop-color", "#0b4264").attr("stop-opacity", 0.16);
81
+ grad.append("stop").attr("offset", "100%").attr("stop-color", "#00101f").attr("stop-opacity", 0);
82
+ });
83
+
84
+ defs.append("radialGradient")
85
+ .attr("id", `${uid}-slotFill`)
86
+ .attr("cx", "50%")
87
+ .attr("cy", "42%")
88
+ .attr("r", "58%")
89
+ .call(grad => {
90
+ grad.append("stop").attr("offset", "0%").attr("stop-color", "#1a8195").attr("stop-opacity", 0.44);
91
+ grad.append("stop").attr("offset", "100%").attr("stop-color", "#06293e").attr("stop-opacity", 0.22);
92
+ });
93
+
94
+ defs.append("linearGradient")
95
+ .attr("id", `${uid}-leftArea`)
96
+ .attr("x1", "0%")
97
+ .attr("y1", "0%")
98
+ .attr("x2", "100%")
99
+ .attr("y2", "0%")
100
+ .call(grad => {
101
+ grad.append("stop").attr("offset", "0%").attr("stop-color", "#bdf5eb");
102
+ grad.append("stop").attr("offset", "100%").attr("stop-color", "#7fe4d6");
103
+ });
104
+
105
+ defs.append("linearGradient")
106
+ .attr("id", `${uid}-rightArea`)
107
+ .attr("x1", "0%")
108
+ .attr("y1", "0%")
109
+ .attr("x2", "100%")
110
+ .attr("y2", "0%")
111
+ .call(grad => {
112
+ grad.append("stop").attr("offset", "0%").attr("stop-color", "#28d5cf");
113
+ grad.append("stop").attr("offset", "100%").attr("stop-color", "#1aabae");
114
+ });
115
+
116
+ defs.append("filter")
117
+ .attr("id", `${uid}-softGlow`)
118
+ .attr("x", "-18%")
119
+ .attr("y", "-18%")
120
+ .attr("width", "136%")
121
+ .attr("height", "136%")
122
+ .call(filter => {
123
+ filter.append("feGaussianBlur").attr("stdDeviation", 3).attr("result", "blur");
124
+ const merge = filter.append("feMerge");
125
+ merge.append("feMergeNode").attr("in", "blur");
126
+ merge.append("feMergeNode").attr("in", "SourceGraphic");
127
+ });
128
+
129
+ svg.append("rect")
130
+ .attr("x", 0)
131
+ .attr("y", 0)
132
+ .attr("width", width)
133
+ .attr("height", height)
134
+ .attr("rx", 0)
135
+ .attr("fill", `url(#${uid}-panel)`)
136
+ .attr("fill-opacity", 0.28);
137
+
138
+ svg.append("rect")
139
+ .attr("x", 0)
140
+ .attr("y", 0)
141
+ .attr("width", width)
142
+ .attr("height", height)
143
+ .attr("fill", `url(#${uid}-light)`);
144
+
145
+ const margin = {
146
+ top: Math.max(62, height * 0.09),
147
+ right: Math.max(34, width * 0.06),
148
+ bottom: Math.max(128, height * 0.20),
149
+ left: Math.max(34, width * 0.06)
150
+ };
151
+
152
+ const plot = {
153
+ x: margin.left,
154
+ y: margin.top,
155
+ w: width - margin.left - margin.right,
156
+ h: Math.max(230, height - margin.top - margin.bottom)
157
+ };
158
+
159
+ const centerX = plot.x + plot.w / 2;
160
+ const centerWidth = Math.max(54, Math.min(78, plot.w * 0.13));
161
+ const halfCenter = centerWidth / 2;
162
+ const leftBase = centerX - halfCenter;
163
+ const rightBase = centerX + halfCenter;
164
+ const leftOuter = plot.x + Math.max(6, plot.w * 0.015);
165
+ const rightOuter = plot.x + plot.w - Math.max(6, plot.w * 0.015);
166
+
167
+ const parseTemporal = value => {
168
+ const parsed = chartUtils.format.parseDate(value);
169
+ if (parsed instanceof Date && !Number.isNaN(+parsed)) return parsed;
170
+ const numeric = Number(value);
171
+ if (Number.isFinite(numeric)) return new Date(numeric, 0, 1);
172
+ return new Date(String(value));
173
+ };
174
+
175
+ const prepared = chartData
176
+ .filter(d => groups.includes(d[groupField]))
177
+ .map((d, index) => ({
178
+ raw: d,
179
+ index,
180
+ group: d[groupField],
181
+ date: parseTemporal(d[xField]),
182
+ value: Math.max(0, +d[yField] || 0)
183
+ }))
184
+ .filter(d => d.date instanceof Date && !Number.isNaN(+d.date));
185
+
186
+ const allDates = [...new Map(prepared.map(d => [+d.date, d.date])).values()]
187
+ .sort((a, b) => +a - +b);
188
+
189
+ const xScale = d3.scaleTime()
190
+ .domain(d3.extent(allDates))
191
+ .range([plot.y + 2, plot.y + plot.h - 2]);
192
+
193
+ const maxValue = d3.max(prepared, d => d.value) || 1;
194
+ const step = niceStep(maxValue);
195
+ const niceMax = Math.max(step, Math.ceil(maxValue / step) * step);
196
+
197
+ const leftScale = d3.scaleLinear()
198
+ .domain([0, niceMax])
199
+ .range([leftBase, leftOuter]);
200
+
201
+ const rightScale = d3.scaleLinear()
202
+ .domain([0, niceMax])
203
+ .range([rightBase, rightOuter]);
204
+
205
+ const panel = svg.append("g").attr("class", "spline-multiple-area-panel");
206
+
207
+ for (let i = 0; i < 10; i++) {
208
+ panel.append("path")
209
+ .attr("d", wavePath(width, height, i))
210
+ .attr("fill", "none")
211
+ .attr("stroke", "#2db7c2")
212
+ .attr("stroke-opacity", i % 2 ? 0.06 : 0.038)
213
+ .attr("stroke-width", 1);
214
+ }
215
+
216
+ for (let i = 0; i < 6; i++) {
217
+ panel.append("circle")
218
+ .attr("class", "ambient-bubble")
219
+ .attr("cx", width * (0.08 + i * 0.16))
220
+ .attr("cy", height * (0.18 + (i % 3) * 0.18))
221
+ .attr("r", 2.4 + (i % 2) * 1.6)
222
+ .attr("fill", "#9EECEF")
223
+ .attr("fill-opacity", 0.10 + (i % 2) * 0.05);
224
+ }
225
+
226
+ const tickDates = chooseTemporalTicks(allDates);
227
+ const yearFormat = d3.timeFormat("%Y");
228
+
229
+ panel.append("rect")
230
+ .attr("class", "center-year-band")
231
+ .attr("x", leftBase)
232
+ .attr("y", plot.y - 1)
233
+ .attr("width", centerWidth)
234
+ .attr("height", plot.h + 2)
235
+ .attr("rx", 3)
236
+ .attr("fill", "#061a2d")
237
+ .attr("fill-opacity", 0.88)
238
+ .attr("stroke", "#4ea9cb")
239
+ .attr("stroke-opacity", 0.35);
240
+
241
+ tickDates.forEach(date => {
242
+ const y = xScale(date);
243
+ panel.append("line")
244
+ .attr("class", "year-guide year-guide--left")
245
+ .attr("x1", leftOuter)
246
+ .attr("x2", leftBase - 2)
247
+ .attr("y1", y)
248
+ .attr("y2", y)
249
+ .attr("stroke", "#8ac8df")
250
+ .attr("stroke-width", 1)
251
+ .attr("stroke-dasharray", "2 4")
252
+ .attr("stroke-opacity", 0.42);
253
+ panel.append("line")
254
+ .attr("class", "year-guide year-guide--right")
255
+ .attr("x1", rightBase + 2)
256
+ .attr("x2", rightOuter)
257
+ .attr("y1", y)
258
+ .attr("y2", y)
259
+ .attr("stroke", "#8ac8df")
260
+ .attr("stroke-width", 1)
261
+ .attr("stroke-dasharray", "2 4")
262
+ .attr("stroke-opacity", 0.42);
263
+ panel.append("text")
264
+ .attr("class", "center-year-label")
265
+ .attr("x", centerX)
266
+ .attr("y", y)
267
+ .attr("text-anchor", "middle")
268
+ .attr("dominant-baseline", "middle")
269
+ .attr("fill", "#f2fbff")
270
+ .style("font-size", `${Math.max(11, Math.min(14, height * 0.022))}px`)
271
+ .style("font-weight", 700)
272
+ .text(yearFormat(date));
273
+ });
274
+
275
+ const areaGroups = groups.map((group, i) => {
276
+ const series = prepared
277
+ .filter(d => d.group === group)
278
+ .sort((a, b) => +a.date - +b.date);
279
+ return { group, index: i, series };
280
+ });
281
+
282
+ areaGroups.forEach(({ group, index, series }) => {
283
+ const isLeft = index === 0;
284
+ const scale = isLeft ? leftScale : rightScale;
285
+ const baseX = isLeft ? leftBase : rightBase;
286
+ const gradientId = isLeft ? `${uid}-leftArea` : `${uid}-rightArea`;
287
+ const stroke = isLeft ? "#c9fff4" : "#66f6ec";
288
+
289
+ const area = d3.area()
290
+ .x0(baseX)
291
+ .x1(d => scale(d.value))
292
+ .y(d => xScale(d.date))
293
+ .curve(d3.curveBasis);
294
+
295
+ panel.append("path")
296
+ .datum(series)
297
+ .attr("class", `area-mark area-mark--${isLeft ? "left" : "right"}`)
298
+ .attr("data-chart-mark", "spline-area")
299
+ .attr("data-series", group)
300
+ .attr("fill", `url(#${gradientId})`)
301
+ .attr("fill-opacity", 0.96)
302
+ .attr("stroke", stroke)
303
+ .attr("stroke-width", 2)
304
+ .attr("stroke-linejoin", "round")
305
+ .attr("filter", `url(#${uid}-softGlow)`)
306
+ .attr("d", area);
307
+ });
308
+
309
+ drawUnitBadge(panel, centerX, Math.max(16, plot.y - 48), yLabel, yColumn.unit || "");
310
+ drawBottomAxis(panel, plot, leftOuter, leftBase, rightBase, rightOuter, niceMax, yLabel, yColumn.unit || "");
311
+ drawGroupCallouts(panel, plot, groups, colorResolver);
312
+ drawLegend(panel, plot, groups, colorResolver);
313
+
314
+ return svg.node();
315
+
316
+ function drawUnitBadge(root, x, y, label, unit) {
317
+ const badgeW = Math.min(166, Math.max(112, chartUtils.text.measure(null, label, { fontSize: 12, fontWeight: "bold" }).width + 44));
318
+ root.append("rect")
319
+ .attr("class", "unit-badge")
320
+ .attr("x", x - badgeW / 2)
321
+ .attr("y", y)
322
+ .attr("width", badgeW)
323
+ .attr("height", 42)
324
+ .attr("rx", 8)
325
+ .attr("fill", "#082b48")
326
+ .attr("fill-opacity", 0.86)
327
+ .attr("stroke", "#57cae1")
328
+ .attr("stroke-opacity", 0.64);
329
+ root.append("text")
330
+ .attr("x", x)
331
+ .attr("y", y + 17)
332
+ .attr("text-anchor", "middle")
333
+ .attr("fill", "#f6fbff")
334
+ .style("font-size", "13px")
335
+ .style("font-weight", 800)
336
+ .style("letter-spacing", "1px")
337
+ .text(label.toUpperCase());
338
+ root.append("text")
339
+ .attr("x", x)
340
+ .attr("y", y + 33)
341
+ .attr("text-anchor", "middle")
342
+ .attr("fill", "#d7edf4")
343
+ .style("font-size", "12px")
344
+ .text(unit ? `(${unit})` : "");
345
+ }
346
+
347
+ function drawBottomAxis(root, plotBox, leftStart, leftEnd, rightStart, rightEnd, axisMax, label, unit) {
348
+ const axisY = plotBox.y + plotBox.h + 16;
349
+ const ticks = d3.ticks(0, axisMax, 5);
350
+
351
+ root.append("line")
352
+ .attr("x1", leftStart)
353
+ .attr("x2", leftEnd)
354
+ .attr("y1", axisY)
355
+ .attr("y2", axisY)
356
+ .attr("stroke", "#d9f6ff")
357
+ .attr("stroke-width", 1.2)
358
+ .attr("stroke-opacity", 0.82);
359
+ root.append("line")
360
+ .attr("x1", rightStart)
361
+ .attr("x2", rightEnd)
362
+ .attr("y1", axisY)
363
+ .attr("y2", axisY)
364
+ .attr("stroke", "#d9f6ff")
365
+ .attr("stroke-width", 1.2)
366
+ .attr("stroke-opacity", 0.82);
367
+
368
+ ticks.forEach(tick => {
369
+ const lx = leftScale(tick);
370
+ const rx = rightScale(tick);
371
+ [lx, rx].forEach(x => {
372
+ root.append("circle")
373
+ .attr("cx", x)
374
+ .attr("cy", axisY)
375
+ .attr("r", 3)
376
+ .attr("fill", "#d9f6ff")
377
+ .attr("fill-opacity", 0.9);
378
+ root.append("line")
379
+ .attr("x1", x)
380
+ .attr("x2", x)
381
+ .attr("y1", axisY - 8)
382
+ .attr("y2", axisY + 8)
383
+ .attr("stroke", "#d9f6ff")
384
+ .attr("stroke-opacity", 0.65);
385
+ });
386
+ root.append("text")
387
+ .attr("x", lx)
388
+ .attr("y", axisY + 28)
389
+ .attr("text-anchor", "middle")
390
+ .attr("fill", "#e9f6fb")
391
+ .style("font-size", "12px")
392
+ .text(formatTick(tick));
393
+ root.append("text")
394
+ .attr("x", rx)
395
+ .attr("y", axisY + 28)
396
+ .attr("text-anchor", "middle")
397
+ .attr("fill", "#e9f6fb")
398
+ .style("font-size", "12px")
399
+ .text(formatTick(tick));
400
+ });
401
+
402
+ root.append("text")
403
+ .attr("x", centerX)
404
+ .attr("y", axisY + 47)
405
+ .attr("text-anchor", "middle")
406
+ .attr("fill", "#f5fbff")
407
+ .style("font-size", "15px")
408
+ .style("font-weight", 800)
409
+ .style("letter-spacing", "1px")
410
+ .text(`${label.toUpperCase()}${unit ? ` (${unit.toUpperCase()})` : ""}`);
411
+ }
412
+
413
+ function drawGroupCallouts(root, plotBox, labels, resolver) {
414
+ const calloutY = plotBox.y + plotBox.h * 0.58;
415
+ const slotY = calloutY - 63;
416
+ const callouts = [
417
+ { label: labels[0], side: "left", x: plotBox.x + Math.min(30, plotBox.w * 0.06), textAnchor: "middle" },
418
+ { label: labels[1], side: "right", x: plotBox.x + plotBox.w - Math.min(42, plotBox.w * 0.08), textAnchor: "middle" }
419
+ ];
420
+
421
+ callouts.forEach((item, i) => {
422
+ const w = Math.min(154, Math.max(102, plotBox.w * 0.25));
423
+ const h = 48;
424
+ const x = clamp(item.x - w / 2, plotBox.x + 2, plotBox.x + plotBox.w - w - 2);
425
+ const color = resolver.field(item.label, i, { palette: "category10" }).value;
426
+
427
+ root.append("circle")
428
+ .attr("class", `reserved-asset-slot reserved-asset-slot--side-${item.side}`)
429
+ .attr("data-asset-slot", `side_callout_pictogram_${item.side}`)
430
+ .attr("data-anchor", `${item.side}-group-callout`)
431
+ .attr("data-bbox", `${Math.round(item.x - 24)},${Math.round(slotY - 24)},48,48`)
432
+ .attr("data-collision-rule", "avoid area marks, group label, center year axis, bottom axis, and legend")
433
+ .attr("data-slot-policy", "neutral_placeholder_or_image2_asset")
434
+ .attr("data-asset-source-policy", "neutral_placeholder")
435
+ .attr("cx", item.x)
436
+ .attr("cy", slotY)
437
+ .attr("r", 24)
438
+ .attr("fill", `url(#${uid}-slotFill)`)
439
+ .attr("fill-opacity", 0.92)
440
+ .attr("stroke", color)
441
+ .attr("stroke-width", 2)
442
+ .attr("stroke-dasharray", "5 5")
443
+ .attr("stroke-opacity", 0.82);
444
+
445
+ root.append("rect")
446
+ .attr("class", "group-callout-box")
447
+ .attr("x", x)
448
+ .attr("y", calloutY - h / 2)
449
+ .attr("width", w)
450
+ .attr("height", h)
451
+ .attr("rx", 7)
452
+ .attr("fill", "#dff8f8")
453
+ .attr("fill-opacity", 0.88)
454
+ .attr("stroke", "#ffffff")
455
+ .attr("stroke-opacity", 0.55);
456
+
457
+ const text = root.append("text")
458
+ .attr("class", "group-callout-label")
459
+ .attr("x", x + w / 2)
460
+ .attr("y", calloutY - 7)
461
+ .attr("text-anchor", item.textAnchor)
462
+ .attr("fill", "#07304a")
463
+ .style("font-size", "13px")
464
+ .style("font-weight", 800);
465
+ wrapText(text, item.label, w - 16, 15, 2);
466
+ });
467
+ }
468
+
469
+ function drawLegend(root, plotBox, labels, resolver) {
470
+ const legendY = height - Math.max(30, height * 0.052);
471
+ const itemW = Math.min(220, plotBox.w * 0.42);
472
+ const legendW = itemW * 2 + 24;
473
+ const legendX = clamp(centerX - legendW / 2, 14, width - legendW - 14);
474
+
475
+ root.append("rect")
476
+ .attr("class", "chart-owned-legend")
477
+ .attr("x", legendX)
478
+ .attr("y", legendY - 24)
479
+ .attr("width", legendW)
480
+ .attr("height", 48)
481
+ .attr("rx", 8)
482
+ .attr("fill", "#05243d")
483
+ .attr("fill-opacity", 0.72)
484
+ .attr("stroke", "#3fa9c9")
485
+ .attr("stroke-opacity", 0.35);
486
+
487
+ labels.forEach((label, i) => {
488
+ const x = legendX + 16 + i * itemW;
489
+ const color = i === 0 ? "#9eece1" : "#21c6c2";
490
+ root.append("rect")
491
+ .attr("x", x)
492
+ .attr("y", legendY - 12)
493
+ .attr("width", 24)
494
+ .attr("height", 24)
495
+ .attr("rx", 4)
496
+ .attr("fill", color)
497
+ .attr("stroke", "#d7fffb")
498
+ .attr("stroke-opacity", 0.7);
499
+ const text = root.append("text")
500
+ .attr("x", x + 34)
501
+ .attr("y", legendY + 4)
502
+ .attr("fill", "#f6fbff")
503
+ .style("font-size", "13px")
504
+ .style("font-weight", 600);
505
+ wrapText(text, label, itemW - 48, 14, 2);
506
+ });
507
+ }
508
+
509
+ function chooseTemporalTicks(dates) {
510
+ if (dates.length <= 8) return dates;
511
+ const stride = Math.ceil(dates.length / 8);
512
+ const picked = dates.filter((_, i) => i % stride === 0);
513
+ const last = dates[dates.length - 1];
514
+ if (+picked[picked.length - 1] !== +last) picked.push(last);
515
+ return picked;
516
+ }
517
+
518
+ function niceStep(value) {
519
+ if (value <= 1) return 0.2;
520
+ const exponent = Math.floor(Math.log10(value));
521
+ const base = Math.pow(10, exponent);
522
+ const scaled = value / base;
523
+ if (scaled <= 2) return base / 5;
524
+ if (scaled <= 5) return base / 2;
525
+ return base;
526
+ }
527
+
528
+ function formatTick(value) {
529
+ if (Math.abs(value) >= 1000) return d3.format(".2s")(value).replace("G", "B");
530
+ if (Math.abs(value) < 1 && value !== 0) return d3.format(".2f")(value);
531
+ return d3.format("~g")(value);
532
+ }
533
+
534
+ function wavePath(w, h, i) {
535
+ const y = h * (0.16 + i * 0.095);
536
+ const amp = 10 + i * 2;
537
+ return `M ${-20} ${y} C ${w * 0.18} ${y - amp}, ${w * 0.28} ${y + amp}, ${w * 0.48} ${y} S ${w * 0.82} ${y - amp}, ${w + 20} ${y + amp * 0.35}`;
538
+ }
539
+
540
+ function wrapText(selection, text, maxWidth, lineHeight, maxLines) {
541
+ const words = String(text).split(/\s+/).filter(Boolean);
542
+ const x = +selection.attr("x");
543
+ const y = +selection.attr("y");
544
+ selection.text(null);
545
+ let line = [];
546
+ let lineNumber = 0;
547
+ let tspan = selection.append("tspan").attr("x", x).attr("y", y);
548
+
549
+ words.forEach(word => {
550
+ line.push(word);
551
+ tspan.text(line.join(" "));
552
+ if (tspan.node().getComputedTextLength() > maxWidth && line.length > 1) {
553
+ line.pop();
554
+ tspan.text(line.join(" "));
555
+ line = [word];
556
+ lineNumber += 1;
557
+ if (lineNumber >= maxLines) {
558
+ tspan.text(`${tspan.text()}...`);
559
+ return;
560
+ }
561
+ tspan = selection.append("tspan")
562
+ .attr("x", x)
563
+ .attr("y", y + lineNumber * lineHeight)
564
+ .text(word);
565
+ }
566
+ });
567
+ }
568
+
569
+ function clamp(value, min, max) {
570
+ return Math.max(min, Math.min(max, value));
571
+ }
572
+ }
modules/chart_engine/template/d3-js/area_chart/spline_stacked_area_chart_01.js ADDED
@@ -0,0 +1,631 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Stacked Area Chart",
5
+ "chart_name": "spline_stacked_area_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 50], [0, "inf"], [2, 6]],
9
+ "required_fields_icons": ["group"],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "dark",
17
+ "icon_mark": "overlay",
18
+ "icon_label": "side",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ const jsonData = data;
27
+ const rawRows = jsonData.data.data || [];
28
+ const variables = jsonData.variables || {};
29
+ const dataColumns = chartUtils.schema.columns(jsonData);
30
+ const colorResolver = chartUtils.color.resolver(jsonData);
31
+ const images = jsonData.images || {};
32
+ const fontSize = chartUtils.text.fontSize;
33
+
34
+ d3.select(containerSelector).html("");
35
+
36
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
37
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
38
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
39
+
40
+ const width = variables.width || 600;
41
+ const height = Math.max(720, Math.round((variables.height || 600) * 1.2));
42
+ const uid = `splineStackedArea${Math.random().toString(36).slice(2)}`;
43
+
44
+ const parseTemporal = value => {
45
+ if (value instanceof Date) return value;
46
+ if (typeof value === "number" || /^[0-9]{3,4}$/.test(String(value))) {
47
+ return new Date(Number(value), 0, 1);
48
+ }
49
+ const parsed = chartUtils.format.parseDate(value);
50
+ if (parsed instanceof Date && !isNaN(parsed)) return parsed;
51
+ const fallback = new Date(value);
52
+ return isNaN(fallback) ? new Date(0) : fallback;
53
+ };
54
+
55
+ const formatYear = date => {
56
+ const parsed = date instanceof Date ? date : parseTemporal(date);
57
+ return chartUtils.format.date(parsed, { output: "%Y" }).text;
58
+ };
59
+
60
+ const rows = rawRows
61
+ .map((d, index) => ({
62
+ ...d,
63
+ __index: index,
64
+ __date: parseTemporal(d[xField]),
65
+ __value: Math.max(0, +d[yField] || 0),
66
+ __group: String(d[groupField])
67
+ }))
68
+ .sort((a, b) => a.__date - b.__date);
69
+
70
+ const groupStats = Array.from(d3.group(rows, d => d.__group), ([group, values]) => {
71
+ const ordered = values.slice().sort((a, b) => a.__date - b.__date);
72
+ const latest = ordered[ordered.length - 1];
73
+ return {
74
+ group,
75
+ values: ordered,
76
+ latestValue: latest ? latest.__value : 0,
77
+ avgValue: d3.mean(ordered, d => d.__value) || 0
78
+ };
79
+ });
80
+
81
+ const groups = groupStats
82
+ .slice()
83
+ .sort((a, b) => a.avgValue - b.avgValue)
84
+ .map(d => d.group);
85
+
86
+ const distinctDates = Array.from(new Map(rows.map(d => [+d.__date, d.__date])).values())
87
+ .sort((a, b) => a - b);
88
+
89
+ const stackData = distinctDates.map(date => {
90
+ const entry = { date };
91
+ groups.forEach(group => {
92
+ const row = rows.find(d => +d.__date === +date && d.__group === group);
93
+ entry[group] = row ? row.__value : 0;
94
+ });
95
+ return entry;
96
+ });
97
+
98
+ const latestDate = distinctDates[distinctDates.length - 1];
99
+ const latestYear = latestDate ? formatYear(latestDate) : "";
100
+ const latestTotal = d3.sum(groups, group => {
101
+ const stat = groupStats.find(d => d.group === group);
102
+ return stat ? stat.latestValue : 0;
103
+ });
104
+
105
+ const safeText = value => String(value == null ? "" : value);
106
+ const title = safeText(jsonData.titles && jsonData.titles.main_title ? jsonData.titles.main_title : jsonData.metadata && jsonData.metadata.title ? jsonData.metadata.title : "");
107
+ const subtitle = safeText(jsonData.titles && jsonData.titles.sub_title ? jsonData.titles.sub_title : jsonData.description || "").replace(/^"|"$/g, "");
108
+
109
+ const svg = d3.select(containerSelector)
110
+ .append("svg")
111
+ .attr("width", "100%")
112
+ .attr("height", height)
113
+ .attr("viewBox", `0 0 ${width} ${height}`)
114
+ .attr("style", "max-width: 100%; height: auto; font-family: Arial, 'Liberation Sans', sans-serif;")
115
+ .attr("xmlns", "http://www.w3.org/2000/svg")
116
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
117
+
118
+ const defs = svg.append("defs");
119
+ const bgGradient = defs.append("radialGradient")
120
+ .attr("id", `${uid}-bg`)
121
+ .attr("cx", "58%")
122
+ .attr("cy", "48%")
123
+ .attr("r", "78%");
124
+ bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#092445");
125
+ bgGradient.append("stop").attr("offset", "58%").attr("stop-color", "#04162b");
126
+ bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#010915");
127
+
128
+ const panelGradient = defs.append("linearGradient")
129
+ .attr("id", `${uid}-panel`)
130
+ .attr("x1", "0%").attr("x2", "100%").attr("y1", "0%").attr("y2", "100%");
131
+ panelGradient.append("stop").attr("offset", "0%").attr("stop-color", "#06172c");
132
+ panelGradient.append("stop").attr("offset", "100%").attr("stop-color", "#082a4c");
133
+
134
+ const glow = defs.append("filter")
135
+ .attr("id", `${uid}-glow`)
136
+ .attr("x", "-30%").attr("y", "-30%")
137
+ .attr("width", "160%").attr("height", "160%");
138
+ glow.append("feGaussianBlur").attr("stdDeviation", 3.2).attr("result", "blur");
139
+ const merge = glow.append("feMerge");
140
+ merge.append("feMergeNode").attr("in", "blur");
141
+ merge.append("feMergeNode").attr("in", "SourceGraphic");
142
+
143
+ const cardShadow = defs.append("filter")
144
+ .attr("id", `${uid}-cardShadow`)
145
+ .attr("x", "-20%").attr("y", "-20%")
146
+ .attr("width", "140%").attr("height", "150%");
147
+ cardShadow.append("feDropShadow")
148
+ .attr("dx", 0)
149
+ .attr("dy", 7)
150
+ .attr("stdDeviation", 8)
151
+ .attr("flood-color", "#000814")
152
+ .attr("flood-opacity", 0.45);
153
+
154
+ const starData = [
155
+ [22, 58, 1.4], [82, 38, 1.1], [505, 52, 1.3], [558, 88, 1.0],
156
+ [70, 292, 0.8], [518, 310, 1.0], [138, 392, 0.8], [500, 557, 1.2],
157
+ [430, 102, 1.2], [40, 175, 0.9], [548, 210, 0.8]
158
+ ];
159
+ svg.selectAll(".decor-star")
160
+ .data(starData)
161
+ .join("circle")
162
+ .attr("class", "decor-star")
163
+ .attr("cx", d => d[0])
164
+ .attr("cy", d => d[1])
165
+ .attr("r", d => d[2])
166
+ .attr("fill", d => d[0] % 2 ? "#99d8ff" : "#b9ff62")
167
+ .attr("opacity", 0.75);
168
+
169
+ const splitTitle = value => {
170
+ const words = value.split(/\s+/).filter(Boolean);
171
+ if (words.length <= 4) return { top: value, highlight: "" };
172
+ const leadIndex = words.findIndex(w => /^lead/i.test(w));
173
+ if (leadIndex > 1 && leadIndex < words.length - 1) {
174
+ return {
175
+ top: words.slice(0, leadIndex).join(" "),
176
+ highlight: words.slice(leadIndex).join(" ")
177
+ };
178
+ }
179
+ const split = Math.ceil(words.length / 2);
180
+ return {
181
+ top: words.slice(0, split).join(" "),
182
+ highlight: words.slice(split).join(" ")
183
+ };
184
+ };
185
+
186
+ const fitSize = (value, maxChars, large, small) => {
187
+ const len = value.length;
188
+ if (len <= maxChars) return large;
189
+ return Math.max(small, large - (len - maxChars) * 0.8);
190
+ };
191
+
192
+ const groupCardY = 25;
193
+ const groupCardH = 76;
194
+ const visibleGroups = groupStats
195
+ .slice()
196
+ .sort((a, b) => b.latestValue - a.latestValue)
197
+ .slice(0, Math.min(groups.length, 3));
198
+ const cardGap = 10;
199
+ const cardW = visibleGroups.length === 2 ? 232 : (width - 72 - cardGap * (visibleGroups.length - 1)) / visibleGroups.length;
200
+ const groupCardsX = visibleGroups.length === 2 ? (width - cardW * 2 - cardGap) / 2 : 36;
201
+
202
+ const basePalette = ["#a9f33e", "#43a7ff", "#f1d46b", "#f1844a", "#a782ff", "#57dab7"];
203
+ const colorForGroup = (group, index) => {
204
+ const lowered = group.toLowerCase();
205
+ if (lowered.includes("space") || lowered.includes("potassium")) return "#43a7ff";
206
+ if (lowered.includes("ground") || lowered.includes("nitrogen")) return "#a9f33e";
207
+ const resolved = colorResolver.field(group, index, { fallbackKey: "primary" }).value;
208
+ return resolved || basePalette[index % basePalette.length];
209
+ };
210
+ const groupColors = new Map(groups.map((group, i) => [group, colorForGroup(group, i)]));
211
+
212
+ const wrapText = (selection, text, maxChars, maxLines) => {
213
+ const words = safeText(text).split(/\s+/).filter(Boolean);
214
+ const lines = [];
215
+ let line = "";
216
+ words.forEach(word => {
217
+ const candidate = line ? `${line} ${word}` : word;
218
+ if (candidate.length > maxChars && line) {
219
+ lines.push(line);
220
+ line = word;
221
+ } else {
222
+ line = candidate;
223
+ }
224
+ });
225
+ if (line) lines.push(line);
226
+ lines.slice(0, maxLines).forEach((value, i) => {
227
+ selection.append("tspan")
228
+ .attr("x", selection.attr("data-x"))
229
+ .attr("dy", i === 0 ? 0 : 15)
230
+ .text(i === maxLines - 1 && lines.length > maxLines ? `${value.slice(0, Math.max(0, maxChars - 1))}...` : value);
231
+ });
232
+ };
233
+
234
+ const groupCard = svg.selectAll(".top-group-card")
235
+ .data(visibleGroups)
236
+ .join("g")
237
+ .attr("class", "top-group-card")
238
+ .attr("transform", (d, i) => `translate(${groupCardsX + i * (cardW + cardGap)}, ${groupCardY})`);
239
+
240
+ groupCard.append("rect")
241
+ .attr("width", cardW)
242
+ .attr("height", groupCardH)
243
+ .attr("rx", 18)
244
+ .attr("fill", "#061a32")
245
+ .attr("stroke", d => d3.color(groupColors.get(d.group)).copy({ opacity: 0.42 }))
246
+ .attr("stroke-width", 1.2)
247
+ .attr("filter", `url(#${uid}-cardShadow)`);
248
+
249
+ groupCard.each(function(d, i) {
250
+ const g = d3.select(this);
251
+ const color = groupColors.get(d.group);
252
+ const clipId = `${uid}-topSlot-${i}`;
253
+ defs.append("clipPath")
254
+ .attr("id", clipId)
255
+ .append("circle")
256
+ .attr("cx", 38)
257
+ .attr("cy", 38)
258
+ .attr("r", 25);
259
+
260
+ g.append("circle")
261
+ .attr("cx", 38)
262
+ .attr("cy", 38)
263
+ .attr("r", 27)
264
+ .attr("fill", d3.color(color).copy({ opacity: 0.22 }))
265
+ .attr("stroke", color)
266
+ .attr("stroke-width", 2)
267
+ .attr("data-asset-slot", "top_group_image_slot")
268
+ .attr("data-bbox", `${groupCardsX + i * (cardW + cardGap) + 11},${groupCardY + 11},54,54`)
269
+ .attr("data-anchor", `top-card:${d.group}`)
270
+ .attr("data-collision-rule", "inside card icon circle; avoid card text, subtitle, neighboring cards, and chart panel")
271
+ .attr("data-asset-source-policy", "neutral-placeholder-or-downstream-image2")
272
+ .attr("data-no-embedded-raster", "true")
273
+ .attr("data-no-copied-reference-art", "true");
274
+
275
+ g.append("circle")
276
+ .attr("cx", 38)
277
+ .attr("cy", 38)
278
+ .attr("r", 19)
279
+ .attr("fill", "none")
280
+ .attr("stroke", color)
281
+ .attr("stroke-width", 1.4)
282
+ .attr("stroke-dasharray", "4 4")
283
+ .attr("opacity", 0.58)
284
+ .attr("data-asset-slot", "top_group_image_slot")
285
+ .attr("data-anchor", `top-card:${d.group}`)
286
+ .attr("data-no-embedded-raster", "true")
287
+ .attr("data-no-copied-reference-art", "true");
288
+
289
+ g.append("text")
290
+ .attr("x", 38)
291
+ .attr("y", 43)
292
+ .attr("text-anchor", "middle")
293
+ .attr("fill", color)
294
+ .attr("font-size", fontSize(18))
295
+ .attr("font-weight", 800)
296
+ .attr("data-asset-slot", "top_group_image_slot")
297
+ .attr("data-no-embedded-raster", "true")
298
+ .attr("data-no-copied-reference-art", "true")
299
+ .text(d.group.slice(0, 1).toUpperCase());
300
+
301
+ const textX = visibleGroups.length === 3 ? 72 : 80;
302
+ g.append("text")
303
+ .attr("x", textX)
304
+ .attr("y", 29)
305
+ .attr("fill", color)
306
+ .attr("font-size", fontSize(visibleGroups.length === 3 ? 12 : 14))
307
+ .attr("font-weight", 800)
308
+ .text(d.group);
309
+
310
+ const share = latestTotal ? Math.round(d.latestValue / latestTotal * 100) : 0;
311
+ const detail = `${chartUtils.format.autoText(d.latestValue).text || chartUtils.format.autoText(d.latestValue)} ${yField} by ${latestYear} (${share}% share)`;
312
+ const detailText = g.append("text")
313
+ .attr("data-x", textX)
314
+ .attr("x", textX)
315
+ .attr("y", 49)
316
+ .attr("fill", "#f3f8ff")
317
+ .attr("font-size", fontSize(visibleGroups.length === 3 ? 10 : 12))
318
+ .attr("font-weight", 500);
319
+ wrapText(detailText, detail, visibleGroups.length === 3 ? 18 : 26, 2);
320
+ });
321
+
322
+ const panel = { x: 32, y: 105, w: width - 64, h: height - 195 };
323
+ const plot = {
324
+ x: panel.x + 66,
325
+ y: panel.y + 28,
326
+ w: panel.w - 106,
327
+ h: panel.h - 82
328
+ };
329
+
330
+ const stack = d3.stack()
331
+ .keys(groups)
332
+ .order(d3.stackOrderNone)
333
+ .offset(d3.stackOffsetNone);
334
+ const stackedData = stack(stackData);
335
+
336
+ const maxTotal = d3.max(stackData, d => d3.sum(groups, group => d[group] || 0)) || 1;
337
+ const roundBase = maxTotal > 250 ? 50 : maxTotal > 100 ? 25 : maxTotal > 30 ? 10 : 5;
338
+ const yMax = Math.max(roundBase, Math.ceil(maxTotal * 1.1 / roundBase) * roundBase);
339
+ const xScale = d3.scaleTime()
340
+ .domain(d3.extent(distinctDates))
341
+ .range([0, plot.w]);
342
+ const yScale = d3.scaleLinear()
343
+ .domain([0, yMax])
344
+ .range([plot.h, 0]);
345
+
346
+ const chartG = svg.append("g")
347
+ .attr("transform", `translate(${plot.x}, ${plot.y})`);
348
+
349
+ const yTicks = yScale.ticks(6);
350
+ yTicks.forEach(tick => {
351
+ chartG.append("line")
352
+ .attr("x1", 0)
353
+ .attr("x2", plot.w)
354
+ .attr("y1", yScale(tick))
355
+ .attr("y2", yScale(tick))
356
+ .attr("stroke", "#8fb7df")
357
+ .attr("stroke-width", 0.7)
358
+ .attr("stroke-dasharray", "4 5")
359
+ .attr("opacity", 0.26);
360
+
361
+ chartG.append("text")
362
+ .attr("x", -14)
363
+ .attr("y", yScale(tick) + 4)
364
+ .attr("text-anchor", "end")
365
+ .attr("fill", "#edf5ff")
366
+ .attr("font-size", fontSize(12))
367
+ .attr("font-weight", 600)
368
+ .text(chartUtils.format.autoText(tick).text || chartUtils.format.autoText(tick));
369
+ });
370
+
371
+ chartG.append("line")
372
+ .attr("x1", 0)
373
+ .attr("x2", plot.w)
374
+ .attr("y1", plot.h)
375
+ .attr("y2", plot.h)
376
+ .attr("stroke", "#d9efff")
377
+ .attr("stroke-width", 1.2);
378
+
379
+ chartG.append("line")
380
+ .attr("x1", 0)
381
+ .attr("x2", 0)
382
+ .attr("y1", 0)
383
+ .attr("y2", plot.h)
384
+ .attr("stroke", "#d9efff")
385
+ .attr("stroke-width", 1.2);
386
+
387
+ const xTickDates = distinctDates.length <= 7
388
+ ? distinctDates
389
+ : distinctDates.filter((d, i) => i === 0 || i === distinctDates.length - 1 || i % Math.ceil(distinctDates.length / 5) === 0);
390
+ xTickDates.forEach(date => {
391
+ chartG.append("text")
392
+ .attr("x", xScale(date))
393
+ .attr("y", plot.h + 28)
394
+ .attr("text-anchor", "middle")
395
+ .attr("fill", "#edf5ff")
396
+ .attr("font-size", fontSize(12))
397
+ .attr("font-weight", 600)
398
+ .text(formatYear(date));
399
+ });
400
+
401
+ svg.append("text")
402
+ .attr("transform", `translate(${panel.x + 22}, ${plot.y + plot.h / 2}) rotate(-90)`)
403
+ .attr("text-anchor", "middle")
404
+ .attr("fill", "#edf5ff")
405
+ .attr("font-size", fontSize(12))
406
+ .attr("font-weight", 800)
407
+ .attr("letter-spacing", 1.2)
408
+ .text(yField.toUpperCase());
409
+
410
+ groups.forEach((group, i) => {
411
+ const color = groupColors.get(group);
412
+ const grad = defs.append("linearGradient")
413
+ .attr("id", `${uid}-area-${i}`)
414
+ .attr("x1", "0%")
415
+ .attr("x2", "0%")
416
+ .attr("y1", "0%")
417
+ .attr("y2", "100%");
418
+ grad.append("stop")
419
+ .attr("offset", "0%")
420
+ .attr("stop-color", d3.color(color).brighter(0.8))
421
+ .attr("stop-opacity", 0.96);
422
+ grad.append("stop")
423
+ .attr("offset", "100%")
424
+ .attr("stop-color", d3.color(color).darker(0.8))
425
+ .attr("stop-opacity", 0.74);
426
+ });
427
+
428
+ const area = d3.area()
429
+ .x(d => xScale(d.data.date))
430
+ .y0(d => yScale(d[0]))
431
+ .y1(d => yScale(d[1]))
432
+ .curve(d3.curveBasis);
433
+
434
+ const topLine = d3.line()
435
+ .x(d => xScale(d.data.date))
436
+ .y(d => yScale(d[1]))
437
+ .curve(d3.curveBasis);
438
+
439
+ const areaLayer = chartG.append("g").attr("class", "stacked-area-layers");
440
+ areaLayer.selectAll("path.area-fill")
441
+ .data(stackedData)
442
+ .join("path")
443
+ .attr("class", "area-fill")
444
+ .attr("d", area)
445
+ .attr("fill", (d, i) => `url(#${uid}-area-${i})`)
446
+ .attr("stroke", "none")
447
+ .attr("opacity", 0.96)
448
+ .attr("data-series", d => d.key);
449
+
450
+ areaLayer.selectAll("path.area-glow")
451
+ .data(stackedData)
452
+ .join("path")
453
+ .attr("class", "area-glow")
454
+ .attr("d", topLine)
455
+ .attr("fill", "none")
456
+ .attr("stroke", d => groupColors.get(d.key))
457
+ .attr("stroke-width", 5.5)
458
+ .attr("stroke-opacity", 0.22)
459
+ .attr("filter", `url(#${uid}-glow)`);
460
+
461
+ areaLayer.selectAll("path.area-outline")
462
+ .data(stackedData)
463
+ .join("path")
464
+ .attr("class", "area-outline")
465
+ .attr("d", topLine)
466
+ .attr("fill", "none")
467
+ .attr("stroke", d => d3.color(groupColors.get(d.key)).brighter(0.8))
468
+ .attr("stroke-width", 1.8)
469
+ .attr("stroke-linecap", "round")
470
+ .attr("stroke-linejoin", "round");
471
+
472
+ const usedSlots = [];
473
+ stackedData.forEach((series, i) => {
474
+ const middleIndex = Math.floor(series.length * 0.68);
475
+ const point = series[middleIndex];
476
+ if (!point) return;
477
+ const segmentHeight = Math.abs(yScale(point[0]) - yScale(point[1]));
478
+ if (segmentHeight < 52 || i > 1) return;
479
+ const slotSize = Math.min(54, segmentHeight * 0.58);
480
+ const slotX = Math.min(plot.w - slotSize - 10, Math.max(10, xScale(point.data.date) - slotSize / 2));
481
+ const slotY = yScale((point[0] + point[1]) / 2) - slotSize / 2;
482
+ const overlapsUsed = usedSlots.some(slot => Math.abs(slot.x - slotX) < slotSize && Math.abs(slot.y - slotY) < slotSize);
483
+ if (overlapsUsed) return;
484
+ usedSlots.push({ x: slotX, y: slotY });
485
+ const slot = chartG.append("g")
486
+ .attr("class", "reserved-asset-slot plot-area-neutral-image-slot")
487
+ .attr("transform", `translate(${slotX},${slotY})`)
488
+ .attr("opacity", 0.18)
489
+ .attr("data-asset-slot", "plot_area_decorative_image_slot")
490
+ .attr("data-series", series.key)
491
+ .attr("data-bbox", `${plot.x + slotX},${plot.y + slotY},${slotSize},${slotSize}`)
492
+ .attr("data-anchor", `series-interior:${series.key}`)
493
+ .attr("data-collision-rule", "omit or keep translucent only when inside a large segment and away from endpoint cards, labels, and axes")
494
+ .attr("data-asset-source-policy", "neutral-placeholder-or-downstream-image2")
495
+ .attr("data-no-embedded-raster", "true")
496
+ .attr("data-no-copied-reference-art", "true");
497
+ slot.append("rect")
498
+ .attr("x", 0)
499
+ .attr("y", 0)
500
+ .attr("width", slotSize)
501
+ .attr("height", slotSize)
502
+ .attr("rx", Math.max(6, slotSize * 0.16))
503
+ .attr("fill", "none")
504
+ .attr("stroke", groupColors.get(series.key))
505
+ .attr("stroke-width", 1.4)
506
+ .attr("stroke-dasharray", "5 5");
507
+ slot.append("line")
508
+ .attr("x1", slotSize * 0.25)
509
+ .attr("y1", slotSize * 0.50)
510
+ .attr("x2", slotSize * 0.75)
511
+ .attr("y2", slotSize * 0.50)
512
+ .attr("stroke", groupColors.get(series.key))
513
+ .attr("stroke-width", 1.2);
514
+ });
515
+
516
+ const endpointData = stackedData.map((series, index) => {
517
+ const anchorIndex = Math.max(0, series.length - 2);
518
+ const anchor = series[anchorIndex];
519
+ const stat = groupStats.find(d => d.group === series.key);
520
+ const share = latestTotal && stat ? Math.round(stat.latestValue / latestTotal * 100) : 0;
521
+ return {
522
+ group: series.key,
523
+ value: stat ? stat.latestValue : 0,
524
+ share,
525
+ x: xScale(anchor.data.date),
526
+ y: yScale((anchor[0] + anchor[1]) / 2),
527
+ color: groupColors.get(series.key),
528
+ layerIndex: index
529
+ };
530
+ }).sort((a, b) => a.y - b.y);
531
+
532
+ const endpointCardW = Math.min(130, plot.w * 0.34);
533
+ const endpointCardH = 62;
534
+ const endpointMinY = plot.y + 8;
535
+ const endpointMaxY = plot.y + plot.h - endpointCardH - 8;
536
+ const endpointGap = 8;
537
+ endpointData.forEach(d => {
538
+ d.cardX = plot.x + plot.w * 0.58;
539
+ if (d.layerIndex === 0) d.cardX = plot.x + plot.w * 0.60;
540
+ d.cardX = Math.min(plot.x + plot.w - endpointCardW - 54, Math.max(plot.x + plot.w * 0.48, d.cardX));
541
+ d.preferredCardY = Math.max(endpointMinY, Math.min(endpointMaxY, plot.y + d.y - endpointCardH / 2));
542
+ });
543
+
544
+ let nextCardY = endpointMinY;
545
+ endpointData.forEach(d => {
546
+ d.cardY = Math.max(nextCardY, d.preferredCardY);
547
+ nextCardY = d.cardY + endpointCardH + endpointGap;
548
+ });
549
+ const bottomOverflow = nextCardY - endpointGap - (endpointMaxY + endpointCardH);
550
+ if (bottomOverflow > 0) {
551
+ endpointData.forEach(d => {
552
+ d.cardY -= bottomOverflow;
553
+ });
554
+ }
555
+ const topUnderflow = endpointMinY - (endpointData[0] ? endpointData[0].cardY : endpointMinY);
556
+ if (topUnderflow > 0) {
557
+ endpointData.forEach(d => {
558
+ d.cardY += topUnderflow;
559
+ });
560
+ }
561
+ for (let i = endpointData.length - 2; i >= 0; i -= 1) {
562
+ const next = endpointData[i + 1];
563
+ const current = endpointData[i];
564
+ if (current.cardY + endpointCardH + endpointGap > next.cardY) {
565
+ current.cardY = Math.max(endpointMinY, next.cardY - endpointCardH - endpointGap);
566
+ }
567
+ }
568
+
569
+ const endpointLayer = svg.append("g").attr("class", "endpoint-annotations");
570
+ endpointData.forEach(d => {
571
+ const dotX = Math.min(plot.x + plot.w - 18, Math.max(d.cardX + endpointCardW + 18, plot.x + d.x + 30));
572
+ const dotY = plot.y + d.y;
573
+ const leaderStartX = d.cardX + endpointCardW;
574
+ const leaderY = Math.max(d.cardY + 18, Math.min(d.cardY + endpointCardH - 18, dotY));
575
+
576
+ endpointLayer.append("circle")
577
+ .attr("cx", dotX)
578
+ .attr("cy", dotY)
579
+ .attr("r", 4.8)
580
+ .attr("fill", d.color)
581
+ .attr("stroke", "#e9fbff")
582
+ .attr("stroke-width", 1.4)
583
+ .attr("filter", `url(#${uid}-glow)`);
584
+
585
+ endpointLayer.append("path")
586
+ .attr("d", `M${leaderStartX},${leaderY} L${dotX - 16},${leaderY} Q${dotX - 10},${leaderY} ${dotX - 10},${dotY} L${dotX - 5},${dotY}`)
587
+ .attr("fill", "none")
588
+ .attr("stroke", d.color)
589
+ .attr("stroke-width", 1.2)
590
+ .attr("stroke-linecap", "round")
591
+ .attr("opacity", 0.88);
592
+
593
+ endpointLayer.append("rect")
594
+ .attr("x", d.cardX)
595
+ .attr("y", d.cardY)
596
+ .attr("width", endpointCardW)
597
+ .attr("height", endpointCardH)
598
+ .attr("rx", 10)
599
+ .attr("fill", "#061d36")
600
+ .attr("stroke", d.color)
601
+ .attr("stroke-width", 1.1)
602
+ .attr("stroke-opacity", 0.8)
603
+ .attr("filter", `url(#${uid}-cardShadow)`);
604
+
605
+ endpointLayer.append("text")
606
+ .attr("x", d.cardX + 10)
607
+ .attr("y", d.cardY + 19)
608
+ .attr("fill", d.color)
609
+ .attr("font-size", fontSize(11))
610
+ .attr("font-weight", 800)
611
+ .text(d.group.length > 12 ? `${d.group.slice(0, 11)}...` : d.group);
612
+
613
+ endpointLayer.append("text")
614
+ .attr("x", d.cardX + 10)
615
+ .attr("y", d.cardY + 42)
616
+ .attr("fill", "#eaf8ff")
617
+ .attr("font-size", fontSize(12))
618
+ .attr("font-weight", 700)
619
+ .text(`${chartUtils.format.autoText(d.value).text || chartUtils.format.autoText(d.value)} by ${latestYear}`);
620
+
621
+ endpointLayer.append("text")
622
+ .attr("x", d.cardX + 11)
623
+ .attr("y", d.cardY + 56)
624
+ .attr("fill", "#dbe8f4")
625
+ .attr("font-size", fontSize(10))
626
+ .attr("font-weight", 500)
627
+ .text(`~${d.share}% of total`);
628
+ });
629
+
630
+ return svg.node();
631
+ }
modules/chart_engine/template/d3-js/area_chart/stacked_area_chart_01.js ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Stacked Area Chart",
5
+ "chart_name": "stacked_area_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[5, 50], [0, "inf"], [2, 10]],
9
+ "required_fields_icons": ["group"],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": ["opacity"],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "light",
17
+ "icon_mark": "overlay",
18
+ "icon_label": "side",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
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 variables = jsonData.variables || {};
30
+ const typography = jsonData.typography || {};
31
+ const sourceColors = jsonData.colors || {};
32
+ const colorResolver = chartUtils.color.resolver(jsonData, { colors: sourceColors });
33
+ const fontSize = chartUtils.text.fontSize;
34
+
35
+ d3.select(containerSelector).html("");
36
+
37
+ const xColumn = dataColumns[0] || {};
38
+ const yColumn = dataColumns[1] || {};
39
+ const groupColumn = dataColumns[2] || {};
40
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
41
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
42
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
43
+
44
+ const width = Math.max(600, Number(variables.width) || 600);
45
+ const height = Math.max(600, Number(variables.height) || 600);
46
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, Helvetica, sans-serif";
47
+ const textColor = sourceColors.text_color || "#0B2B4D";
48
+ const mutedText = "#657B8F";
49
+ const axisColor = "#2E506E";
50
+ const gridColor = "#B8D6E6";
51
+ const pageFill = "transparent";
52
+ const referenceAreaPalette = [
53
+ { base: "#F7D94A", top: "#FFF2A5", bottom: "#F3CF25" },
54
+ { base: "#22BBA4", top: "#30D0B7", bottom: "#169C91" },
55
+ { base: "#1EB06E", top: "#29C881", bottom: "#098E59" },
56
+ { base: "#82E2BF", top: "#A7F0D8", bottom: "#55CAA9" },
57
+ { base: "#BDEB68", top: "#D7F58F", bottom: "#A8DF4C" },
58
+ { base: "#6DD2E6", top: "#A8E8F3", bottom: "#3DB9D1" },
59
+ { base: "#8ED36E", top: "#BBE99B", bottom: "#65B955" }
60
+ ];
61
+
62
+ const svg = d3.select(containerSelector)
63
+ .append("svg")
64
+ .attr("width", "100%")
65
+ .attr("height", height)
66
+ .attr("viewBox", `0 0 ${width} ${height}`)
67
+ .attr("style", "max-width: 100%; height: auto;")
68
+ .attr("xmlns", "http://www.w3.org/2000/svg")
69
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
70
+ .attr("class", "stacked-area-chart-01-reference-layout")
71
+ .attr("data-reference-layout", "selected-reference-sample00-v03")
72
+ .attr("data-temporal-order", "chronological");
73
+
74
+ const showMessage = message => {
75
+ svg.append("rect")
76
+ .attr("width", width)
77
+ .attr("height", height)
78
+ .attr("fill", pageFill);
79
+ svg.append("text")
80
+ .attr("x", width / 2)
81
+ .attr("y", height / 2)
82
+ .attr("text-anchor", "middle")
83
+ .attr("fill", textColor)
84
+ .style("font-family", fontFamily)
85
+ .style("font-size", `${fontSize(16)}px`)
86
+ .text(message);
87
+ return svg.node();
88
+ };
89
+
90
+ if (!xField || !yField || !groupField) {
91
+ return showMessage("Missing stacked area fields");
92
+ }
93
+
94
+ const parseTimeValue = value => {
95
+ const raw = String(value ?? "").trim();
96
+ if (!raw) return null;
97
+ if (/^\d{4}$/.test(raw)) return new Date(Number(raw), 0, 1);
98
+ const helperParsed = chartUtils.format.parseDate(value);
99
+ if (helperParsed instanceof Date && Number.isFinite(helperParsed.getTime())) return helperParsed;
100
+ const parsed = Date.parse(raw);
101
+ if (Number.isFinite(parsed)) return new Date(parsed);
102
+ const numeric = Number(raw);
103
+ if (Number.isFinite(numeric)) return new Date(numeric, 0, 1);
104
+ return null;
105
+ };
106
+
107
+ const formatYear = date => {
108
+ if (!(date instanceof Date) || !Number.isFinite(date.getTime())) return "";
109
+ return String(date.getFullYear());
110
+ };
111
+
112
+ const cleanLabel = value => String(value ?? "")
113
+ .replace(/_/g, " ")
114
+ .replace(/\s+/g, " ")
115
+ .trim();
116
+
117
+ const truncate = (value, maxChars = 24) => {
118
+ const clean = cleanLabel(value);
119
+ if (clean.length <= maxChars) return clean;
120
+ return `${clean.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
121
+ };
122
+
123
+ const formatValue = value => {
124
+ const num = Number(value) || 0;
125
+ const abs = Math.abs(num);
126
+ const spec = abs >= 1000 ? ",.0f" : abs >= 100 ? ",.0f" : abs >= 10 ? ",.1f" : ",.2f";
127
+ return d3.format(spec)(num)
128
+ .replace(/\.0$/, "")
129
+ .replace(/(\.\d*?)0+$/, "$1")
130
+ .replace(/\.$/, "");
131
+ };
132
+
133
+ const rows = chartData
134
+ .map((row, sourceIndex) => {
135
+ const date = parseTimeValue(row[xField]);
136
+ const value = Number(row[yField]);
137
+ return {
138
+ raw: row,
139
+ sourceIndex,
140
+ date,
141
+ xKey: date && Number.isFinite(date.getTime()) ? String(date.getTime()) : String(row[xField] ?? ""),
142
+ value: Number.isFinite(value) ? value : 0,
143
+ group: String(row[groupField] ?? "")
144
+ };
145
+ })
146
+ .filter(row => row.date && Number.isFinite(row.date.getTime()) && row.group);
147
+
148
+ if (rows.length < 2) {
149
+ return showMessage("Not enough data for stacked area");
150
+ }
151
+
152
+ const groups = [...new Set(rows.map(row => row.group))]
153
+ .sort((a, b) => {
154
+ const avgA = d3.mean(rows.filter(row => row.group === a), row => row.value) || 0;
155
+ const avgB = d3.mean(rows.filter(row => row.group === b), row => row.value) || 0;
156
+ return avgA - avgB || a.localeCompare(b);
157
+ });
158
+
159
+ const dates = [...new Map(rows
160
+ .sort((a, b) => a.date - b.date || a.sourceIndex - b.sourceIndex)
161
+ .map(row => [row.xKey, row.date])).values()];
162
+
163
+ const valuesByDate = d3.group(rows, row => row.xKey);
164
+ const stackData = dates.map(date => {
165
+ const key = String(date.getTime());
166
+ const values = valuesByDate.get(key) || [];
167
+ const obj = { date };
168
+ groups.forEach(group => {
169
+ const match = values.find(row => row.group === group);
170
+ obj[group] = match ? Math.max(0, Number(match.value) || 0) : 0;
171
+ });
172
+ return obj;
173
+ });
174
+
175
+ const stack = d3.stack()
176
+ .keys(groups)
177
+ .order(d3.stackOrderNone)
178
+ .offset(d3.stackOffsetNone);
179
+ const stackedData = stack(stackData);
180
+
181
+ const yMaxRaw = d3.max(stackedData, layer => d3.max(layer, point => point[1])) || 1;
182
+ const yMax = yMaxRaw * 1.08;
183
+
184
+ const margin = {
185
+ top: Math.max(12, height * 0.025),
186
+ right: Math.max(128, width * 0.22),
187
+ bottom: Math.max(58, height * 0.095),
188
+ left: Math.max(70, width * 0.118)
189
+ };
190
+ const chartWidth = Math.max(260, width - margin.left - margin.right);
191
+ const chartHeight = Math.max(330, height - margin.top - margin.bottom);
192
+ const plotX = margin.left;
193
+ const plotY = margin.top;
194
+ const xScale = d3.scaleTime()
195
+ .domain(d3.extent(dates))
196
+ .range([0, chartWidth]);
197
+ const yScale = d3.scaleLinear()
198
+ .domain([0, yMax])
199
+ .nice(5)
200
+ .range([chartHeight, 0]);
201
+
202
+ const defs = svg.append("defs");
203
+ const clipId = `stacked-area-01-plot-${Math.random().toString(36).slice(2, 9)}`;
204
+ defs.append("clipPath")
205
+ .attr("id", clipId)
206
+ .append("rect")
207
+ .attr("x", 0)
208
+ .attr("y", 0)
209
+ .attr("width", chartWidth)
210
+ .attr("height", chartHeight);
211
+
212
+ const g = svg.append("g")
213
+ .attr("class", "stacked-area-plot")
214
+ .attr("data-role", "chart-panel-plot-area")
215
+ .attr("data-reference-local-geometry", "endpoint labels attach to final-year band midpoints")
216
+ .attr("transform", `translate(${plotX}, ${plotY})`);
217
+
218
+ svg.append("rect")
219
+ .attr("x", 0)
220
+ .attr("y", 0)
221
+ .attr("width", width)
222
+ .attr("height", height)
223
+ .attr("fill", pageFill);
224
+
225
+ const yTicks = yScale.ticks(5);
226
+ g.append("g")
227
+ .attr("class", "grid-layer")
228
+ .selectAll("line")
229
+ .data(yTicks)
230
+ .enter()
231
+ .append("line")
232
+ .attr("x1", 0)
233
+ .attr("x2", chartWidth)
234
+ .attr("y1", d => yScale(d))
235
+ .attr("y2", d => yScale(d))
236
+ .attr("stroke", gridColor)
237
+ .attr("stroke-opacity", 0.58)
238
+ .attr("stroke-width", 0.9)
239
+ .attr("stroke-dasharray", "2 5");
240
+
241
+ const area = d3.area()
242
+ .x(d => xScale(d.data.date))
243
+ .y0(d => yScale(d[0]))
244
+ .y1(d => yScale(d[1]))
245
+ .curve(d3.curveLinear);
246
+
247
+ const layerGroup = g.append("g")
248
+ .attr("clip-path", `url(#${clipId})`);
249
+
250
+ const gradientIds = new Map();
251
+ stackedData.forEach((layer, index) => {
252
+ const palette = referenceAreaPalette[index % referenceAreaPalette.length];
253
+ const gradientId = `stacked-area-01-fill-${index}-${Math.random().toString(36).slice(2, 8)}`;
254
+ gradientIds.set(layer.key, gradientId);
255
+ const gradient = defs.append("linearGradient")
256
+ .attr("id", gradientId)
257
+ .attr("x1", "0%")
258
+ .attr("x2", "100%")
259
+ .attr("y1", "0%")
260
+ .attr("y2", "0%");
261
+ gradient.append("stop")
262
+ .attr("offset", "0%")
263
+ .attr("stop-color", palette.top);
264
+ gradient.append("stop")
265
+ .attr("offset", "58%")
266
+ .attr("stop-color", palette.base);
267
+ gradient.append("stop")
268
+ .attr("offset", "100%")
269
+ .attr("stop-color", palette.bottom);
270
+ });
271
+
272
+ layerGroup.selectAll("path.area-layer")
273
+ .data(stackedData)
274
+ .enter()
275
+ .append("path")
276
+ .attr("class", "area-layer")
277
+ .attr("data-series", d => d.key)
278
+ .attr("data-mark-type", "stacked-area-polygon")
279
+ .attr("data-rectangular-end-cap", "not-applicable-continuous-area")
280
+ .attr("fill", d => `url(#${gradientIds.get(d.key)})`)
281
+ .attr("fill-opacity", 0.9)
282
+ .attr("stroke", "#FFFFFF")
283
+ .attr("stroke-opacity", 0.56)
284
+ .attr("stroke-width", 0.8)
285
+ .attr("d", area);
286
+
287
+ g.append("line")
288
+ .attr("x1", 0)
289
+ .attr("x2", chartWidth)
290
+ .attr("y1", chartHeight)
291
+ .attr("y2", chartHeight)
292
+ .attr("stroke", "#8DB4CA")
293
+ .attr("stroke-width", 1);
294
+
295
+ g.selectAll("text.y-tick")
296
+ .data(yTicks)
297
+ .enter()
298
+ .append("text")
299
+ .attr("class", "y-tick")
300
+ .attr("x", -10)
301
+ .attr("y", d => yScale(d))
302
+ .attr("text-anchor", "end")
303
+ .attr("dominant-baseline", "middle")
304
+ .attr("fill", axisColor)
305
+ .style("font-family", fontFamily)
306
+ .style("font-size", `${fontSize(11)}px`)
307
+ .text(d => formatValue(d));
308
+
309
+ const xTicks = dates.length <= 6 ? dates : xScale.ticks(Math.min(6, dates.length));
310
+ g.selectAll("text.x-tick")
311
+ .data(xTicks)
312
+ .enter()
313
+ .append("text")
314
+ .attr("class", "x-tick")
315
+ .attr("x", d => xScale(d))
316
+ .attr("y", chartHeight + 24)
317
+ .attr("text-anchor", "middle")
318
+ .attr("fill", axisColor)
319
+ .style("font-family", fontFamily)
320
+ .style("font-size", `${fontSize(12)}px`)
321
+ .style("font-weight", 500)
322
+ .text(d => formatYear(d));
323
+
324
+ g.append("text")
325
+ .attr("class", "y-axis-title")
326
+ .attr("transform", "rotate(-90)")
327
+ .attr("y", -54)
328
+ .attr("x", -chartHeight / 2)
329
+ .attr("text-anchor", "middle")
330
+ .attr("fill", axisColor)
331
+ .style("font-family", fontFamily)
332
+ .style("font-size", `${fontSize(12)}px`)
333
+ .style("font-weight", 700)
334
+ .text(cleanLabel(yColumn.name || yField));
335
+
336
+ g.append("text")
337
+ .attr("class", "x-axis-title")
338
+ .attr("x", chartWidth / 2)
339
+ .attr("y", chartHeight + 46)
340
+ .attr("text-anchor", "middle")
341
+ .attr("fill", mutedText)
342
+ .style("font-family", fontFamily)
343
+ .style("font-size", `${fontSize(10)}px`)
344
+ .style("font-weight", 600)
345
+ .text(cleanLabel(xColumn.name || xField));
346
+
347
+ const finalIndex = stackData.length - 1;
348
+ const endpointRows = stackedData.map((layer, index) => {
349
+ const point = layer[finalIndex];
350
+ return {
351
+ group: layer.key,
352
+ index,
353
+ anchorY: (yScale(point[0]) + yScale(point[1])) / 2,
354
+ value: stackData[finalIndex][layer.key] || 0
355
+ };
356
+ }).sort((a, b) => a.anchorY - b.anchorY);
357
+
358
+ const minLabelGap = 56;
359
+ endpointRows.forEach((row, index) => {
360
+ const minY = index === 0 ? 18 : endpointRows[index - 1].packedY + minLabelGap;
361
+ row.packedY = Math.max(row.anchorY, minY);
362
+ });
363
+ const overflow = endpointRows.length
364
+ ? endpointRows[endpointRows.length - 1].packedY - (chartHeight - 22)
365
+ : 0;
366
+ if (overflow > 0) {
367
+ for (let i = endpointRows.length - 1; i >= 0; i--) {
368
+ const maxY = i === endpointRows.length - 1 ? chartHeight - 22 : endpointRows[i + 1].packedY - minLabelGap;
369
+ endpointRows[i].packedY = Math.min(endpointRows[i].packedY - overflow, maxY);
370
+ }
371
+ }
372
+
373
+ const endpointByGroup = new Map(endpointRows.map(row => [row.group, row]));
374
+ const endpointLayer = g.append("g")
375
+ .attr("class", "endpoint-label-layer")
376
+ .attr("data-role", "endpoint-series-labels")
377
+ .attr("data-relationship", "attached-centerline-aligned-to-final-year-band-midpoints")
378
+ .attr("data-alignment-basis", "final-year-band-midpoint-plus-collision-aware-vertical-packing");
379
+
380
+ const iconX = chartWidth + 24;
381
+ const textX = chartWidth + 56;
382
+ const endpointGroups = endpointLayer.selectAll("g.endpoint-label")
383
+ .data(stackedData.map(layer => endpointByGroup.get(layer.key)).filter(Boolean))
384
+ .enter()
385
+ .append("g")
386
+ .attr("class", "endpoint-label")
387
+ .attr("data-series", d => d.group)
388
+ .attr("data-anchor-y", d => d.anchorY.toFixed(2))
389
+ .attr("data-packed-y", d => d.packedY.toFixed(2));
390
+
391
+ endpointGroups.append("line")
392
+ .attr("x1", chartWidth - 1)
393
+ .attr("x2", iconX - 24)
394
+ .attr("y1", d => d.anchorY)
395
+ .attr("y2", d => d.packedY)
396
+ .attr("stroke", "#81AFC7")
397
+ .attr("stroke-width", 0.9)
398
+ .attr("stroke-opacity", 0.62);
399
+
400
+ endpointGroups.append("circle")
401
+ .attr("class", "endpoint-series-icon-slot")
402
+ .attr("cx", iconX)
403
+ .attr("cy", d => d.packedY)
404
+ .attr("r", 23)
405
+ .attr("fill", "#FFFFFF")
406
+ .attr("stroke", "#D7E8F2")
407
+ .attr("stroke-width", 1.2)
408
+ .attr("data-asset-slot", "endpoint-series-icon")
409
+ .attr("data-slot-policy", "reserved-semantic-slot-no-raster")
410
+ .attr("data-asset-source-policy", "none-in-source-data")
411
+ .attr("data-anchor", "final-year-stacked-area-band-midpoint")
412
+ .attr("data-bbox-policy", "circle-r23-centered-on-packed-endpoint-label")
413
+ .attr("data-collision-rule", "packed with endpoint labels inside right gutter")
414
+ .attr("data-no-embedded-raster", "true")
415
+ .attr("data-no-copied-reference-art", "true");
416
+
417
+ endpointGroups.append("circle")
418
+ .attr("class", "endpoint-series-icon-slot-inner")
419
+ .attr("cx", iconX)
420
+ .attr("cy", d => d.packedY)
421
+ .attr("r", 15)
422
+ .attr("fill", "none")
423
+ .attr("stroke", "#A9CFE2")
424
+ .attr("stroke-width", 1)
425
+ .attr("stroke-dasharray", "3 4")
426
+ .attr("opacity", 0.9)
427
+ .attr("data-asset-placeholder", "neutral-circular-slot")
428
+ .attr("data-no-embedded-raster", "true")
429
+ .attr("data-no-copied-reference-art", "true");
430
+
431
+ endpointGroups.append("text")
432
+ .attr("x", textX)
433
+ .attr("y", d => d.packedY - 3)
434
+ .attr("text-anchor", "start")
435
+ .attr("fill", textColor)
436
+ .style("font-family", fontFamily)
437
+ .style("font-size", `${fontSize(10.5)}px`)
438
+ .style("font-weight", 700)
439
+ .each(function(d) {
440
+ const label = truncate(d.group, 32);
441
+ const words = label.split(/\s+/);
442
+ const first = words.slice(0, Math.ceil(words.length / 2)).join(" ");
443
+ const second = words.slice(Math.ceil(words.length / 2)).join(" ");
444
+ const text = d3.select(this);
445
+ text.append("tspan").attr("x", textX).attr("dy", 0).text(first);
446
+ if (second) text.append("tspan").attr("x", textX).attr("dy", 12).text(second);
447
+ });
448
+
449
+ return svg.node();
450
+ }
modules/chart_engine/template/d3-js/area_chart/stacked_area_chart_02.js ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Stacked Area Chart",
5
+ "chart_name": "stacked_area_chart_02",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[2, 10], [0, "inf"], [2, 6]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 600,
15
+ "min_width": 800,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ const jsonData = data;
27
+ const chartData = jsonData.data.data;
28
+ const variables = jsonData.variables;
29
+ const typography = jsonData.typography;
30
+ const colors = jsonData.colors || {};
31
+ const colorResolver = chartUtils.color.resolver(jsonData);
32
+ const dataColumns = chartUtils.schema.columns(jsonData);
33
+ const images = jsonData.images || {};
34
+
35
+ d3.select(containerSelector).html("");
36
+
37
+ // 获取字段名
38
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
39
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
40
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
41
+
42
+ const parseTemporalValue = value => {
43
+ if (value instanceof Date) return value.getTime();
44
+ if (typeof value === "number" && Number.isFinite(value)) return value;
45
+ const text = String(value ?? "").trim();
46
+ if (/^\d{4}$/.test(text)) return +text;
47
+ const parsed = Date.parse(text);
48
+ return Number.isNaN(parsed) ? null : parsed;
49
+ };
50
+
51
+ const isTemporalX = (dataColumns[0] && dataColumns[0].data_type === "temporal")
52
+ || chartData.some(d => parseTemporalValue(d[xField]) !== null);
53
+
54
+ // Keep larger average groups lower in the stack, matching the selected reference.
55
+ const groups = [...new Set(chartData.map(d => d[groupField]))]
56
+ .sort((a, b) => {
57
+ const avgA = d3.mean(chartData.filter(d => d[groupField] === a), d => +d[yField]);
58
+ const avgB = d3.mean(chartData.filter(d => d[groupField] === b), d => +d[yField]);
59
+ return avgB - avgA;
60
+ });
61
+
62
+ // 找出所有组都有数据的x值
63
+ const xValuesByGroup = {};
64
+ groups.forEach(group => {
65
+ xValuesByGroup[group] = new Set(
66
+ chartData.filter(d => d[groupField] === group).map(d => d[xField])
67
+ );
68
+ });
69
+
70
+ // 找出所有组共有的x值
71
+ let commonXValues = [...xValuesByGroup[groups[0]]];
72
+ for (let i = 1; i < groups.length; i++) {
73
+ commonXValues = commonXValues.filter(x => xValuesByGroup[groups[i]].has(x));
74
+ }
75
+
76
+ // Temporal fields must render chronologically by default.
77
+ commonXValues.sort((a, b) => {
78
+ if (isTemporalX) {
79
+ const ta = parseTemporalValue(a);
80
+ const tb = parseTemporalValue(b);
81
+ if (ta !== null && tb !== null && ta !== tb) return ta - tb;
82
+ }
83
+ const aIndex = chartData.findIndex(d => d[xField] === a);
84
+ const bIndex = chartData.findIndex(d => d[xField] === b);
85
+ return aIndex - bIndex;
86
+ });
87
+
88
+ // 过滤数据,只保留所有组都有的x值
89
+ const filteredData = chartData.filter(d => commonXValues.includes(d[xField]));
90
+
91
+ // 设置尺寸和边距
92
+ const width = variables.width;
93
+ const height = variables.height;
94
+ const margin = { top: 44, right: 22, bottom: 62, left: 76 };
95
+
96
+ // 创建SVG
97
+ const svg = d3.select(containerSelector)
98
+ .append("svg")
99
+ .attr("width", "100%")
100
+ .attr("height", height)
101
+ .attr("viewBox", `0 0 ${width} ${height}`)
102
+ .attr("style", "max-width: 100%; height: auto;")
103
+ .attr("xmlns", "http://www.w3.org/2000/svg")
104
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
105
+
106
+ const defs = svg.append("defs");
107
+ const shadow = defs.append("filter")
108
+ .attr("id", "stacked-area-02-legend-shadow")
109
+ .attr("x", "-15%")
110
+ .attr("y", "-70%")
111
+ .attr("width", "130%")
112
+ .attr("height", "240%");
113
+ shadow.append("feDropShadow")
114
+ .attr("dx", 0)
115
+ .attr("dy", 6)
116
+ .attr("stdDeviation", 7)
117
+ .attr("flood-color", "#56708c")
118
+ .attr("flood-opacity", 0.22);
119
+
120
+ // 创建图表区域
121
+ const chartWidth = width - margin.left - margin.right;
122
+ const chartHeight = height - margin.top - margin.bottom;
123
+
124
+ const g = svg.append("g")
125
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
126
+
127
+ // 创建x轴比例尺 - 使用分类比例尺
128
+ const xScale = d3.scaleBand()
129
+ .domain(commonXValues)
130
+ .range([0, chartWidth])
131
+ .padding(0.28);
132
+
133
+ // 为堆叠数据准备
134
+ const groupedData = d3.group(filteredData, d => d[xField]);
135
+
136
+ // 转换为堆叠格式
137
+ const stackData = Array.from(groupedData, ([key, values]) => {
138
+ const obj = { category: key };
139
+ values.forEach(v => {
140
+ obj[v[groupField]] = v[yField];
141
+ });
142
+ return obj;
143
+ });
144
+
145
+ // 确保所有组都有值(如果某些类别缺少某组的数据,填充为0)
146
+ stackData.forEach(d => {
147
+ groups.forEach(group => {
148
+ if (d[group] === undefined) {
149
+ d[group] = 0;
150
+ }
151
+ });
152
+ });
153
+
154
+ // 按照原始x值顺序排序
155
+ stackData.sort((a, b) => {
156
+ return commonXValues.indexOf(a.category) - commonXValues.indexOf(b.category);
157
+ });
158
+
159
+ // 计算每个类别的总和,并转换为百分比
160
+ stackData.forEach(d => {
161
+ const total = groups.reduce((sum, group) => sum + d[group], 0);
162
+ groups.forEach(group => {
163
+ d[group + "_original"] = d[group]; // 保存原始值
164
+ d[group] = (d[group] / total) * 100; // 转换为百分比
165
+ });
166
+ });
167
+
168
+ // 创建堆叠生成器
169
+ const stack = d3.stack()
170
+ .keys(groups)
171
+ .order(d3.stackOrderNone)
172
+ .offset(d3.stackOffsetNone);
173
+
174
+ // 生成堆叠数据
175
+ const stackedData = stack(stackData);
176
+
177
+ // 创建y轴比例尺 (固定为0-100%)
178
+ const yScale = d3.scaleLinear()
179
+ .domain([0, 100])
180
+ .range([chartHeight, 0]);
181
+
182
+ // 添加Y轴网格线
183
+ const yTicks = [0, 20, 40, 60, 80, 100];
184
+ yTicks.forEach(tick => {
185
+ g.append("line")
186
+ .attr("x1", 0)
187
+ .attr("y1", yScale(tick))
188
+ .attr("x2", chartWidth)
189
+ .attr("y2", yScale(tick))
190
+ .attr("stroke", "#cfd8e3")
191
+ .attr("stroke-width", 1)
192
+ .attr("stroke-dasharray", "4,4")
193
+ .attr("opacity", 0.88);
194
+
195
+ g.append("text")
196
+ .attr("x", -10)
197
+ .attr("y", yScale(tick))
198
+ .attr("text-anchor", "end")
199
+ .attr("dominant-baseline", "middle")
200
+ .attr("fill", "#26384f")
201
+ .attr("font-weight", "700")
202
+ .style("font-size", "14px")
203
+ .text(tick + "%");
204
+ });
205
+
206
+ g.append("text")
207
+ .attr("class", "y-axis-title")
208
+ .attr("x", -52)
209
+ .attr("y", chartHeight / 2)
210
+ .attr("text-anchor", "middle")
211
+ .attr("transform", `rotate(-90, -52, ${chartHeight / 2})`)
212
+ .attr("fill", "#21334f")
213
+ .attr("font-weight", "700")
214
+ .style("font-family", typography.label?.font_family || "Arial")
215
+ .style("font-size", "16px")
216
+ .text(dataColumns[1]?.name === yField ? yField.replace(/([a-z])([A-Z])/g, "$1 $2") : yField);
217
+
218
+ // 添加X轴刻度和标签
219
+ commonXValues.forEach(category => {
220
+ g.append("text")
221
+ .attr("x", xScale(category) + xScale.bandwidth() / 2)
222
+ .attr("y", chartHeight + 25)
223
+ .attr("text-anchor", "middle")
224
+ .style("font-family", "Arial")
225
+ .style("font-size", "14px")
226
+ .style("font-weight", "700")
227
+ .style("fill", "#26384f")
228
+ .text(category);
229
+ });
230
+
231
+ // 添加X轴线
232
+ g.append("line")
233
+ .attr("x1", 0)
234
+ .attr("y1", chartHeight)
235
+ .attr("x2", chartWidth)
236
+ .attr("y2", chartHeight)
237
+ .attr("stroke", "#6d7d91")
238
+ .attr("stroke-width", 1.2);
239
+
240
+ const markLayer = g.append("g").attr("class", "stacked-column-layer");
241
+
242
+ commonXValues.forEach((category, categoryIndex) => {
243
+ const barData = stackData.find(d => d.category === category);
244
+ if (!barData) return;
245
+
246
+ const barX = xScale(category);
247
+ const barWidth = xScale.bandwidth();
248
+ const clipId = `stacked-area-02-rounded-column-${categoryIndex}`;
249
+ defs.append("clipPath")
250
+ .attr("id", clipId)
251
+ .append("rect")
252
+ .attr("x", barX)
253
+ .attr("y", 0)
254
+ .attr("width", barWidth)
255
+ .attr("height", chartHeight)
256
+ .attr("rx", Math.min(10, barWidth * 0.18))
257
+ .attr("ry", Math.min(10, barWidth * 0.18));
258
+
259
+ const barGroup = markLayer.append("g")
260
+ .attr("class", "stacked-column")
261
+ .attr("data-x-value", category)
262
+ .attr("data-mark-endcap", "rounded-outer-corners-flat-internal-joins")
263
+ .attr("clip-path", `url(#${clipId})`);
264
+
265
+ let cumulativeHeight = 0;
266
+
267
+ groups.forEach((group, groupIndex) => {
268
+ const value = barData[group];
269
+ const height = (value / 100) * chartHeight;
270
+ const y = chartHeight - cumulativeHeight - height;
271
+
272
+ const color = colorResolver.field(group, groupIndex, { palette: "category10" }).value;
273
+ const gradientId = `stacked-area-02-gradient-${categoryIndex}-${groupIndex}`;
274
+ const gradient = defs.append("linearGradient")
275
+ .attr("id", gradientId)
276
+ .attr("x1", "0%")
277
+ .attr("y1", "0%")
278
+ .attr("x2", "100%")
279
+ .attr("y2", "100%");
280
+ gradient.append("stop")
281
+ .attr("offset", "0%")
282
+ .attr("stop-color", chartUtils.color.scaleRgb(color, 1.08));
283
+ gradient.append("stop")
284
+ .attr("offset", "100%")
285
+ .attr("stop-color", chartUtils.color.scaleRgb(color, 0.92));
286
+
287
+ barGroup.append("rect")
288
+ .attr("class", "stacked-segment")
289
+ .attr("data-series", group)
290
+ .attr("data-value", barData[group + "_original"])
291
+ .attr("data-percent", value)
292
+ .attr("data-stacked-join", groupIndex === 0 ? "baseline-rounded-by-column-clip" : "flat-internal-join")
293
+ .attr("x", barX)
294
+ .attr("y", y)
295
+ .attr("width", barWidth)
296
+ .attr("height", height)
297
+ .attr("fill", `url(#${gradientId})`);
298
+
299
+ const minHeightForLabel = 34;
300
+
301
+ if (height >= minHeightForLabel) {
302
+ const labelX = barX + barWidth / 2;
303
+ const labelY = y + height / 2;
304
+ const originalValue = barData[group + "_original"];
305
+
306
+ g.append("text")
307
+ .attr("class", "segment-label-percent")
308
+ .attr("data-series", group)
309
+ .attr("data-x-value", category)
310
+ .attr("x", labelX)
311
+ .attr("y", labelY - 7)
312
+ .attr("text-anchor", "middle")
313
+ .attr("dominant-baseline", "middle")
314
+ .attr("fill", "#fff")
315
+ .attr("font-weight", "800")
316
+ .style("font-size", "15px")
317
+ .style("paint-order", "stroke")
318
+ .style("stroke", "rgba(0,0,0,0.16)")
319
+ .style("stroke-width", "1px")
320
+ .style("stroke-linejoin", "round")
321
+ .text(chartUtils.format.percent(value).text);
322
+
323
+ g.append("text")
324
+ .attr("class", "segment-label-raw")
325
+ .attr("data-series", group)
326
+ .attr("data-x-value", category)
327
+ .attr("x", labelX)
328
+ .attr("y", labelY + 8)
329
+ .attr("text-anchor", "middle")
330
+ .attr("dominant-baseline", "middle")
331
+ .attr("fill", "rgba(255,255,255,0.92)")
332
+ .attr("font-weight", "700")
333
+ .style("font-size", "12px")
334
+ .style("paint-order", "stroke")
335
+ .style("stroke", "rgba(0,0,0,0.18)")
336
+ .style("stroke-width", "0.8px")
337
+ .style("stroke-linejoin", "round")
338
+ .text(`(${chartUtils.format.fixed(originalValue).text})`);
339
+ }
340
+
341
+ cumulativeHeight += height;
342
+ });
343
+ });
344
+
345
+ const legendGroup = g.append("g")
346
+ .attr("class", "legend-pill")
347
+ .attr("data-coordinate-relationship", "independent-floating-card")
348
+ .attr("data-anchor", "between-subtitle-and-plot")
349
+ .attr("data-collision-rule", "avoid-title-subtitle-and-plot");
350
+
351
+ const titleWidth = chartUtils.text.measure(null, groupField, { fontSize: 16, fontWeight: "bold" }).width;
352
+ const titleMargin = 15;
353
+
354
+ const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
355
+ x: titleWidth + titleMargin,
356
+ y: 0,
357
+ fontSize: 14,
358
+ fontWeight: "bold",
359
+ align: "left",
360
+ maxWidth: chartWidth - titleWidth - titleMargin,
361
+ shape: "rect",
362
+ });
363
+
364
+ legendGroup.append("text")
365
+ .attr("class", "legend-title")
366
+ .attr("x", 0)
367
+ .attr("y", legendSize.height / 2)
368
+ .attr("dominant-baseline", "middle")
369
+ .attr("fill", "#1e2b3e")
370
+ .style("font-size", "16px")
371
+ .style("font-weight", "800")
372
+ .text(groupField);
373
+
374
+ const legendBBox = legendGroup.node().getBBox();
375
+ const padX = 18;
376
+ const padY = 10;
377
+ legendGroup.insert("rect", ":first-child")
378
+ .attr("class", "legend-pill-bg")
379
+ .attr("x", legendBBox.x - padX)
380
+ .attr("y", legendBBox.y - padY)
381
+ .attr("width", legendBBox.width + padX * 2)
382
+ .attr("height", legendBBox.height + padY * 2)
383
+ .attr("rx", 18)
384
+ .attr("ry", 18)
385
+ .attr("fill", "#ffffff")
386
+ .attr("stroke", "rgba(100,130,160,0.14)")
387
+ .attr("filter", "url(#stacked-area-02-legend-shadow)")
388
+ .attr("data-legend-relationship", "independent-floating-card");
389
+
390
+ const legendTotalWidth = legendBBox.width + padX * 2;
391
+ legendGroup.attr("transform", `translate(${(chartWidth - legendTotalWidth) / 2 - legendBBox.x + padX}, ${-42 - legendSize.height/2})`);
392
+
393
+ return svg.node();
394
+ }