Ray1ee01 commited on
Commit
5eb5229
·
verified ·
1 Parent(s): 8281877

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type27_stepped_line_graph/stepped_line_graph_plain_chart_01.js ADDED
@@ -0,0 +1,651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Stepped Line Graph",
5
+ "chart_name": "stepped_line_graph_plain_chart_01",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["temporal"], ["numerical"]],
8
+ "required_fields_range": [[4, 20], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
13
+ "supported_effects": [],
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 variables = jsonData.variables || {};
29
+ const typography = jsonData.typography || {};
30
+ const sourceColors = jsonData.colors || {};
31
+ const dataColumns = chartUtils.schema.columns(jsonData);
32
+ const colorResolver = chartUtils.color.resolver(jsonData);
33
+ const chartUtilsTextSample = chartUtils.text.estimate;
34
+ const chartUtilsStandard = {
35
+ schema: chartUtils.schema,
36
+ format: chartUtils.format,
37
+ text: chartUtils.text,
38
+ color: chartUtils.color,
39
+ legendLayout: chartUtils.legend.layout,
40
+ legendDraw: chartUtils.legend.draw,
41
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
42
+ };
43
+ const standardChannels = chartUtils.schema.channels(jsonData, {
44
+ x: { fallbackIndex: 0 },
45
+ y: { fallbackIndex: 1 },
46
+ y2: { fallbackIndex: 2 },
47
+ y3: { fallbackIndex: 3 },
48
+ size: { fallbackIndex: 2 },
49
+ group: { fallbackIndex: 2 },
50
+ group2: { fallbackIndex: 3 },
51
+ group3: { fallbackIndex: 4 }
52
+ });
53
+ const sourceData = jsonData.data.data || [];
54
+
55
+ d3.select(containerSelector).html("");
56
+
57
+ const xField = chartUtils.schema.column(dataColumns, 0).key;
58
+ const yField = chartUtils.schema.column(dataColumns, 1).key;
59
+ const readableName = value => String(value || "")
60
+ .replace(/_/g, " ")
61
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
62
+ .replace(/\s+/g, " ")
63
+ .trim();
64
+ const xLabel = readableName(chartUtils.schema.column(dataColumns, 0).rawLabel || chartUtils.schema.column(dataColumns, 0).key);
65
+ const yLabel = readableName(chartUtils.schema.column(dataColumns, 1).rawLabel || chartUtils.schema.column(dataColumns, 1).key);
66
+ const yUnit = (chartUtils.schema.column(dataColumns, 1).unit || "").trim();
67
+
68
+ const width = Number(variables.width) || 600;
69
+ const height = Math.max(Number(variables.height) || 600, 560);
70
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
71
+ const textColor = sourceColors.text_color || "#262626";
72
+ const canvasColor = "#f8fbff";
73
+ const panelColor = "#ffffff";
74
+ const plotColor = "#fbfdff";
75
+ const navyColor = "#071a3d";
76
+ const mutedText = "#6b7280";
77
+ const axisColor = "#0b2a52";
78
+ const gridColor = "#d9e3ef";
79
+ const lineColor = d3.interpolateRgb(sourceColors.other?.primary || "#3f9829", "#3f9829")(0.78);
80
+ const positiveColor = "#2f8c28";
81
+ const negativeColor = "#c93448";
82
+ const greenWash = "#e9f5e5";
83
+
84
+ const plainNumber = value => {
85
+ const abs = Math.abs(value);
86
+ const spec = abs >= 100 ? ",.0f" : abs >= 10 ? ",.1f" : ".2f";
87
+ return d3.format(spec)(value).replace(/\.0$/, "");
88
+ };
89
+
90
+ const compactNumber = value => {
91
+ const abs = Math.abs(value);
92
+ const spec = abs >= 100 ? ".0f" : abs >= 10 ? ".1f" : ".2f";
93
+ if (abs >= 1000000000) return `${d3.format(spec)(value / 1000000000)}B`;
94
+ if (abs >= 1000000) return `${d3.format(spec)(value / 1000000)}M`;
95
+ if (abs >= 1000) return `${d3.format(spec)(value / 1000)}K`;
96
+ return d3.format(spec)(value).replace(/\.0$/, "");
97
+ };
98
+
99
+ const formatLocalValue = value => {
100
+ const currencyWithSuffix = yUnit.match(/^([£$€¥])\s*([A-Za-z]+)$/);
101
+ if (yUnit === "%") return `${plainNumber(value)}%`;
102
+ if (currencyWithSuffix) return `${currencyWithSuffix[1]}${plainNumber(value)}${currencyWithSuffix[2]}`;
103
+ if (/^[£$€¥]$/.test(yUnit)) return `${yUnit}${compactNumber(value)}`;
104
+ if (yUnit) return `${compactNumber(value)} ${yUnit}`;
105
+ return compactNumber(value);
106
+ };
107
+
108
+ const formatDelta = value => {
109
+ const sign = value >= 0 ? "+" : "-";
110
+ const absValue = Math.abs(value);
111
+ if (yUnit === "%") return `${sign}${plainNumber(absValue)} points`;
112
+ const currencyWithSuffix = yUnit.match(/^([£$€¥])\s*([A-Za-z]+)$/);
113
+ if (currencyWithSuffix) return `${sign}${currencyWithSuffix[1]}${plainNumber(absValue)}${currencyWithSuffix[2]}`;
114
+ if (/^[£$€¥]$/.test(yUnit)) return `${sign}${yUnit}${compactNumber(absValue)}`;
115
+ if (yUnit) return `${sign}${compactNumber(absValue)} ${yUnit}`;
116
+ return `${sign}${compactNumber(absValue)}`;
117
+ };
118
+
119
+ const chartData = sourceData
120
+ .map(d => ({
121
+ ...d,
122
+ __xDate: chartUtils.format.parseDate(d[xField]),
123
+ __value: Number(d[yField])
124
+ }))
125
+ .filter(d => !Number.isNaN(d.__xDate.getTime()) && Number.isFinite(d.__value))
126
+ .sort((a, b) => a.__xDate - b.__xDate);
127
+
128
+ const svg = d3.select(containerSelector)
129
+ .append("svg")
130
+ .attr("width", "100%")
131
+ .attr("height", height)
132
+ .attr("viewBox", `0 0 ${width} ${height}`)
133
+ .attr("style", "max-width: 100%; height: auto;")
134
+ .attr("xmlns", "http://www.w3.org/2000/svg")
135
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
136
+ .attr("class", "stepped-line-chart-root");
137
+
138
+ const defs = svg.append("defs");
139
+ const cardShadow = defs.append("filter")
140
+ .attr("id", "stepped_line_plain_01_card_shadow")
141
+ .attr("x", "-8%")
142
+ .attr("y", "-10%")
143
+ .attr("width", "116%")
144
+ .attr("height", "124%");
145
+ cardShadow.append("feDropShadow")
146
+ .attr("dx", 0)
147
+ .attr("dy", 2)
148
+ .attr("stdDeviation", 2.3)
149
+ .attr("flood-color", "#4c6074")
150
+ .attr("flood-opacity", 0.13);
151
+
152
+ const areaGradient = defs.append("linearGradient")
153
+ .attr("id", "stepped_line_plain_01_area_gradient")
154
+ .attr("x1", "0%")
155
+ .attr("y1", "0%")
156
+ .attr("x2", "0%")
157
+ .attr("y2", "100%");
158
+ areaGradient.append("stop")
159
+ .attr("offset", "0%")
160
+ .attr("stop-color", lineColor)
161
+ .attr("stop-opacity", 0.22);
162
+ areaGradient.append("stop")
163
+ .attr("offset", "100%")
164
+ .attr("stop-color", lineColor)
165
+ .attr("stop-opacity", 0.04);
166
+
167
+ if (chartData.length < 2) {
168
+ svg.append("text")
169
+ .attr("x", width / 2)
170
+ .attr("y", height / 2)
171
+ .attr("text-anchor", "middle")
172
+ .attr("fill", textColor)
173
+ .style("font-family", fontFamily)
174
+ .style("font-size", "16px")
175
+ .text("Not enough data");
176
+ return svg.node();
177
+ }
178
+
179
+ const firstDatum = chartData[0];
180
+ const lastDatum = chartData[chartData.length - 1];
181
+ const deltaValue = lastDatum.__value - firstDatum.__value;
182
+ const deltaColor = deltaValue >= 0 ? positiveColor : negativeColor;
183
+ const cardX = Math.max(14, Math.min(22, width * 0.035));
184
+ const cardY = 14;
185
+ const cardWidth = width - cardX * 2;
186
+ const cardHeight = height - cardY * 2;
187
+ const headerHeight = 56;
188
+ const footerHeight = 70;
189
+ const margin = {
190
+ top: cardY + headerHeight + 36,
191
+ right: Math.max(36, width * 0.06),
192
+ bottom: cardY + footerHeight + 58,
193
+ left: Math.max(72, width * 0.12)
194
+ };
195
+ const chartWidth = Math.max(280, width - margin.left - margin.right - cardX);
196
+ const chartHeight = Math.max(250, height - margin.top - margin.bottom);
197
+ const plotX = margin.left;
198
+ const plotY = margin.top;
199
+
200
+ function fitTextSize(text, maxWidth, startSize, minSize) {
201
+ let fontSize = startSize;
202
+ const temp = svg.append("text")
203
+ .attr("visibility", "hidden")
204
+ .style("font-family", fontFamily)
205
+ .style("font-weight", "700")
206
+ .text(text);
207
+ while (fontSize > minSize) {
208
+ temp.style("font-size", `${fontSize}px`);
209
+ const bboxWidth = temp.node() ? temp.node().textContent.length * 7 : String(text).length * fontSize * 0.56;
210
+ if (bboxWidth <= maxWidth) break;
211
+ fontSize -= 0.5;
212
+ }
213
+ temp.remove();
214
+ return fontSize;
215
+ }
216
+
217
+ svg.append("rect")
218
+ .attr("class", "chart-canvas")
219
+ .attr("x", 0)
220
+ .attr("y", 0)
221
+ .attr("width", width)
222
+ .attr("height", height)
223
+ .attr("fill", canvasColor);
224
+
225
+ svg.append("rect")
226
+ .attr("class", "chart-style-panel")
227
+ .attr("x", cardX)
228
+ .attr("y", cardY)
229
+ .attr("width", cardWidth)
230
+ .attr("height", cardHeight)
231
+ .attr("rx", 8)
232
+ .attr("ry", 8)
233
+ .attr("fill", panelColor)
234
+ .attr("stroke", "#dce8f3")
235
+ .attr("stroke-width", 1)
236
+ .attr("filter", "url(#stepped_line_plain_01_card_shadow)");
237
+
238
+ const fieldText = yLabel || readableName(yField);
239
+ const fieldPillX = cardX + 22;
240
+ const fieldPillY = cardY + 18;
241
+ const fieldPillWidth = Math.min(cardWidth - 44, Math.max(168, fieldText.length * 8.2 + 58));
242
+ const fieldFontSize = fitTextSize(fieldText, fieldPillWidth - 52, 13, 8.5);
243
+ svg.append("rect")
244
+ .attr("class", "field-pill")
245
+ .attr("x", fieldPillX)
246
+ .attr("y", fieldPillY)
247
+ .attr("width", fieldPillWidth)
248
+ .attr("height", 29)
249
+ .attr("rx", 14.5)
250
+ .attr("ry", 14.5)
251
+ .attr("fill", "#ffffff")
252
+ .attr("stroke", "#d8e3ef")
253
+ .attr("stroke-width", 1);
254
+ svg.append("line")
255
+ .attr("class", "field-pill-line")
256
+ .attr("x1", fieldPillX + 17)
257
+ .attr("x2", fieldPillX + 46)
258
+ .attr("y1", fieldPillY + 14.5)
259
+ .attr("y2", fieldPillY + 14.5)
260
+ .attr("stroke", lineColor)
261
+ .attr("stroke-width", 4)
262
+ .attr("stroke-linecap", "round");
263
+ svg.append("circle")
264
+ .attr("class", "field-pill-dot")
265
+ .attr("cx", fieldPillX + 31.5)
266
+ .attr("cy", fieldPillY + 14.5)
267
+ .attr("r", 5.2)
268
+ .attr("fill", "#ffffff")
269
+ .attr("stroke", lineColor)
270
+ .attr("stroke-width", 3);
271
+ svg.append("text")
272
+ .attr("class", "field-pill-label")
273
+ .attr("x", fieldPillX + 58)
274
+ .attr("y", fieldPillY + 14.5)
275
+ .attr("dominant-baseline", "middle")
276
+ .style("font-family", fontFamily)
277
+ .style("font-size", `${fieldFontSize}px`)
278
+ .style("font-weight", "700")
279
+ .style("fill", navyColor)
280
+ .text(fieldText);
281
+
282
+ const sortNote = `${xLabel || readableName(xField)} chronological`;
283
+ svg.append("text")
284
+ .attr("class", "sort-note")
285
+ .attr("x", cardX + cardWidth - 24)
286
+ .attr("y", fieldPillY + 15)
287
+ .attr("text-anchor", "end")
288
+ .attr("dominant-baseline", "middle")
289
+ .style("font-family", fontFamily)
290
+ .style("font-size", `${fitTextSize(sortNote, Math.max(92, cardWidth - fieldPillWidth - 82), 10, 7.5)}px`)
291
+ .style("fill", mutedText)
292
+ .text(sortNote);
293
+
294
+ svg.append("rect")
295
+ .attr("class", "plot-card")
296
+ .attr("x", plotX - 22)
297
+ .attr("y", plotY - 22)
298
+ .attr("width", chartWidth + 42)
299
+ .attr("height", chartHeight + 58)
300
+ .attr("rx", 10)
301
+ .attr("ry", 10)
302
+ .attr("fill", plotColor)
303
+ .attr("stroke", "#e3edf7")
304
+ .attr("stroke-width", 1);
305
+
306
+ const g = svg.append("g")
307
+ .attr("class", "plot-area")
308
+ .attr("data-count", chartData.length)
309
+ .attr("data-sort-mode", "temporal")
310
+ .attr("data-min-value", d3.min(chartData, d => d.__value))
311
+ .attr("data-max-value", d3.max(chartData, d => d.__value))
312
+ .attr("transform", `translate(${plotX}, ${plotY})`);
313
+
314
+ const xExtent = d3.extent(chartData, d => d.__xDate);
315
+ if (+xExtent[0] === +xExtent[1]) {
316
+ xExtent[0] = d3.timeYear.offset(xExtent[0], -1);
317
+ xExtent[1] = d3.timeYear.offset(xExtent[1], 1);
318
+ }
319
+
320
+ const xScale = d3.scaleTime()
321
+ .domain(xExtent)
322
+ .range([0, chartWidth]);
323
+
324
+ const yMin = d3.min(chartData, d => d.__value);
325
+ const yMax = d3.max(chartData, d => d.__value);
326
+ const ySpan = yMax - yMin;
327
+ const yPadding = ySpan > 0 ? ySpan * 0.16 : Math.max(Math.abs(yMax) * 0.1, 1);
328
+ const yScale = d3.scaleLinear()
329
+ .domain([yMin - yPadding, yMax + yPadding])
330
+ .range([chartHeight, 0])
331
+ .nice(5);
332
+
333
+ const dateSpanYears = (xExtent[1] - xExtent[0]) / (1000 * 60 * 60 * 24 * 365);
334
+ const formatDate = d => {
335
+ if (dateSpanYears > 2) return d3.timeFormat("%Y")(d);
336
+ if (dateSpanYears > 1) return `${d.getFullYear().toString().slice(-2)}Q${Math.floor(d.getMonth() / 3) + 1}`;
337
+ return d3.timeFormat("%b %Y")(d);
338
+ };
339
+
340
+ const maxXTicks = Math.max(4, Math.min(8, Math.floor(chartWidth / 90)));
341
+ const selectXTicks = values => {
342
+ const step = Math.max(1, Math.ceil(values.length / maxXTicks));
343
+ const selected = values.filter((_, i) => i % step === 0);
344
+ if (+selected[0] !== +values[0]) selected.unshift(values[0]);
345
+ if (+selected[selected.length - 1] !== +values[values.length - 1]) selected.push(values[values.length - 1]);
346
+ const seen = new Set();
347
+ return selected.filter(d => {
348
+ const key = formatDate(d);
349
+ if (seen.has(key)) return false;
350
+ seen.add(key);
351
+ return true;
352
+ });
353
+ };
354
+ const xTicks = selectXTicks(chartData.map(d => d.__xDate));
355
+ const yTicks = yScale.ticks(5);
356
+
357
+ g.append("rect")
358
+ .attr("class", "chart-panel-background")
359
+ .attr("x", 0)
360
+ .attr("y", 0)
361
+ .attr("width", chartWidth)
362
+ .attr("height", chartHeight)
363
+ .attr("fill", "#ffffff")
364
+ .attr("opacity", 0.55);
365
+
366
+ xTicks.slice(0, -1).forEach((tick, index) => {
367
+ if (index % 2 !== 0) return;
368
+ const nextTick = xTicks[index + 1];
369
+ const x = xScale(tick);
370
+ const nextX = xScale(nextTick);
371
+ g.append("rect")
372
+ .attr("class", "plot-band")
373
+ .attr("x", x)
374
+ .attr("y", 0)
375
+ .attr("width", Math.max(0, nextX - x))
376
+ .attr("height", chartHeight)
377
+ .attr("fill", "#f0f7ff")
378
+ .attr("opacity", 0.55);
379
+ });
380
+
381
+ yTicks.forEach(tick => {
382
+ const y = yScale(tick);
383
+ g.append("line")
384
+ .attr("class", "gridline y-gridline")
385
+ .attr("x1", 0)
386
+ .attr("y1", y)
387
+ .attr("x2", chartWidth)
388
+ .attr("y2", y)
389
+ .attr("stroke", gridColor)
390
+ .attr("stroke-width", tick === 0 ? 1.4 : 1)
391
+ .attr("stroke-dasharray", tick === 0 ? null : "4,5");
392
+
393
+ g.append("text")
394
+ .attr("class", "axis-tick y-axis-tick")
395
+ .attr("x", -12)
396
+ .attr("y", y)
397
+ .attr("text-anchor", "end")
398
+ .attr("dominant-baseline", "middle")
399
+ .attr("fill", navyColor)
400
+ .style("font-family", fontFamily)
401
+ .style("font-size", "12px")
402
+ .text(formatLocalValue(tick));
403
+ });
404
+
405
+ xTicks.forEach(tick => {
406
+ const x = xScale(tick);
407
+ g.append("line")
408
+ .attr("class", "gridline x-gridline")
409
+ .attr("x1", x)
410
+ .attr("y1", 0)
411
+ .attr("x2", x)
412
+ .attr("y2", chartHeight)
413
+ .attr("stroke", gridColor)
414
+ .attr("stroke-width", 1)
415
+ .attr("opacity", 0.42);
416
+
417
+ g.append("text")
418
+ .attr("class", "axis-tick x-axis-tick")
419
+ .attr("x", x)
420
+ .attr("y", chartHeight + 28)
421
+ .attr("text-anchor", "middle")
422
+ .attr("fill", navyColor)
423
+ .style("font-family", fontFamily)
424
+ .style("font-size", "12px")
425
+ .text(formatDate(tick));
426
+ });
427
+
428
+ g.append("line")
429
+ .attr("class", "axis x-axis-line")
430
+ .attr("x1", 0)
431
+ .attr("y1", chartHeight)
432
+ .attr("x2", chartWidth)
433
+ .attr("y2", chartHeight)
434
+ .attr("stroke", axisColor)
435
+ .attr("stroke-width", 1.5);
436
+
437
+ g.append("line")
438
+ .attr("class", "axis y-axis-line")
439
+ .attr("x1", 0)
440
+ .attr("y1", 0)
441
+ .attr("x2", 0)
442
+ .attr("y2", chartHeight)
443
+ .attr("stroke", axisColor)
444
+ .attr("stroke-width", 1.5);
445
+
446
+ const line = d3.line()
447
+ .x(d => xScale(d.__xDate))
448
+ .y(d => yScale(d.__value))
449
+ .curve(d3.curveStepAfter);
450
+
451
+ const area = d3.area()
452
+ .x(d => xScale(d.__xDate))
453
+ .y0(chartHeight)
454
+ .y1(d => yScale(d.__value))
455
+ .curve(d3.curveStepAfter);
456
+
457
+ g.append("path")
458
+ .datum(chartData)
459
+ .attr("class", "stepped-line-area")
460
+ .attr("data-count", chartData.length)
461
+ .attr("data-sort-mode", "temporal")
462
+ .attr("fill", "url(#stepped_line_plain_01_area_gradient)")
463
+ .attr("d", area);
464
+
465
+ g.append("path")
466
+ .datum(chartData)
467
+ .attr("class", "mark stepped-line-path")
468
+ .attr("data-count", chartData.length)
469
+ .attr("data-sort-mode", "temporal")
470
+ .attr("data-first-value", firstDatum.__value)
471
+ .attr("data-last-value", lastDatum.__value)
472
+ .attr("fill", "none")
473
+ .attr("stroke", lineColor)
474
+ .attr("stroke-width", 4.2)
475
+ .attr("stroke-linejoin", "round")
476
+ .attr("stroke-linecap", "round")
477
+ .attr("d", line);
478
+
479
+ chartData.forEach((d, i) => {
480
+ const isEndpoint = i === 0 || i === chartData.length - 1;
481
+ g.append("circle")
482
+ .attr("class", `mark stepped-line-point${isEndpoint ? " endpoint-point" : ""}`)
483
+ .attr("data-label", formatDate(d.__xDate))
484
+ .attr("data-value", d.__value)
485
+ .attr("data-index", i)
486
+ .attr("data-sort-mode", "temporal")
487
+ .attr("cx", xScale(d.__xDate))
488
+ .attr("cy", yScale(d.__value))
489
+ .attr("r", isEndpoint ? 5.8 : 4.2)
490
+ .attr("fill", isEndpoint ? "#ffffff" : lineColor)
491
+ .attr("stroke", lineColor)
492
+ .attr("stroke-width", isEndpoint ? 3 : 2);
493
+ });
494
+
495
+ const drawEndpointLabel = (d, anchor) => {
496
+ const x = xScale(d.__xDate);
497
+ const y = yScale(d.__value);
498
+ const label = formatLocalValue(d.__value);
499
+ const labelWidth = Math.max(50, label.length * 7.6 + 20);
500
+ const labelHeight = 25;
501
+ const isLeft = anchor === "start";
502
+ const labelX = Math.max(0, Math.min(chartWidth - labelWidth, isLeft ? x + 12 : x - labelWidth - 12));
503
+ const labelY = Math.max(0, Math.min(chartHeight - labelHeight, y - labelHeight / 2));
504
+
505
+ const labelGroup = g.append("g")
506
+ .attr("class", `endpoint-label ${anchor}-endpoint-label`)
507
+ .attr("data-label", formatDate(d.__xDate))
508
+ .attr("data-value", d.__value)
509
+ .attr("data-anchor", anchor);
510
+
511
+ labelGroup.append("line")
512
+ .attr("x1", x)
513
+ .attr("y1", y)
514
+ .attr("x2", isLeft ? labelX : labelX + labelWidth)
515
+ .attr("y2", labelY + labelHeight / 2)
516
+ .attr("stroke", lineColor)
517
+ .attr("stroke-width", 1.2)
518
+ .attr("opacity", 0.7);
519
+
520
+ labelGroup.append("rect")
521
+ .attr("x", labelX)
522
+ .attr("y", labelY)
523
+ .attr("width", labelWidth)
524
+ .attr("height", labelHeight)
525
+ .attr("rx", 6)
526
+ .attr("ry", 6)
527
+ .attr("fill", lineColor)
528
+ .attr("stroke", lineColor)
529
+ .attr("stroke-width", 1.2);
530
+
531
+ labelGroup.append("text")
532
+ .attr("x", labelX + labelWidth / 2)
533
+ .attr("y", labelY + labelHeight / 2)
534
+ .attr("text-anchor", "middle")
535
+ .attr("dominant-baseline", "middle")
536
+ .attr("fill", "#ffffff")
537
+ .style("font-family", fontFamily)
538
+ .style("font-size", "12px")
539
+ .style("font-weight", "700")
540
+ .text(label);
541
+ };
542
+
543
+ drawEndpointLabel(chartData[0], "start");
544
+ drawEndpointLabel(chartData[chartData.length - 1], "end");
545
+
546
+ svg.append("text")
547
+ .attr("class", "axis-title y-axis-title")
548
+ .attr("transform", `translate(${plotX - 58}, ${plotY + chartHeight / 2}) rotate(-90)`)
549
+ .attr("text-anchor", "middle")
550
+ .attr("fill", navyColor)
551
+ .style("font-family", fontFamily)
552
+ .style("font-size", "12px")
553
+ .style("font-weight", "700")
554
+ .text(yUnit ? `${yLabel} (${yUnit})` : yLabel);
555
+
556
+ svg.append("text")
557
+ .attr("class", "axis-title x-axis-title")
558
+ .attr("x", plotX + chartWidth)
559
+ .attr("y", plotY + chartHeight + 47)
560
+ .attr("text-anchor", "end")
561
+ .attr("fill", mutedText)
562
+ .style("font-family", fontFamily)
563
+ .style("font-size", "12px")
564
+ .text(xLabel);
565
+
566
+ const summaryY = cardY + cardHeight - footerHeight + 12;
567
+ const summaryX = cardX + 22;
568
+ const summaryWidth = cardWidth - 44;
569
+ const summaryHeight = 46;
570
+ svg.append("rect")
571
+ .attr("class", "scale-note-band takeaway-card")
572
+ .attr("x", summaryX)
573
+ .attr("y", summaryY)
574
+ .attr("width", summaryWidth)
575
+ .attr("height", summaryHeight)
576
+ .attr("rx", 8)
577
+ .attr("ry", 8)
578
+ .attr("fill", "#ffffff")
579
+ .attr("stroke", "#d8e3ef")
580
+ .attr("stroke-width", 1);
581
+ svg.append("rect")
582
+ .attr("class", "takeaway-accent")
583
+ .attr("x", summaryX)
584
+ .attr("y", summaryY)
585
+ .attr("width", 4)
586
+ .attr("height", summaryHeight)
587
+ .attr("rx", 2)
588
+ .attr("fill", lineColor);
589
+ svg.append("circle")
590
+ .attr("class", "takeaway-icon")
591
+ .attr("cx", summaryX + 30)
592
+ .attr("cy", summaryY + summaryHeight / 2)
593
+ .attr("r", 15)
594
+ .attr("fill", greenWash)
595
+ .attr("stroke", lineColor)
596
+ .attr("stroke-width", 1.4);
597
+ svg.append("path")
598
+ .attr("class", "takeaway-icon-line")
599
+ .attr("d", `M ${summaryX + 20} ${summaryY + 28} L ${summaryX + 27} ${summaryY + 22} L ${summaryX + 34} ${summaryY + 25} L ${summaryX + 41} ${summaryY + 17}`)
600
+ .attr("fill", "none")
601
+ .attr("stroke", lineColor)
602
+ .attr("stroke-width", 2.5)
603
+ .attr("stroke-linecap", "round")
604
+ .attr("stroke-linejoin", "round");
605
+ svg.append("text")
606
+ .attr("class", "takeaway-title")
607
+ .attr("x", summaryX + 58)
608
+ .attr("y", summaryY + 17)
609
+ .style("font-family", fontFamily)
610
+ .style("font-size", "12px")
611
+ .style("font-weight", "800")
612
+ .style("fill", navyColor)
613
+ .text("Key takeaway");
614
+ const noteText = `${formatLocalValue(firstDatum.__value)} to ${formatLocalValue(lastDatum.__value)} from ${formatDate(firstDatum.__xDate)} to ${formatDate(lastDatum.__xDate)}.`;
615
+ svg.append("text")
616
+ .attr("class", "scale-note")
617
+ .attr("x", summaryX + 58)
618
+ .attr("y", summaryY + 34)
619
+ .style("font-family", fontFamily)
620
+ .style("font-size", `${fitTextSize(noteText, summaryWidth * 0.55, 10.5, 7.5)}px`)
621
+ .style("fill", mutedText)
622
+ .text(noteText);
623
+ svg.append("line")
624
+ .attr("class", "takeaway-divider")
625
+ .attr("x1", summaryX + summaryWidth * 0.68)
626
+ .attr("x2", summaryX + summaryWidth * 0.68)
627
+ .attr("y1", summaryY + 10)
628
+ .attr("y2", summaryY + summaryHeight - 10)
629
+ .attr("stroke", "#d8e3ef")
630
+ .attr("stroke-width", 1);
631
+ svg.append("text")
632
+ .attr("class", "delta-label")
633
+ .attr("x", summaryX + summaryWidth * 0.72)
634
+ .attr("y", summaryY + 16)
635
+ .style("font-family", fontFamily)
636
+ .style("font-size", "10px")
637
+ .style("fill", mutedText)
638
+ .text(`Change ${formatDate(firstDatum.__xDate)}-${formatDate(lastDatum.__xDate)}`);
639
+ svg.append("text")
640
+ .attr("class", "delta-value")
641
+ .attr("data-value", deltaValue)
642
+ .attr("x", summaryX + summaryWidth * 0.72)
643
+ .attr("y", summaryY + 35)
644
+ .style("font-family", fontFamily)
645
+ .style("font-size", `${Math.max(15, Math.min(24, summaryWidth / 24))}px`)
646
+ .style("font-weight", "800")
647
+ .style("fill", deltaColor)
648
+ .text(formatDelta(deltaValue));
649
+
650
+ return svg.node();
651
+ }
modules/chart_engine/template/d3-js/type27_stepped_line_graph/stepped_line_graph_plain_chart_02.js ADDED
@@ -0,0 +1,459 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Stepped Line Graph",
5
+ "chart_name": "stepped_line_graph_plain_chart_02",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[3, 30], ["-inf", "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": 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
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ // 分组step_line_graph_plain_chart_02
26
+ function makeChart(containerSelector, data) {
27
+ // ---------- 辅助函数 ----------
28
+ // 创建智能日期比例尺和刻度
29
+ const createXAxisScaleAndTicks = (data, xField, rangeStart, rangeEnd) => {
30
+ const dates = data.map(d => chartUtils.format.parseDate(d[xField]));
31
+ const xExtent = d3.extent(dates);
32
+ const xScale = d3.scaleTime().domain(xExtent).range([rangeStart, rangeEnd]);
33
+
34
+ const timeSpan = xExtent[1] - xExtent[0];
35
+ const yearSpan = timeSpan / (1000 * 60 * 60 * 24 * 365);
36
+
37
+ let timeInterval, formatFunction;
38
+ if (yearSpan > 2) {
39
+ timeInterval = d3.timeYear.every(1);
40
+ formatFunction = d => chartUtils.format.date(d, { output: "%Y" }).text;
41
+ } else if (yearSpan > 1) {
42
+ timeInterval = d3.timeMonth.every(3);
43
+ formatFunction = d => `${d.getFullYear().toString().slice(-2)}Q${Math.floor(d.getMonth() / 3) + 1}`;
44
+ } else {
45
+ timeInterval = d3.timeMonth.every(1);
46
+ formatFunction = d => chartUtils.format.date(d, { output: "%m %Y" }).text;
47
+ }
48
+
49
+ const xTicks = xScale.ticks(timeInterval);
50
+ return { xScale, xTicks, xFormat: formatFunction };
51
+ };
52
+
53
+ // 格式化数值
54
+
55
+ // 智能排版图例
56
+ const layoutLegend = (g, groups, colors, options = {}) => {
57
+ const defaults = {
58
+ maxWidth: 500, x: 0, y: 0, itemHeight: 20, itemSpacing: 20,
59
+ rowSpacing: 10, symbolSize: 10, textColor: "#333", fontSize: 12,
60
+ fontWeight: "normal", align: "left", shape: "circle"
61
+ };
62
+
63
+ const opts = {...defaults, ...options};
64
+
65
+ const itemWidths = groups.map(group => opts.symbolSize * 2 + chartUtils.text.measure(g, group, {
66
+ fontSize: opts.fontSize,
67
+ fontWeight: opts.fontWeight
68
+ }).width + 5);
69
+
70
+ const rows = [];
71
+ let currentRow = [];
72
+ let currentRowWidth = 0;
73
+
74
+ itemWidths.forEach((width, i) => {
75
+ if (currentRow.length === 0 || currentRowWidth + width + opts.itemSpacing <= opts.maxWidth) {
76
+ currentRow.push(i);
77
+ currentRowWidth += width + (currentRow.length > 1 ? opts.itemSpacing : 0);
78
+ } else {
79
+ rows.push(currentRow);
80
+ currentRow = [i];
81
+ currentRowWidth = width;
82
+ }
83
+ });
84
+
85
+ if (currentRow.length > 0) {
86
+ rows.push(currentRow);
87
+ }
88
+
89
+ const totalHeight = rows.length * opts.itemHeight + (rows.length - 1) * opts.rowSpacing;
90
+ const maxRowWidth = Math.max(...rows.map(row => {
91
+ return row.reduce((sum, i, idx) => {
92
+ return sum + itemWidths[i] + (idx > 0 ? opts.itemSpacing : 0);
93
+ }, 0);
94
+ }));
95
+
96
+ rows.forEach((row, rowIndex) => {
97
+ const rowWidth = row.reduce((sum, i, idx) => {
98
+ return sum + itemWidths[i] + (idx > 0 ? opts.itemSpacing : 0);
99
+ }, 0);
100
+
101
+ let rowStartX = opts.align === "center" ? opts.x + (opts.maxWidth - rowWidth) / 2 :
102
+ opts.align === "right" ? opts.x + opts.maxWidth - rowWidth : opts.x;
103
+
104
+ let currentX = rowStartX;
105
+ row.forEach(i => {
106
+ const group = groups[i];
107
+ const color = colorResolver.field(group, i, { palette: "category10" }).value;
108
+
109
+ const legendGroup = g.append("g")
110
+ .attr("transform", `translate(${currentX}, ${opts.y + rowIndex * (opts.itemHeight + opts.rowSpacing)})`);
111
+
112
+ if (opts.shape === "line") {
113
+ legendGroup.append("line")
114
+ .attr("x1", 0).attr("y1", opts.itemHeight / 2)
115
+ .attr("x2", opts.symbolSize).attr("y2", opts.itemHeight / 2)
116
+ .attr("stroke", color).attr("stroke-width", 4)
117
+ .attr("class", "mark");
118
+ } else {
119
+ legendGroup.append("circle")
120
+ .attr("cx", opts.symbolSize / 2).attr("cy", opts.itemHeight / 2)
121
+ .attr("r", opts.symbolSize / 2).attr("fill", color)
122
+ .attr("class", "mark");
123
+ }
124
+
125
+ legendGroup.append("text")
126
+ .attr("x", opts.symbolSize * 1.5).attr("y", opts.itemHeight / 2)
127
+ .attr("dominant-baseline", "middle").attr("fill", opts.textColor)
128
+ .attr("class", "label")
129
+ .style("font-size", `${opts.fontSize}px`)
130
+ .style("font-weight", opts.fontWeight)
131
+ .text(group);
132
+
133
+ currentX += itemWidths[i] + opts.itemSpacing;
134
+ });
135
+ });
136
+
137
+ return { width: maxRowWidth, height: totalHeight };
138
+ };
139
+
140
+ // 动态规划标签放置算法
141
+ const placeLabelsDP = (points, avoidYPositions, chartHeight) => {
142
+ const GRID_SIZE = 3;
143
+ const PROTECTION_RADIUS = 3;
144
+ const LABEL_HEIGHT = 10;
145
+
146
+ const gridCount = Math.ceil(chartHeight / GRID_SIZE);
147
+ points.sort((a, b) => a.y - b.y);
148
+
149
+ const occupied = new Array(gridCount).fill(false);
150
+
151
+ // 标记数据点保护区域
152
+ points.forEach(point => {
153
+ const gridY = Math.floor(point.y / GRID_SIZE) - 3;
154
+ for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
155
+ occupied[i] = true;
156
+ }
157
+ });
158
+
159
+ const n = points.length;
160
+ const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
161
+ const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
162
+
163
+ // 初始化第一个点
164
+ const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
165
+ for (let j = 0; j < gridCount; j++) {
166
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
167
+ let canPlace = true;
168
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
169
+ if (j + k < gridCount && occupied[j + k]) {
170
+ canPlace = false;
171
+ break;
172
+ }
173
+ }
174
+ if (canPlace) {
175
+ dp[0][j] = Math.abs(j - firstPointGridY);
176
+ }
177
+ }
178
+ }
179
+
180
+ // 动态规划填表
181
+ for (let i = 1; i < n; i++) {
182
+ const pointGridY = Math.floor(points[i].y / GRID_SIZE);
183
+
184
+ for (let j = 0; j < gridCount; j++) {
185
+ if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
186
+ let canPlace = true;
187
+ for (let k = 0; k < LABEL_HEIGHT; k++) {
188
+ if (j + k < gridCount && occupied[j + k]) {
189
+ canPlace = false;
190
+ break;
191
+ }
192
+ }
193
+
194
+ if (canPlace) {
195
+ for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
196
+ if (dp[i-1][k] !== Infinity) {
197
+ const curCost = Math.abs(j - pointGridY);
198
+ const totalCost = dp[i-1][k] + curCost;
199
+
200
+ if (totalCost < dp[i][j]) {
201
+ dp[i][j] = totalCost;
202
+ prev[i][j] = k;
203
+ }
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ // 找最优解并回溯
212
+ let minCost = Infinity;
213
+ let bestPos = -1;
214
+
215
+ for (let j = 0; j < gridCount; j++) {
216
+ if (dp[n-1][j] < minCost) {
217
+ minCost = dp[n-1][j];
218
+ bestPos = j;
219
+ }
220
+ }
221
+
222
+ const labelPositions = [];
223
+ if (bestPos !== -1) {
224
+ let pos = bestPos;
225
+ for (let i = n - 1; i >= 0; i--) {
226
+ labelPositions.unshift({
227
+ point: points[i],
228
+ labelY: pos * GRID_SIZE
229
+ });
230
+ pos = prev[i][pos];
231
+ }
232
+ } else {
233
+ // 退化方案
234
+ let lastY = 0;
235
+ for (let i = 0; i < n; i++) {
236
+ const point = points[i];
237
+ let maxY = chartHeight;
238
+ if (i < n - 1) {
239
+ maxY = points[i+1].y;
240
+ }
241
+ const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
242
+ labelPositions.push({ point: point, labelY: labelY });
243
+ lastY = labelY;
244
+ }
245
+ }
246
+
247
+ return labelPositions;
248
+ };
249
+
250
+ // ---------- 主要代码 ----------
251
+ const jsonData = data;
252
+ const chartData = jsonData.data.data;
253
+ const variables = jsonData.variables;
254
+ const typography = jsonData.typography;
255
+ const colors = jsonData.colors || {};
256
+ const colorResolver = chartUtils.color.resolver(jsonData);
257
+ const dataColumns = chartUtils.schema.columns(jsonData);
258
+
259
+ d3.select(containerSelector).html("");
260
+
261
+ // 获取字段名
262
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
263
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
264
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
265
+
266
+ // 设置尺寸和边距
267
+ const width = variables.width;
268
+ const height = variables.height;
269
+ const margin = { top: 60, right: 30, bottom: 60, left: 60 };
270
+
271
+ // 创建SVG
272
+ const svg = d3.select(containerSelector)
273
+ .append("svg")
274
+ .attr("width", "100%")
275
+ .attr("height", height)
276
+ .attr("viewBox", `0 0 ${width} ${height}`)
277
+ .attr("style", "max-width: 100%; height: auto;")
278
+ .attr("xmlns", "http://www.w3.org/2000/svg")
279
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
280
+
281
+ const chartWidth = width - margin.left - margin.right;
282
+ const chartHeight = height - margin.top - margin.bottom;
283
+
284
+ const g = svg.append("g")
285
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
286
+
287
+ // 获取唯一的组值
288
+ const groups = [...new Set(chartData.map(d => d[groupField]))];
289
+ const groupedData = d3.group(chartData, d => d[groupField]);
290
+
291
+ const { xScale, xTicks, xFormat } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
292
+
293
+ // 创建y轴比例尺
294
+ const yMin = d3.min(chartData, d => +d[yField]);
295
+ const yMax = d3.max(chartData, d => +d[yField]);
296
+ const yPadding = (yMax - yMin) * 0.3;
297
+ const yDomainMax = yMax + yPadding;
298
+ const yDomainMin = Math.min(0, yMin - yPadding);
299
+
300
+ const yScale = d3.scaleLinear()
301
+ .domain([yDomainMin, yDomainMax])
302
+ .range([chartHeight, 0]);
303
+
304
+ // 颜色函数
305
+ const colorScale = d => colorResolver.field(d, groups.indexOf(d), { palette: "category10" }).value;
306
+
307
+ const yTicks = yScale.ticks(5);
308
+
309
+ // 添加水平网格线
310
+ yTicks.forEach(tick => {
311
+ g.append("line")
312
+ .attr("x1", 0).attr("y1", yScale(tick))
313
+ .attr("x2", chartWidth).attr("y2", yScale(tick))
314
+ .attr("stroke", "#dddddd").attr("stroke-width", 1)
315
+ .attr("class", "gridline");
316
+ });
317
+
318
+ // 添加刻度之间的额外网格线
319
+ if (yTicks.length > 1) {
320
+ for (let i = 0; i < yTicks.length - 1; i++) {
321
+ const midValue = (yTicks[i] + yTicks[i + 1]) / 2;
322
+ g.append("line")
323
+ .attr("x1", 0).attr("y1", yScale(midValue))
324
+ .attr("x2", chartWidth).attr("y2", yScale(midValue))
325
+ .attr("stroke", "#dddddd").attr("stroke-width", 1)
326
+ .attr("class", "gridline");
327
+ }
328
+ }
329
+
330
+ // Y轴刻度文本
331
+ yTicks.forEach(tick => {
332
+ g.append("text")
333
+ .attr("x", -10).attr("y", yScale(tick))
334
+ .attr("text-anchor", "end").attr("dominant-baseline", "middle")
335
+ .attr("class", "value")
336
+ .attr("fill", "#666")
337
+ .style("font-size", "14px")
338
+ .text(chartUtils.format.autoText(tick));
339
+ });
340
+
341
+ // X轴刻度文本
342
+ xTicks.forEach(tick => {
343
+ g.append("text")
344
+ .attr("x", xScale(tick)).attr("y", chartHeight + 20)
345
+ .attr("text-anchor", "middle")
346
+ .attr("class", "label")
347
+ .attr("fill", "#666")
348
+ .style("font-size", "14px")
349
+ .text(xFormat(tick));
350
+ });
351
+
352
+ // X轴线
353
+ g.append("line")
354
+ .attr("x1", 0).attr("y1", chartHeight)
355
+ .attr("x2", chartWidth).attr("y2", chartHeight)
356
+ .attr("stroke", "#aaa").attr("stroke-width", 1)
357
+ .attr("class", "axis");
358
+
359
+ // 创建阶梯线生成器
360
+ const line = d3.line()
361
+ .x(d => xScale(chartUtils.format.parseDate(d[xField])))
362
+ .y(d => yScale(+d[yField]))
363
+ .curve(d3.curveStepAfter);
364
+
365
+ // 收集标注数据点
366
+ const startPoints = [];
367
+ const middlePoints = [];
368
+ const endPoints = [];
369
+
370
+ groupedData.forEach((values, group) => {
371
+ values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
372
+ const color = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
373
+
374
+ // 绘制阶梯线
375
+ g.append("path")
376
+ .datum(values)
377
+ .attr("fill", "none")
378
+ .attr("stroke", color)
379
+ .attr("stroke-width", 4)
380
+ .attr("class", "mark")
381
+ .attr("d", line);
382
+
383
+ const middleIndex = Math.floor(values.length / 2);
384
+
385
+ values.forEach((d, i) => {
386
+ const pointData = {
387
+ x: xScale(chartUtils.format.parseDate(d[xField])),
388
+ y: yScale(+d[yField]),
389
+ value: d[yField],
390
+ color: color,
391
+ group: group,
392
+ point: d
393
+ };
394
+
395
+ if (i === 0) {
396
+ startPoints.push(pointData);
397
+ } else if (i === values.length - 1) {
398
+ endPoints.push(pointData);
399
+ } else if (i === middleIndex) {
400
+ middlePoints.push(pointData);
401
+ }
402
+ });
403
+ });
404
+
405
+ // 应用动态规划算法
406
+ const startLabelPositions = placeLabelsDP(startPoints, yTicks.map(tick => yScale(tick)), chartHeight);
407
+ const middleLabelPositions = placeLabelsDP(middlePoints, yTicks.map(tick => yScale(tick)), chartHeight);
408
+ const endLabelPositions = placeLabelsDP(endPoints, yTicks.map(tick => yScale(tick)), chartHeight);
409
+
410
+ // 绘制标签函数
411
+ const drawLabels = (labelPositions) => {
412
+ labelPositions.forEach(placement => {
413
+ const point = placement.point;
414
+ const labelY = placement.labelY;
415
+
416
+ const labelText = chartUtils.format.autoText(point.value);
417
+ const labelWidth = labelText.toString().length * 10 + 10;
418
+ const labelHeight = 24;
419
+
420
+ // 圆角矩形背景
421
+ g.append("rect")
422
+ .attr("x", point.x - labelWidth / 2)
423
+ .attr("y", labelY + labelHeight / 2)
424
+ .attr("width", labelWidth).attr("height", labelHeight)
425
+ .attr("rx", 5).attr("ry", 5)
426
+ .attr("fill", point.color)
427
+ .attr("class", "mark");
428
+
429
+ // 标签文本
430
+ g.append("text")
431
+ .attr("x", point.x)
432
+ .attr("y", labelY + labelHeight)
433
+ .attr("text-anchor", "middle")
434
+ .attr("dominant-baseline", "middle")
435
+ .attr("fill", "#fff").attr("font-weight", "bold")
436
+ .attr("class", "value")
437
+ .style("font-size", "14px")
438
+ .text(labelText);
439
+ });
440
+ };
441
+
442
+ // 绘制所有标签
443
+ drawLabels(startLabelPositions);
444
+ drawLabels(middleLabelPositions);
445
+ drawLabels(endLabelPositions);
446
+
447
+ // 添加图例
448
+ const legendGroup = g.append("g");
449
+
450
+ const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
451
+ x: 0, y: 0, fontSize: 14, fontWeight: "bold",
452
+ align: "left", maxWidth: chartWidth, shape: "line"
453
+ });
454
+
455
+ const maxYTickPosition = yScale(yTicks[yTicks.length - 1]);
456
+ legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
457
+
458
+ return svg.node();
459
+ }