Ray1ee01 commited on
Commit
3ea14e2
·
verified ·
1 Parent(s): 3507b45

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type36_range_area_chart/range_area_chart_icons_01.js ADDED
@@ -0,0 +1,580 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Range Area Chart",
5
+ "chart_name": "range_area_chart_icons_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 chartData = jsonData.data.data;
29
+ const variables = jsonData.variables;
30
+ const dataColumns = chartUtils.schema.columns(jsonData);
31
+ const colorResolver = chartUtils.color.resolver(jsonData);
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 yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
39
+ const yDescription = yColumn.description || yField;
40
+ const yUnit = yColumn.unit || "";
41
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
42
+
43
+ const width = variables.width || 600;
44
+ const height = variables.height || 600;
45
+
46
+ const svg = d3.select(containerSelector)
47
+ .append("svg")
48
+ .attr("width", "100%")
49
+ .attr("height", height)
50
+ .attr("viewBox", `0 0 ${width} ${height}`)
51
+ .attr("style", "max-width: 100%; height: auto;")
52
+ .attr("xmlns", "http://www.w3.org/2000/svg")
53
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
54
+ .attr("data-no-embedded-raster", "true")
55
+ .attr("data-no-copied-reference-art", "true");
56
+
57
+ const defs = svg.append("defs");
58
+ const shadowId = `range-panel-shadow-${Math.round(width)}-${Math.round(height)}`;
59
+ const shadow = defs.append("filter")
60
+ .attr("id", shadowId)
61
+ .attr("x", "-8%")
62
+ .attr("y", "-8%")
63
+ .attr("width", "116%")
64
+ .attr("height", "116%");
65
+ shadow.append("feDropShadow")
66
+ .attr("dx", 0)
67
+ .attr("dy", 4)
68
+ .attr("stdDeviation", 5)
69
+ .attr("flood-color", "#0b1b2e")
70
+ .attr("flood-opacity", 0.12);
71
+
72
+ const panel = {
73
+ x: width * 0.025,
74
+ y: height * 0.035,
75
+ w: width * 0.95,
76
+ h: height * 0.92
77
+ };
78
+ const rightLaneW = Math.max(78, panel.w * 0.16);
79
+ const plot = {
80
+ x: panel.x + panel.w * 0.075,
81
+ y: panel.y + panel.h * 0.31,
82
+ w: panel.w - panel.w * 0.075 - rightLaneW - panel.w * 0.045,
83
+ h: panel.h * 0.58
84
+ };
85
+ plot.bottom = plot.y + plot.h;
86
+ plot.right = plot.x + plot.w;
87
+
88
+ svg.append("rect")
89
+ .attr("x", panel.x)
90
+ .attr("y", panel.y)
91
+ .attr("width", panel.w)
92
+ .attr("height", panel.h)
93
+ .attr("rx", 16)
94
+ .attr("fill", "#ffffff")
95
+ .attr("stroke", "#d9e4e6")
96
+ .attr("stroke-width", 1)
97
+ .attr("filter", `url(#${shadowId})`);
98
+
99
+ const slot = svg.append("g")
100
+ .attr("class", "asset-slot panel-header-icon-slot")
101
+ .attr("data-asset-slot", "panel-header-icon")
102
+ .attr("data-asset-anchor", "panel-header-left")
103
+ .attr("data-asset-bbox", `${Math.round(panel.x + 21)},${Math.round(panel.y + 27)},52,52`)
104
+ .attr("data-collision-policy", "avoid-panel-title-legend-plot")
105
+ .attr("data-asset-source-policy", "neutral_placeholder")
106
+ .attr("data-no-embedded-raster", "true")
107
+ .attr("data-no-copied-reference-art", "true");
108
+
109
+ slot.append("circle")
110
+ .attr("cx", panel.x + 47)
111
+ .attr("cy", panel.y + 53)
112
+ .attr("r", 26)
113
+ .attr("fill", "#078b8b")
114
+ .attr("opacity", 0.95);
115
+ slot.append("circle")
116
+ .attr("cx", panel.x + 47)
117
+ .attr("cy", panel.y + 53)
118
+ .attr("r", 16)
119
+ .attr("fill", "none")
120
+ .attr("stroke", "#ffffff")
121
+ .attr("stroke-width", 2)
122
+ .attr("stroke-dasharray", "3 4")
123
+ .attr("opacity", 0.85);
124
+
125
+ const titleX = panel.x + 84;
126
+ const panelTitle = `${yDescription}`;
127
+ const years = chartData.map(d => chartUtils.format.parseDate(d[xField]).getFullYear()).filter(d => Number.isFinite(d));
128
+ const yearLabel = years.length ? `${d3.min(years)}-${d3.max(years)}` : "";
129
+
130
+ svg.append("text")
131
+ .attr("x", titleX)
132
+ .attr("y", panel.y + 46)
133
+ .attr("fill", "#071a33")
134
+ .style("font-family", "Arial, sans-serif")
135
+ .style("font-size", `${fontSize(14)}px`)
136
+ .style("font-weight", 800)
137
+ .text(panelTitle.toUpperCase());
138
+
139
+ if (yearLabel) {
140
+ svg.append("text")
141
+ .attr("x", panel.x + panel.w - 116)
142
+ .attr("y", panel.y + 46)
143
+ .attr("fill", "#324255")
144
+ .style("font-family", "Arial, sans-serif")
145
+ .style("font-size", `${fontSize(14)}px`)
146
+ .style("font-weight", 400)
147
+ .text(yearLabel);
148
+ }
149
+
150
+ const groupNames = [...new Set(chartData.map(d => d[groupField]))];
151
+ const groupAverages = groupNames.map(group => {
152
+ const groupRows = chartData.filter(d => d[groupField] === group);
153
+ return { group, avg: d3.mean(groupRows, d => +d[yField]) };
154
+ }).sort((a, b) => d3.descending(a.avg, b.avg));
155
+
156
+ const selectedGroups = groupAverages.slice(0, 2).map(d => d.group);
157
+ if (selectedGroups.length < 2) {
158
+ return svg.node();
159
+ }
160
+
161
+ const parseX = value => {
162
+ const parsed = chartUtils.format.parseDate(value);
163
+ if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed;
164
+ if (Number.isFinite(+value)) return new Date(+value, 0, 1);
165
+ return new Date(value);
166
+ };
167
+
168
+ const grouped = selectedGroups.map(group => ({
169
+ group,
170
+ rows: chartData
171
+ .filter(d => d[groupField] === group)
172
+ .map(d => ({ ...d, __x: parseX(d[xField]), __y: +d[yField] }))
173
+ .filter(d => d.__x instanceof Date && !Number.isNaN(d.__x.getTime()) && Number.isFinite(d.__y))
174
+ .sort((a, b) => d3.ascending(a.__x, b.__x))
175
+ }));
176
+
177
+ const allRows = grouped.flatMap(d => d.rows);
178
+ const yMinRaw = d3.min(allRows, d => d.__y);
179
+ const yMaxRaw = d3.max(allRows, d => d.__y);
180
+ const yMin = Math.floor(yMinRaw / 5) * 5;
181
+ const yMax = Math.ceil(yMaxRaw / 5) * 5;
182
+ const yDomain = yMin === yMax ? [yMin - 1, yMax + 1] : [yMin, yMax];
183
+
184
+ const xScale = d3.scaleTime()
185
+ .domain(d3.extent(allRows, d => d.__x))
186
+ .range([plot.x, plot.right]);
187
+ const yScale = d3.scaleLinear()
188
+ .domain(yDomain)
189
+ .nice(5)
190
+ .range([plot.bottom, plot.y]);
191
+
192
+ const warm = "#f2a000";
193
+ const teal = "#078b8b";
194
+ const series = [
195
+ {
196
+ ...grouped[0],
197
+ color: warm,
198
+ fill: "#f8d46b",
199
+ labelPosition: "above"
200
+ },
201
+ {
202
+ ...grouped[1],
203
+ color: teal,
204
+ fill: "#88d8d5",
205
+ labelPosition: "above"
206
+ }
207
+ ];
208
+
209
+ const legendY = panel.y + 88;
210
+ series.forEach((s, i) => {
211
+ const lx = titleX + i * Math.min(150, panel.w * 0.25);
212
+ svg.append("line")
213
+ .attr("x1", lx)
214
+ .attr("y1", legendY)
215
+ .attr("x2", lx + 28)
216
+ .attr("y2", legendY)
217
+ .attr("stroke", s.color)
218
+ .attr("stroke-width", 5)
219
+ .attr("stroke-linecap", "round");
220
+ svg.append("text")
221
+ .attr("x", lx + 38)
222
+ .attr("y", legendY + 5)
223
+ .attr("fill", "#102238")
224
+ .style("font-family", "Arial, sans-serif")
225
+ .style("font-size", `${fontSize(14)}px`)
226
+ .text(s.group);
227
+ });
228
+
229
+ const yAxisLabel = `${chartUtils.schema.columnField(dataColumns, 1)}${yUnit ? ` (${yUnit})` : ""}`;
230
+ svg.append("text")
231
+ .attr("x", plot.x - 34)
232
+ .attr("y", plot.y - 18)
233
+ .attr("fill", "#102238")
234
+ .style("font-family", "Arial, sans-serif")
235
+ .style("font-size", `${fontSize(12)}px`)
236
+ .text(yAxisLabel);
237
+
238
+ const yTicks = yScale.ticks(5);
239
+ yTicks.forEach(tick => {
240
+ svg.append("line")
241
+ .attr("x1", plot.x)
242
+ .attr("x2", plot.right)
243
+ .attr("y1", yScale(tick))
244
+ .attr("y2", yScale(tick))
245
+ .attr("stroke", "#d8e0e5")
246
+ .attr("stroke-width", 1)
247
+ .attr("stroke-dasharray", "5 5");
248
+ svg.append("text")
249
+ .attr("x", plot.x - 11)
250
+ .attr("y", yScale(tick) + 4)
251
+ .attr("text-anchor", "end")
252
+ .attr("fill", "#102238")
253
+ .style("font-family", "Arial, sans-serif")
254
+ .style("font-size", `${fontSize(12)}px`)
255
+ .text(chartUtils.format.autoText(+tick) + (yUnit === "%" ? "%" : ""));
256
+ });
257
+
258
+ const xTicks = allRows.map(d => d.__x)
259
+ .filter((d, i, arr) => arr.findIndex(v => +v === +d) === i)
260
+ .sort((a, b) => d3.ascending(a, b));
261
+ const maxVisibleXTicks = 7;
262
+ const tickStep = xTicks.length > maxVisibleXTicks
263
+ ? Math.ceil((xTicks.length - 1) / (maxVisibleXTicks - 1))
264
+ : 1;
265
+ const visibleXTicks = xTicks.filter((tick, i) =>
266
+ i === 0 || i === xTicks.length - 1 || i % tickStep === 0
267
+ );
268
+ visibleXTicks.forEach(tick => {
269
+ const x = xScale(tick);
270
+ svg.append("line")
271
+ .attr("x1", x)
272
+ .attr("x2", x)
273
+ .attr("y1", plot.y)
274
+ .attr("y2", plot.bottom)
275
+ .attr("stroke", "#e2e8ec")
276
+ .attr("stroke-width", 1)
277
+ .attr("stroke-dasharray", "5 5");
278
+ svg.append("text")
279
+ .attr("x", x)
280
+ .attr("y", plot.bottom + 24)
281
+ .attr("text-anchor", "middle")
282
+ .attr("fill", "#102238")
283
+ .style("font-family", "Arial, sans-serif")
284
+ .style("font-size", `${fontSize(13)}px`)
285
+ .text(tick.getFullYear());
286
+ });
287
+
288
+ svg.append("line")
289
+ .attr("x1", plot.x)
290
+ .attr("x2", plot.right)
291
+ .attr("y1", plot.bottom)
292
+ .attr("y2", plot.bottom)
293
+ .attr("stroke", "#102238")
294
+ .attr("stroke-width", 1.4);
295
+ svg.append("line")
296
+ .attr("x1", plot.x)
297
+ .attr("x2", plot.x)
298
+ .attr("y1", plot.y)
299
+ .attr("y2", plot.bottom)
300
+ .attr("stroke", "#102238")
301
+ .attr("stroke-width", 1.4);
302
+
303
+ const area = d3.area()
304
+ .x(d => xScale(d.__x))
305
+ .y0(plot.bottom)
306
+ .y1(d => yScale(d.__y))
307
+ .curve(d3.curveLinear);
308
+ const line = d3.line()
309
+ .x(d => xScale(d.__x))
310
+ .y(d => yScale(d.__y))
311
+ .curve(d3.curveLinear);
312
+
313
+ series.forEach(s => {
314
+ svg.append("path")
315
+ .datum(s.rows)
316
+ .attr("d", area)
317
+ .attr("fill", s.fill)
318
+ .attr("opacity", 0.28);
319
+ });
320
+
321
+ series.forEach(s => {
322
+ svg.append("path")
323
+ .datum(s.rows)
324
+ .attr("d", line)
325
+ .attr("fill", "none")
326
+ .attr("stroke", s.color)
327
+ .attr("stroke-width", 4)
328
+ .attr("stroke-linecap", "round")
329
+ .attr("stroke-linejoin", "round");
330
+ });
331
+
332
+ const cardW = Math.max(72, rightLaneW * 0.78);
333
+ const cardH = 64;
334
+ const cardX = panel.x + panel.w - cardW - 13;
335
+ const markerR = 7;
336
+ const placedLabelBoxes = [];
337
+ const estimateLabelBox = (label, x, y, anchor) => {
338
+ const w = String(label).length * fontSize(14) * 0.62;
339
+ const h = fontSize(14) + 4;
340
+ const left = anchor === "end" ? x - w : anchor === "start" ? x : x - w / 2;
341
+ return { x0: left - 3, y0: y - h + 1, x1: left + w + 3, y1: y + 4 };
342
+ };
343
+ const boxesOverlap = (a, b) => !(a.x1 < b.x0 || a.x0 > b.x1 || a.y1 < b.y0 || a.y0 > b.y1);
344
+ const shouldHideDenseDuplicate = (rows, i, xPositions) => {
345
+ if (rows.length <= 10 || i === 0 || i === rows.length - 1) return false;
346
+ const value = Math.round(rows[i].__y);
347
+ const prevSame = Math.round(rows[i - 1].__y) === value;
348
+ const nextSame = Math.round(rows[i + 1].__y) === value;
349
+ const closePrev = Math.abs(xPositions[i] - xPositions[i - 1]) < 30;
350
+ const closeNext = Math.abs(xPositions[i + 1] - xPositions[i]) < 30;
351
+ return prevSame && nextSame && (closePrev || closeNext);
352
+ };
353
+ const placeValueLabel = (s, d, i, xPositions) => {
354
+ const label = Math.round(d.__y);
355
+ const x = xPositions[i];
356
+ const y = yScale(d.__y);
357
+ const isLast = i === s.rows.length - 1;
358
+ if (shouldHideDenseDuplicate(s.rows, i, xPositions)) return;
359
+
360
+ const anchor = isLast ? "end" : "middle";
361
+ const labelX = isLast ? x - 10 : x;
362
+ const candidates = [
363
+ Math.max(plot.y + 14, y - 15),
364
+ Math.max(plot.y + 14, y - 29),
365
+ Math.min(plot.bottom - 8, y + 25),
366
+ Math.max(plot.y + 14, y - 43)
367
+ ];
368
+ let chosen = null;
369
+ for (const candidateY of candidates) {
370
+ const box = estimateLabelBox(label, labelX, candidateY, anchor);
371
+ const insidePlot = box.x0 >= plot.x - 18 && box.x1 <= cardX - 6 && box.y0 >= plot.y - 4 && box.y1 <= plot.bottom + 2;
372
+ if (insidePlot && !placedLabelBoxes.some(other => boxesOverlap(box, other))) {
373
+ chosen = { y: candidateY, box };
374
+ break;
375
+ }
376
+ }
377
+ if (!chosen && (i === 0 || isLast || i % 2 === 0)) {
378
+ const fallbackY = candidates[0];
379
+ const box = estimateLabelBox(label, labelX, fallbackY, anchor);
380
+ if (!placedLabelBoxes.some(other => boxesOverlap(box, other))) {
381
+ chosen = { y: fallbackY, box };
382
+ }
383
+ }
384
+ if (!chosen) return;
385
+ placedLabelBoxes.push(chosen.box);
386
+
387
+ svg.append("text")
388
+ .attr("x", labelX)
389
+ .attr("y", chosen.y)
390
+ .attr("text-anchor", anchor)
391
+ .attr("fill", s.color)
392
+ .style("font-family", "Arial, sans-serif")
393
+ .style("font-size", `${fontSize(14)}px`)
394
+ .style("font-weight", 800)
395
+ .text(label);
396
+ };
397
+
398
+ series.forEach(s => {
399
+ const xPositions = s.rows.map(d => xScale(d.__x));
400
+ s.rows.forEach((d, i) => {
401
+ const x = xScale(d.__x);
402
+ const y = yScale(d.__y);
403
+ svg.append("circle")
404
+ .attr("cx", x)
405
+ .attr("cy", y)
406
+ .attr("r", markerR)
407
+ .attr("fill", s.color)
408
+ .attr("stroke", "#ffffff")
409
+ .attr("stroke-width", 3);
410
+ });
411
+ s.rows.forEach((d, i) => placeValueLabel(s, d, i, xPositions));
412
+ });
413
+
414
+ const endpointLabelLines = label => {
415
+ const normalized = String(label)
416
+ .toUpperCase()
417
+ .replace(/\bMANAGEMENT\b/g, "MGMT")
418
+ .replace(/\bAND\b/g, "&");
419
+ const words = normalized.split(/\s+/).filter(Boolean);
420
+ const lines = [];
421
+ let current = "";
422
+ words.forEach(word => {
423
+ const trial = current ? `${current} ${word}` : word;
424
+ if (current && trial.length > 11) {
425
+ lines.push(current);
426
+ current = word;
427
+ } else {
428
+ current = trial;
429
+ }
430
+ });
431
+ if (current) lines.push(current);
432
+ if (lines.length > 2) {
433
+ return [lines[0], lines.slice(1).join(" ")];
434
+ }
435
+ return lines.length ? lines : [normalized];
436
+ };
437
+ const lastItems = series.map(s => {
438
+ const point = s.rows[s.rows.length - 1];
439
+ return {
440
+ series: s,
441
+ point,
442
+ x: xScale(point.__x),
443
+ y: yScale(point.__y),
444
+ cardY: yScale(point.__y) - cardH / 2
445
+ };
446
+ }).sort((a, b) => d3.ascending(a.y, b.y));
447
+
448
+ const minGap = 18;
449
+ if (lastItems.length === 2 && lastItems[1].cardY < lastItems[0].cardY + cardH + minGap) {
450
+ const mid = (lastItems[0].cardY + lastItems[1].cardY + cardH) / 2;
451
+ lastItems[0].cardY = mid - cardH - minGap / 2;
452
+ lastItems[1].cardY = mid + minGap / 2;
453
+ }
454
+ lastItems.forEach(item => {
455
+ item.cardY = Math.max(panel.y + 104, Math.min(panel.y + panel.h - cardH - 18, item.cardY));
456
+ item.cardCenterY = item.cardY + cardH / 2;
457
+ });
458
+
459
+ lastItems.forEach(item => {
460
+ svg.append("line")
461
+ .attr("x1", item.x + markerR)
462
+ .attr("x2", cardX)
463
+ .attr("y1", item.y)
464
+ .attr("y2", item.cardCenterY)
465
+ .attr("stroke", item.series.color)
466
+ .attr("stroke-width", 2)
467
+ .attr("stroke-linecap", "round");
468
+
469
+ svg.append("rect")
470
+ .attr("x", cardX)
471
+ .attr("y", item.cardY)
472
+ .attr("width", cardW)
473
+ .attr("height", cardH)
474
+ .attr("rx", 10)
475
+ .attr("fill", item.series.color === warm ? "#fff7df" : "#e7fbfa")
476
+ .attr("stroke", item.series.color)
477
+ .attr("stroke-width", 2);
478
+
479
+ const labelLines = endpointLabelLines(item.series.group);
480
+ const labelFont = labelLines.some(line => line.length > 11) ? 7.2 : 8.2;
481
+ const labelGroup = svg.append("text")
482
+ .attr("x", cardX + cardW / 2)
483
+ .attr("y", item.cardY + (labelLines.length > 1 ? 11 : 16))
484
+ .attr("text-anchor", "middle")
485
+ .attr("fill", "#102238")
486
+ .style("font-family", "Arial, sans-serif")
487
+ .style("font-size", `${fontSize(labelFont)}px`)
488
+ .style("font-weight", 800);
489
+ labelLines.forEach((lineText, idx) => {
490
+ labelGroup.append("tspan")
491
+ .attr("x", cardX + cardW / 2)
492
+ .attr("dy", idx === 0 ? 0 : 9)
493
+ .attr("textLength", lineText.length > 12 ? cardW - 10 : null)
494
+ .attr("lengthAdjust", lineText.length > 12 ? "spacingAndGlyphs" : null)
495
+ .text(lineText);
496
+ });
497
+
498
+ svg.append("text")
499
+ .attr("x", cardX + cardW / 2)
500
+ .attr("y", item.cardY + (labelLines.length > 1 ? 43 : 40))
501
+ .attr("text-anchor", "middle")
502
+ .attr("fill", item.series.color)
503
+ .style("font-family", "Arial, sans-serif")
504
+ .style("font-size", `${fontSize(25)}px`)
505
+ .style("font-weight", 900)
506
+ .text(`${Math.round(item.point.__y)}${yUnit === "%" ? "%" : ""}`);
507
+
508
+ svg.append("text")
509
+ .attr("x", cardX + cardW / 2)
510
+ .attr("y", item.cardY + 58)
511
+ .attr("text-anchor", "middle")
512
+ .attr("fill", "#102238")
513
+ .style("font-family", "Arial, sans-serif")
514
+ .style("font-size", `${fontSize(10)}px`)
515
+ .text(`in ${item.point.__x.getFullYear()}`);
516
+ });
517
+
518
+ const topItem = lastItems[0];
519
+ const bottomItem = lastItems[lastItems.length - 1];
520
+ const ratio = bottomItem && topItem ? (topItem.point.__y / bottomItem.point.__y) : null;
521
+ if (ratio && Number.isFinite(ratio)) {
522
+ const guideX = cardX + cardW / 2;
523
+ const guideTop = topItem.cardY + cardH + 7;
524
+ const guideBottom = bottomItem.cardY - 7;
525
+ const badgeR = 24;
526
+ const badgeY = (guideTop + guideBottom) / 2;
527
+
528
+ if (guideBottom - guideTop > badgeR * 2.4) {
529
+ svg.append("line")
530
+ .attr("x1", guideX)
531
+ .attr("x2", guideX)
532
+ .attr("y1", guideTop)
533
+ .attr("y2", guideBottom)
534
+ .attr("stroke", "#87909a")
535
+ .attr("stroke-width", 2)
536
+ .attr("stroke-dasharray", "5 5");
537
+ svg.append("path")
538
+ .attr("d", `M${guideX - 5},${guideTop + 7} L${guideX},${guideTop} L${guideX + 5},${guideTop + 7}`)
539
+ .attr("fill", "none")
540
+ .attr("stroke", "#87909a")
541
+ .attr("stroke-width", 2)
542
+ .attr("stroke-linecap", "round")
543
+ .attr("stroke-linejoin", "round");
544
+ svg.append("path")
545
+ .attr("d", `M${guideX - 5},${guideBottom - 7} L${guideX},${guideBottom} L${guideX + 5},${guideBottom - 7}`)
546
+ .attr("fill", "none")
547
+ .attr("stroke", "#87909a")
548
+ .attr("stroke-width", 2)
549
+ .attr("stroke-linecap", "round")
550
+ .attr("stroke-linejoin", "round");
551
+ svg.append("circle")
552
+ .attr("cx", guideX)
553
+ .attr("cy", badgeY)
554
+ .attr("r", badgeR)
555
+ .attr("fill", "#ffffff")
556
+ .attr("stroke", "#d8e0e5")
557
+ .attr("stroke-width", 2);
558
+ svg.append("text")
559
+ .attr("x", guideX)
560
+ .attr("y", badgeY - 2)
561
+ .attr("text-anchor", "middle")
562
+ .attr("fill", "#102238")
563
+ .style("font-family", "Arial, sans-serif")
564
+ .style("font-size", `${fontSize(20)}px`)
565
+ .style("font-weight", 900)
566
+ .text(ratio.toFixed(1));
567
+ svg.append("text")
568
+ .attr("x", guideX)
569
+ .attr("y", badgeY + 15)
570
+ .attr("text-anchor", "middle")
571
+ .attr("fill", "#102238")
572
+ .style("font-family", "Arial, sans-serif")
573
+ .style("font-size", `${fontSize(10)}px`)
574
+ .style("font-weight", 800)
575
+ .text("RATIO");
576
+ }
577
+ }
578
+
579
+ return svg.node();
580
+ }
modules/chart_engine/template/d3-js/type36_range_area_chart/range_area_plain_chart_01.js ADDED
@@ -0,0 +1,564 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Range Area Chart",
5
+ "chart_name": "range_area_plain_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 clamp = (value, min, max) => Math.max(min, Math.min(max, value));
28
+ const estimateTextWidth = (text, size, weight = 500) => String(text).length * size * (weight >= 700 ? 0.62 : 0.56);
29
+ const boxesOverlap = (a, b, pad = 0) => !(
30
+ a.x + a.width + pad < b.x ||
31
+ b.x + b.width + pad < a.x ||
32
+ a.y + a.height + pad < b.y ||
33
+ b.y + b.height + pad < a.y
34
+ );
35
+ const formatValueLabel = (value, digits = 0, suffix = "") => {
36
+ const rounded = Number(value);
37
+ if (!Number.isFinite(rounded)) return "";
38
+ return `${chartUtils.format.autoText(+rounded.toFixed(digits))}${suffix}`;
39
+ };
40
+
41
+ const createXAxisScaleAndTicks = (data, xField, rangeStart = 0, rangeEnd = 100) => {
42
+ const dates = data.map(d => chartUtils.format.parseDate(d[xField]));
43
+ const xExtent = d3.extent(dates);
44
+ const xRange = xExtent[1] - xExtent[0];
45
+ const xPadding = xRange * 0.05;
46
+
47
+ const xScale = d3.scaleTime()
48
+ .domain([new Date(xExtent[0].getTime() - xPadding), new Date(xExtent[1].getTime() + xPadding)])
49
+ .range([rangeStart, rangeEnd]);
50
+
51
+ const timeSpan = xExtent[1] - xExtent[0];
52
+ const yearSpan = timeSpan / (1000 * 60 * 60 * 24 * 365);
53
+
54
+ let timeInterval, formatFunction;
55
+ if (yearSpan > 7) {
56
+ const maxTickCount = Math.max(5, Math.floor((rangeEnd - rangeStart) / 72));
57
+ const rawStep = yearSpan <= 16 ? 2 : Math.max(2, Math.ceil(yearSpan / maxTickCount));
58
+ const niceSteps = [2, 5, 10, 20, 25, 50, 100];
59
+ const step = niceSteps.find(candidate => candidate >= rawStep) || rawStep;
60
+ timeInterval = d3.timeYear.every(step);
61
+ formatFunction = d => chartUtils.format.date(d, { output: "%Y" }).text;
62
+ } else if (yearSpan > 2) {
63
+ timeInterval = d3.timeYear.every(1);
64
+ formatFunction = d => chartUtils.format.date(d, { output: "%Y" }).text;
65
+ } else {
66
+ timeInterval = d3.timeMonth.every(3);
67
+ formatFunction = d => chartUtils.format.date(d, { output: "%Y-%m" }).text;
68
+ }
69
+
70
+ const xTicks = xScale.ticks(timeInterval);
71
+ if (xTicks.length > 0 && xTicks[xTicks.length - 1] < xExtent[1]) {
72
+ if (xTicks.length > 7) xTicks.pop();
73
+ xTicks.push(xExtent[1]);
74
+ }
75
+
76
+ return { xScale, xTicks, xFormat: formatFunction };
77
+ };
78
+
79
+ // 提取数据
80
+ const jsonData = data;
81
+ const chartData = jsonData.data.data;
82
+ const variables = jsonData.variables;
83
+ const colors = jsonData.colors || {};
84
+ const colorResolver = chartUtils.color.resolver(jsonData);
85
+ const dataColumns = chartUtils.schema.columns(jsonData);
86
+ const fontSize = chartUtils.text.fontSize;
87
+
88
+ // 清空容器
89
+ d3.select(containerSelector).html("");
90
+
91
+ // 获取字段名
92
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
93
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
94
+ const yDescription = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField }).description;
95
+ const yUnit = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField }).unit || "";
96
+ const valueSuffix = yUnit === "%" ? "%" : (yUnit ? ` ${yUnit}` : "");
97
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
98
+
99
+ // 设置尺寸和边距
100
+ const width = variables.width;
101
+ const height = variables.height;
102
+ const margin = { top: 104, right: 70, bottom: 76, left: 76 };
103
+
104
+ // 创建SVG
105
+ const svg = d3.select(containerSelector)
106
+ .append("svg")
107
+ .attr("width", "100%")
108
+ .attr("height", height)
109
+ .attr("viewBox", `0 0 ${width} ${height}`)
110
+ .attr("style", "max-width: 100%; height: auto;");
111
+
112
+ // 创建图表区域
113
+ const chartWidth = width - margin.left - margin.right;
114
+ const chartHeight = height - margin.top - margin.bottom;
115
+
116
+ const palette = {
117
+ upper: colorResolver.field(groupField, 0, { fallbackKey: "primary" }).value || "#0A7F97",
118
+ lower: colorResolver.field(groupField, 1, { fallbackKey: "secondary" }).value || "#5FA82D",
119
+ panel: "#F9FCFD",
120
+ panelStroke: "#D8E6EC",
121
+ grid: "#D2DEE3",
122
+ text: "#071833"
123
+ };
124
+
125
+ const defs = svg.append("defs");
126
+ const panelGradientId = `range-area-panel-${Math.random().toString(36).slice(2)}`;
127
+ defs.append("linearGradient")
128
+ .attr("id", panelGradientId)
129
+ .attr("x1", "0%")
130
+ .attr("y1", "0%")
131
+ .attr("x2", "0%")
132
+ .attr("y2", "100%")
133
+ .selectAll("stop")
134
+ .data([
135
+ { offset: "0%", color: "#FFFFFF", opacity: 0.98 },
136
+ { offset: "100%", color: "#EFF8FA", opacity: 0.98 }
137
+ ])
138
+ .enter()
139
+ .append("stop")
140
+ .attr("offset", d => d.offset)
141
+ .attr("stop-color", d => d.color)
142
+ .attr("stop-opacity", d => d.opacity);
143
+
144
+ const areaGradientId = `range-area-fill-${Math.random().toString(36).slice(2)}`;
145
+ const areaGradient = defs.append("linearGradient")
146
+ .attr("id", areaGradientId)
147
+ .attr("x1", "0%")
148
+ .attr("y1", "0%")
149
+ .attr("x2", "0%")
150
+ .attr("y2", "100%");
151
+ areaGradient.append("stop").attr("offset", "0%").attr("stop-color", "#8AD4E5").attr("stop-opacity", 0.58);
152
+ areaGradient.append("stop").attr("offset", "100%").attr("stop-color", "#BEE7A5").attr("stop-opacity", 0.42);
153
+
154
+ const panelPad = 24;
155
+ const panelX = 18;
156
+ const panelY = 20;
157
+ const panelW = width - panelX * 2;
158
+ const panelH = height - panelY - 20;
159
+ svg.append("rect")
160
+ .attr("class", "range-area-chart-panel")
161
+ .attr("x", panelX)
162
+ .attr("y", panelY)
163
+ .attr("width", panelW)
164
+ .attr("height", panelH)
165
+ .attr("rx", 20)
166
+ .attr("fill", `url(#${panelGradientId})`)
167
+ .attr("stroke", palette.panelStroke)
168
+ .attr("stroke-width", 1.1)
169
+ .attr("filter", "drop-shadow(0 8px 14px rgba(22, 60, 75, 0.12))");
170
+
171
+ const g = svg.append("g")
172
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
173
+
174
+ // 分离两个组的数据
175
+ const groupNames = [...new Set(chartData.map(d => d[groupField]))];
176
+
177
+ // 计算每个组的平均值并排序
178
+ const groupAverages = groupNames.map(group => {
179
+ const groupData = chartData.filter(d => d[groupField] === group);
180
+ const avg = d3.mean(groupData, d => +d[yField]);
181
+ return { group, avg };
182
+ }).sort((a, b) => b.avg - a.avg);
183
+
184
+ // 选择平均值最高和最低的组
185
+ const highestGroup = groupAverages[0].group;
186
+ const lowestGroup = groupAverages[groupAverages.length - 1].group;
187
+
188
+ // 获取这两个组的数据并排序
189
+ const group1Data = chartData.filter(d => d[groupField] === highestGroup)
190
+ .sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
191
+ const group2Data = chartData.filter(d => d[groupField] === lowestGroup)
192
+ .sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
193
+
194
+ const selectedGroupNames = [highestGroup, lowestGroup];
195
+ palette.upper = colorResolver.field(selectedGroupNames[0], 0, { fallback: "#087F98" }).value || "#087F98";
196
+ palette.lower = colorResolver.field(selectedGroupNames[1], 1, { fallback: "#5C9F2D" }).value || "#5C9F2D";
197
+
198
+ const { xScale, xTicks, xFormat } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
199
+
200
+ // 创建y轴比例尺
201
+ const yMin = Math.max(0, d3.min(chartData, d => +d[yField]));
202
+ const yMax = d3.max(chartData, d => +d[yField]);
203
+ const yPadding = 0.3 * (yMax - yMin);
204
+
205
+ const yScale = d3.scaleLinear()
206
+ .domain([yMin - yPadding, yMax + yPadding])
207
+ .range([chartHeight, 0]);
208
+
209
+ // 添加水平网格线和y轴刻度
210
+ const yTicks = d3.ticks(yMin - yPadding, yMax + yPadding, 5);
211
+ yTicks.forEach(tick => {
212
+ if (tick > 0) {
213
+ g.append("line")
214
+ .attr("class", "gridline")
215
+ .attr("x1", 0)
216
+ .attr("y1", yScale(tick))
217
+ .attr("x2", chartWidth)
218
+ .attr("y2", yScale(tick))
219
+ .attr("stroke", palette.grid)
220
+ .attr("stroke-dasharray", "3 4")
221
+ .attr("stroke-width", 1);
222
+ }
223
+
224
+ g.append("text")
225
+ .attr("class", "value")
226
+ .attr("x", -10)
227
+ .attr("y", yScale(tick))
228
+ .attr("text-anchor", "end")
229
+ .attr("dominant-baseline", "middle")
230
+ .style("font-family", "Arial, Helvetica, sans-serif")
231
+ .style("font-size", `${fontSize(12)}px`)
232
+ .style("fill", "#14233B")
233
+ .text(chartUtils.format.autoText(+(tick)));
234
+ });
235
+
236
+ // 添加x轴刻度标签
237
+ xTicks.forEach(tick => {
238
+ g.append("text")
239
+ .attr("class", "value")
240
+ .attr("x", xScale(tick))
241
+ .attr("y", chartHeight + 15)
242
+ .attr("text-anchor", "middle")
243
+ .style("font-family", "Arial, Helvetica, sans-serif")
244
+ .style("font-size", `${fontSize(12)}px`)
245
+ .style("fill", "#14233B")
246
+ .text(xFormat(tick));
247
+ });
248
+
249
+ // 添加坐标轴
250
+ g.append("line")
251
+ .attr("class", "axis")
252
+ .attr("x1", 0)
253
+ .attr("y1", chartHeight)
254
+ .attr("x2", chartWidth)
255
+ .attr("y2", chartHeight)
256
+ .attr("stroke", "#60727D")
257
+ .attr("stroke-width", 1.2);
258
+
259
+ g.append("line")
260
+ .attr("class", "axis")
261
+ .attr("x1", 0)
262
+ .attr("y1", yScale(d3.max(yTicks)))
263
+ .attr("x2", 0)
264
+ .attr("y2", chartHeight)
265
+ .attr("stroke", "#60727D")
266
+ .attr("stroke-width", 1.2);
267
+
268
+ // 添加y轴标题
269
+ g.append("text")
270
+ .attr("class", "text")
271
+ .attr("x", 0)
272
+ .attr("y", -58)
273
+ .attr("text-anchor", "start")
274
+ .style("font-family", "Arial, Helvetica, sans-serif")
275
+ .style("font-size", `${fontSize(15)}px`)
276
+ .style("font-weight", "800")
277
+ .style("fill", palette.text)
278
+ .text(`${yDescription} ${d3.min(chartData, d => chartUtils.format.parseDate(d[xField]).getFullYear())}-${d3.max(chartData, d => chartUtils.format.parseDate(d[xField]).getFullYear())}`);
279
+
280
+ g.append("text")
281
+ .attr("class", "text")
282
+ .attr("x", 0)
283
+ .attr("y", -30)
284
+ .attr("text-anchor", "start")
285
+ .style("font-family", "Arial, Helvetica, sans-serif")
286
+ .style("font-size", `${fontSize(12)}px`)
287
+ .style("font-weight", "600")
288
+ .style("fill", "#14233B")
289
+ .text(yUnit || yField);
290
+
291
+ const legendFontSize = fontSize(12);
292
+ const legendItems = selectedGroupNames.map((group, idx) => ({
293
+ group,
294
+ color: idx === 0 ? palette.upper : palette.lower,
295
+ width: 36 + estimateTextWidth(group, legendFontSize, 500) + 22
296
+ }));
297
+ const legendTotalWidth = d3.sum(legendItems, d => d.width);
298
+ const legend = g.append("g")
299
+ .attr("class", "legend")
300
+ .attr("transform", `translate(${clamp(chartWidth - legendTotalWidth, 0, chartWidth)}, ${-22})`);
301
+ let legendCursor = 0;
302
+ legendItems.forEach((item) => {
303
+ const itemX = legendCursor;
304
+ const group = item.group;
305
+ const color = item.color;
306
+ legend.append("line")
307
+ .attr("x1", itemX)
308
+ .attr("x2", itemX + 28)
309
+ .attr("y1", 7)
310
+ .attr("y2", 7)
311
+ .attr("stroke", color)
312
+ .attr("stroke-width", 3)
313
+ .attr("stroke-linecap", "round");
314
+ legend.append("circle")
315
+ .attr("cx", itemX + 14)
316
+ .attr("cy", 7)
317
+ .attr("r", 5)
318
+ .attr("fill", color)
319
+ .attr("stroke", "#FFFFFF")
320
+ .attr("stroke-width", 1.6);
321
+ legend.append("text")
322
+ .attr("x", itemX + 36)
323
+ .attr("y", 11)
324
+ .style("font-family", "Arial, Helvetica, sans-serif")
325
+ .style("font-size", `${legendFontSize}px`)
326
+ .style("fill", "#14233B")
327
+ .text(group);
328
+ legendCursor += item.width;
329
+ });
330
+
331
+ // 创建线条和面积生成器
332
+ const line = d3.line()
333
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
334
+ .y(d => yScale(+d[yField]));
335
+
336
+ const area = d3.area()
337
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
338
+ .y0(d => yScale(d.group2Value))
339
+ .y1(d => yScale(d.group1Value));
340
+
341
+ // 创建合并的数据集用于面积图
342
+ const areaData = [];
343
+
344
+ // 找到共同的时间点
345
+ const commonDates = group1Data.map(d => d[xField]).filter(date =>
346
+ group2Data.some(d2 => d2[xField] === date)
347
+ );
348
+
349
+ // 按时间排序构建面积数据
350
+ commonDates.sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b)).forEach(date => {
351
+ const group1Item = group1Data.find(d => d[xField] === date);
352
+ const group2Item = group2Data.find(d => d[xField] === date);
353
+
354
+ if (group1Item && group2Item) {
355
+ areaData.push({
356
+ [xField]: date,
357
+ group1Value: group1Item[yField],
358
+ group2Value: group2Item[yField]
359
+ });
360
+ }
361
+ });
362
+
363
+ // 绘制面积
364
+ g.append("path")
365
+ .attr("class", "mark")
366
+ .datum(areaData)
367
+ .attr("fill", `url(#${areaGradientId})`)
368
+ .attr("opacity", 0.95)
369
+ .attr("d", area);
370
+
371
+ // 绘制线条1(高值组)
372
+ g.append("path")
373
+ .attr("class", "mark")
374
+ .datum(group1Data)
375
+ .attr("fill", "none")
376
+ .attr("stroke", palette.upper)
377
+ .attr("stroke-width", 4)
378
+ .attr("stroke-linejoin", "round")
379
+ .attr("stroke-linecap", "round")
380
+ .attr("d", line);
381
+
382
+ // 绘制线条2(低值组)
383
+ g.append("path")
384
+ .attr("class", "mark")
385
+ .datum(group2Data)
386
+ .attr("fill", "none")
387
+ .attr("stroke", palette.lower)
388
+ .attr("stroke-width", 4)
389
+ .attr("stroke-linejoin", "round")
390
+ .attr("stroke-linecap", "round")
391
+ .attr("d", line);
392
+
393
+ const drawMarkers = (series, color) => {
394
+ g.selectAll(`.range-marker-${color.replace(/[^a-zA-Z0-9]/g, "")}`)
395
+ .data(series)
396
+ .enter()
397
+ .append("circle")
398
+ .attr("class", "mark range-area-point")
399
+ .attr("cx", d => xScale(chartUtils.format.parseDate(d[xField])))
400
+ .attr("cy", d => yScale(+d[yField]))
401
+ .attr("r", 4.9)
402
+ .attr("fill", color)
403
+ .attr("stroke", "#FFFFFF")
404
+ .attr("stroke-width", 2);
405
+ };
406
+ drawMarkers(group1Data, palette.upper);
407
+ drawMarkers(group2Data, palette.lower);
408
+
409
+ // 添加组标签到线的左侧
410
+ const firstPoint1 = group1Data[0];
411
+ const firstPoint2 = group2Data[0];
412
+ const lastPoint1 = group1Data[group1Data.length - 1];
413
+ const lastPoint2 = group2Data[group2Data.length - 1];
414
+
415
+ const labelBoxes = [];
416
+ const endpointDigits = point => yUnit === "%" ? 0 : (+point[yField] < 10 ? 1 : 0);
417
+ const endpointDisplayedValue = point => +(+point[yField]).toFixed(endpointDigits(point));
418
+ const drawValuePill = (point, color, side, preferredDy = 0) => {
419
+ const digits = endpointDigits(point);
420
+ const text = formatValueLabel(+point[yField], digits, valueSuffix);
421
+ const fs = fontSize(14);
422
+ const padX = 8;
423
+ const pillW = Math.max(38, estimateTextWidth(text, fs, 800) + padX * 2);
424
+ const pillH = 25;
425
+ const pointX = xScale(chartUtils.format.parseDate(point[xField]));
426
+ const pointY = yScale(+point[yField]);
427
+ let x = side === "left" ? pointX - pillW - 8 : pointX + 8;
428
+ let y = pointY - pillH / 2 + preferredDy;
429
+ x = clamp(x, 2, chartWidth - pillW - 2);
430
+ y = clamp(y, -8, chartHeight - pillH - 2);
431
+ const box = { x, y, width: pillW, height: pillH };
432
+ for (let i = 0; i < labelBoxes.length; i++) {
433
+ if (boxesOverlap(box, labelBoxes[i], 4)) {
434
+ y = clamp(y + (preferredDy >= 0 ? pillH + 4 : -pillH - 4), -8, chartHeight - pillH - 2);
435
+ box.y = y;
436
+ }
437
+ }
438
+ labelBoxes.push(box);
439
+ const pill = g.append("g")
440
+ .attr("class", "range-area-endpoint-pill")
441
+ .attr("transform", `translate(${x},${y})`)
442
+ .attr("data-anchor", `${side}-endpoint-point`)
443
+ .attr("data-collision-rule", "clamped-to-panel-and-staggered-from-neighbor-endpoint-pills");
444
+ pill.append("rect")
445
+ .attr("width", pillW)
446
+ .attr("height", pillH)
447
+ .attr("rx", 7)
448
+ .attr("fill", color)
449
+ .attr("stroke", "#FFFFFF")
450
+ .attr("stroke-width", 1.4);
451
+ pill.append("text")
452
+ .attr("x", pillW / 2)
453
+ .attr("y", pillH / 2 + 5)
454
+ .attr("text-anchor", "middle")
455
+ .style("font-family", "Arial, Helvetica, sans-serif")
456
+ .style("font-size", `${fs}px`)
457
+ .style("font-weight", "800")
458
+ .style("fill", "#FFFFFF")
459
+ .text(text);
460
+ };
461
+ drawValuePill(firstPoint1, palette.upper, "left", -10);
462
+ drawValuePill(firstPoint2, palette.lower, "left", 12);
463
+ drawValuePill(lastPoint1, palette.upper, "right", -6);
464
+ drawValuePill(lastPoint2, palette.lower, "right", 8);
465
+
466
+ const lineBoxes = areaData.flatMap(d => {
467
+ const x = xScale(chartUtils.format.parseDate(d[xField]));
468
+ return [
469
+ { x: x - 9, y: yScale(+d.group1Value) - 9, width: 18, height: 18 },
470
+ { x: x - 9, y: yScale(+d.group2Value) - 9, width: 18, height: 18 }
471
+ ];
472
+ });
473
+ const rawFinalGap = Math.abs(+lastPoint1[yField] - +lastPoint2[yField]);
474
+ const finalGap = yUnit === "%"
475
+ ? Math.abs(endpointDisplayedValue(lastPoint1) - endpointDisplayedValue(lastPoint2))
476
+ : rawFinalGap;
477
+ const gapDigits = yUnit === "%" ? (finalGap >= 10 ? 0 : 1) : (finalGap < 10 ? 1 : 0);
478
+ const gapUnit = yUnit === "%" ? "percentage" : (yUnit || "units");
479
+ const higherText = yUnit === "%" ? "points higher" : "higher";
480
+ const gapText = `${chartUtils.format.autoText(+finalGap.toFixed(gapDigits))} ${gapUnit}`;
481
+ const annotationCandidates = [
482
+ { x: chartWidth - 186, y: chartHeight - 136 },
483
+ { x: chartWidth - 186, y: 76 },
484
+ { x: 96, y: chartHeight - 136 },
485
+ { x: 96, y: 76 }
486
+ ];
487
+ const annotationBox = { width: 164, height: 92 };
488
+ const occupied = labelBoxes.concat(lineBoxes, [
489
+ { x: chartWidth - 210, y: -70, width: 210, height: 34 },
490
+ { x: 0, y: -72, width: 260, height: 52 }
491
+ ]);
492
+ const candidate = annotationCandidates.find(c => {
493
+ const box = { x: c.x, y: c.y, width: annotationBox.width, height: annotationBox.height };
494
+ return !occupied.some(o => boxesOverlap(box, o, 8));
495
+ });
496
+ if (candidate) {
497
+ const card = g.append("g")
498
+ .attr("class", "range-area-gap-annotation")
499
+ .attr("data-annotation", "final-year-gap")
500
+ .attr("data-anchor", "collision-aware-plot-relative-safe-area")
501
+ .attr("data-collision-rule", "tries lower-right upper-right lower-left upper-left; avoids lines markers endpoint labels legend and chart title")
502
+ .attr("transform", `translate(${candidate.x},${candidate.y})`);
503
+ card.append("rect")
504
+ .attr("width", annotationBox.width)
505
+ .attr("height", annotationBox.height)
506
+ .attr("rx", 14)
507
+ .attr("fill", "#FFFFFF")
508
+ .attr("stroke", "#C9DCE4")
509
+ .attr("stroke-width", 1.1)
510
+ .attr("filter", "drop-shadow(0 5px 10px rgba(16, 52, 70, 0.14))");
511
+ const iconSlot = card.append("g")
512
+ .attr("class", "reserved-asset-slot range-area-gap-icon-slot")
513
+ .attr("data-asset-slot", "range-area-gap-annotation-icon")
514
+ .attr("data-slot-policy", "neutral_placeholder_or_image2_asset")
515
+ .attr("data-asset-source-policy", "neutral_placeholder")
516
+ .attr("data-anchor", "annotation-card-left")
517
+ .attr("data-bbox", "14,18,42,42")
518
+ .attr("data-collision-rule", "must remain inside annotation card and avoid annotation text")
519
+ .attr("data-no-embedded-raster", "true")
520
+ .attr("data-no-copied-reference-art", "true")
521
+ .attr("transform", "translate(15,20)");
522
+ iconSlot.append("circle")
523
+ .attr("cx", 21)
524
+ .attr("cy", 21)
525
+ .attr("r", 20)
526
+ .attr("fill", "#0B85A0")
527
+ .attr("opacity", 0.92);
528
+ iconSlot.append("circle")
529
+ .attr("cx", 21)
530
+ .attr("cy", 21)
531
+ .attr("r", 9)
532
+ .attr("fill", "none")
533
+ .attr("stroke", "#FFFFFF")
534
+ .attr("stroke-width", 2)
535
+ .attr("stroke-dasharray", "4 3")
536
+ .attr("opacity", 0.8);
537
+ card.append("text")
538
+ .attr("x", 70)
539
+ .attr("y", 30)
540
+ .style("font-family", "Arial, Helvetica, sans-serif")
541
+ .style("font-size", `${fontSize(11)}px`)
542
+ .style("fill", "#263448")
543
+ .text(`In ${chartUtils.format.parseDate(lastPoint1[xField]).getFullYear()},`);
544
+ card.append("text")
545
+ .attr("x", 70)
546
+ .attr("y", 51)
547
+ .style("font-family", "Arial, Helvetica, sans-serif")
548
+ .style("font-size", `${fontSize(15)}px`)
549
+ .style("font-weight", "800")
550
+ .style("fill", palette.upper)
551
+ .text(gapText);
552
+ card.append("text")
553
+ .attr("x", 70)
554
+ .attr("y", 70)
555
+ .style("font-family", "Arial, Helvetica, sans-serif")
556
+ .style("font-size", `${fontSize(11)}px`)
557
+ .style("fill", "#263448")
558
+ .text(higherText);
559
+ }
560
+
561
+
562
+
563
+ return svg.node();
564
+ }