Ray1ee01 commited on
Commit
34be5a5
·
verified ·
1 Parent(s): 40cdcb2

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type31_small_multiples_of_stepped_line_graphs/small_multiple_step_line_graph_icons_01.js ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Small Multiple Step Line Graph",
5
+ "chart_name": "small_multiple_step_line_graph_icons_01",
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": ["group"],
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": "side",
19
+ "has_x_axis": "yes",
20
+ "has_y_axis": "yes"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, dataJSON) {
26
+ const jsonData = dataJSON || {};
27
+ const dataColumns = chartUtils.schema.columns(jsonData);
28
+ const colorResolver = chartUtils.color.resolver(jsonData);
29
+ const chartUtilsTextSample = chartUtils.text.estimate;
30
+ const chartUtilsStandard = {
31
+ schema: chartUtils.schema,
32
+ format: chartUtils.format,
33
+ text: chartUtils.text,
34
+ color: chartUtils.color,
35
+ legendLayout: chartUtils.legend.layout,
36
+ legendDraw: chartUtils.legend.draw,
37
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
38
+ };
39
+ const standardChannels = chartUtils.schema.channels(jsonData, {
40
+ x: { fallbackIndex: 0 },
41
+ y: { fallbackIndex: 1 },
42
+ y2: { fallbackIndex: 2 },
43
+ y3: { fallbackIndex: 3 },
44
+ size: { fallbackIndex: 2 },
45
+ group: { fallbackIndex: 2 },
46
+ group2: { fallbackIndex: 3 },
47
+ group3: { fallbackIndex: 4 }
48
+ });
49
+ const dataBlock = jsonData.data || {};
50
+ const sourceRows = dataBlock.data || [];
51
+ const columns = dataBlock.columns || [];
52
+ const variables = jsonData.variables || {};
53
+ const typography = jsonData.typography || {};
54
+ const sourceColors = jsonData.colors || {};
55
+
56
+ d3.select(containerSelector).html("");
57
+
58
+ const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
59
+ const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
60
+ const groupColumn = chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || {};
61
+ const xField = xColumn.name;
62
+ const yField = yColumn.name;
63
+ const groupField = groupColumn.name;
64
+ const yUnit = yColumn.unit === "none" ? "" : (yColumn.unit || "");
65
+
66
+ const width = Math.max(560, Number(variables.width) || 800);
67
+ const baseHeight = Math.max(420, Number(variables.height) || 560);
68
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
69
+ const annotationFamily = typography.annotation?.font_family || fontFamily;
70
+ const textColor = sourceColors.text_color || "#1f2937";
71
+ const mutedText = "#64748b";
72
+ const gridColor = "rgba(100, 116, 139, 0.22)";
73
+ const palette = sourceColors.available_colors?.length
74
+ ? sourceColors.available_colors
75
+ : ["#2563eb", "#f97316", "#10b981", "#8b5cf6", "#ef4444", "#0f766e"];
76
+ const labelSize = Math.max(8.5, Math.min(11, parseFloat(typography.label?.font_size) || 10));
77
+ const valueSize = Math.max(8, Math.min(10, parseFloat(typography.annotation?.font_size) || 9));
78
+ const labelWeight = typography.label?.font_weight || "700";
79
+ const valueWeight = typography.annotation?.font_weight || "650";
80
+
81
+ const svg = d3.select(containerSelector)
82
+ .append("svg")
83
+ .attr("width", "100%")
84
+ .attr("style", "max-width: 100%; height: auto;")
85
+ .attr("xmlns", "http://www.w3.org/2000/svg")
86
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
87
+ .attr("class", "small-multiple-step-line-graph");
88
+
89
+ function message(text) {
90
+ svg.attr("height", baseHeight).attr("viewBox", `0 0 ${width} ${baseHeight}`);
91
+ svg.append("text")
92
+ .attr("x", width / 2)
93
+ .attr("y", baseHeight / 2)
94
+ .attr("text-anchor", "middle")
95
+ .attr("fill", textColor)
96
+ .style("font-family", fontFamily)
97
+ .style("font-size", "16px")
98
+ .text(text);
99
+ return svg.node();
100
+ }
101
+
102
+ if (!xField || !yField || !groupField) return message("Missing small multiple step line fields");
103
+
104
+ function readableLabel(value) {
105
+ return String(value ?? "")
106
+ .replace(/_/g, " ")
107
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
108
+ .replace(/\s+/g, " ")
109
+ .trim();
110
+ }
111
+
112
+ function parseDateLocal(value) {
113
+ if (value instanceof Date) return value;
114
+ if (typeof value === "number" && Number.isFinite(value)) return new Date(value, 0, 1);
115
+ const text = String(value ?? "").trim();
116
+ let match = text.match(/^(-?\d{4})$/);
117
+ if (match) return new Date(Number(match[1]), 0, 1);
118
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
119
+ if (match) return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1));
120
+ match = text.match(/^Q([1-4])\s+(-?\d{4})$/i);
121
+ if (match) return new Date(Number(match[2]), (Number(match[1]) - 1) * 3, 1);
122
+ match = text.match(/^(-?\d{4})\s+Q([1-4])$/i);
123
+ if (match) return new Date(Number(match[1]), (Number(match[2]) - 1) * 3, 1);
124
+ const parsed = new Date(text);
125
+ return Number.isFinite(parsed.getTime()) ? parsed : null;
126
+ }
127
+
128
+ function compactNumber(value) {
129
+ const numeric = Number(value);
130
+ const abs = Math.abs(numeric);
131
+ if (abs >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
132
+ if (abs >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
133
+ if (abs >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
134
+ return d3.format(",.4~g")(numeric);
135
+ }
136
+
137
+ function formatLocalValue(value, unit = "") {
138
+ const numeric = Number(value);
139
+ const formatted = compactNumber(numeric);
140
+ if (!unit) return formatted;
141
+ if (unit === "%") return `${formatted}%`;
142
+ if (unit.startsWith("$")) {
143
+ const sign = numeric < 0 ? "-" : "";
144
+ return `${sign}$${compactNumber(Math.abs(numeric))}${unit.slice(1)}`;
145
+ }
146
+ if (unit.length <= 3 && /^[^A-Za-z0-9]+$/.test(unit)) return `${unit}${formatted}`;
147
+ return `${formatted} ${unit}`;
148
+ }
149
+
150
+ function truncateText(text, maxChars) {
151
+ if (text.length <= maxChars) return text;
152
+ return `${text.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
153
+ }
154
+
155
+ const rows = sourceRows.map((row, index) => {
156
+ const date = chartUtils.format.parseDate(row[xField]);
157
+ const value = Number(row[yField]);
158
+ const group = readableLabel(row[groupField]);
159
+ if (!date || !Number.isFinite(date.getTime()) || !Number.isFinite(value) || !group) return null;
160
+ return {
161
+ rawX: row[xField],
162
+ date,
163
+ value,
164
+ group,
165
+ rawGroup: row[groupField],
166
+ index
167
+ };
168
+ }).filter(Boolean);
169
+ if (!rows.length) return message("No valid data for small multiple step lines");
170
+
171
+ const groupMap = d3.group(rows, d => d.group);
172
+ const groups = Array.from(groupMap, ([group, values]) => {
173
+ values.sort((a, b) => d3.ascending(a.date, b.date) || d3.ascending(a.index, b.index));
174
+ return {
175
+ group,
176
+ values,
177
+ firstIndex: d3.min(values, d => d.index),
178
+ lastValue: values[values.length - 1]?.value ?? 0
179
+ };
180
+ }).sort((a, b) => d3.descending(a.lastValue, b.lastValue) || d3.ascending(a.firstIndex, b.firstIndex));
181
+
182
+ const cols = groups.length === 1 ? 1 : 2;
183
+ const panelRows = Math.ceil(groups.length / cols);
184
+ const outer = { top: 34, right: 28, bottom: 52, left: 42 };
185
+ const gapX = 42;
186
+ const gapY = 44;
187
+ const minPanelHeight = groups.length <= 2 ? 220 : 150;
188
+ const height = Math.max(420, outer.top + outer.bottom + panelRows * minPanelHeight + (panelRows - 1) * gapY);
189
+ svg.attr("height", height).attr("viewBox", `0 0 ${width} ${height}`);
190
+
191
+ const panelWidth = (width - outer.left - outer.right - gapX * (cols - 1)) / cols;
192
+ const panelHeight = (height - outer.top - outer.bottom - gapY * (panelRows - 1)) / panelRows;
193
+ const inner = { top: 24, right: 16, bottom: 30, left: 42 };
194
+ const innerWidth = Math.max(120, panelWidth - inner.left - inner.right);
195
+ const innerHeight = Math.max(80, panelHeight - inner.top - inner.bottom);
196
+
197
+ const xExtent = d3.extent(rows, d => d.date);
198
+ const xScaleBase = d3.scaleTime()
199
+ .domain(xExtent[0].getTime() === xExtent[1].getTime()
200
+ ? [d3.timeYear.offset(xExtent[0], -1), d3.timeYear.offset(xExtent[1], 1)]
201
+ : xExtent)
202
+ .range([0, innerWidth]);
203
+
204
+ const spanDays = (xScaleBase.domain()[1] - xScaleBase.domain()[0]) / 86400000;
205
+ const xTicks = spanDays > 365 * 10
206
+ ? xScaleBase.ticks(d3.timeYear.every(5))
207
+ : (spanDays > 365 * 4 ? xScaleBase.ticks(d3.timeYear.every(2)) : xScaleBase.ticks(Math.min(4, Math.max(2, Math.floor(innerWidth / 80)))));
208
+ const xFormat = spanDays > 365 * 2 ? d3.timeFormat("%Y") : d3.timeFormat("%b %Y");
209
+
210
+ const colorFor = (group, index) => sourceColors.field?.[group]
211
+ || sourceColors.field?.[groups[index]?.values[0]?.rawGroup]
212
+ || palette[index % palette.length];
213
+
214
+ groups.forEach((entry, index) => {
215
+ const row = Math.floor(index / cols);
216
+ const col = index % cols;
217
+ const panelX = outer.left + col * (panelWidth + gapX);
218
+ const panelY = outer.top + row * (panelHeight + gapY);
219
+ const color = colorFor(entry.group, index);
220
+
221
+ const panel = svg.append("g")
222
+ .attr("class", "small-multiple-panel")
223
+ .attr("data-group", entry.group)
224
+ .attr("transform", `translate(${panelX}, ${panelY})`);
225
+
226
+ panel.append("rect")
227
+ .attr("class", "panel-background")
228
+ .attr("x", 0)
229
+ .attr("y", 0)
230
+ .attr("width", panelWidth)
231
+ .attr("height", panelHeight)
232
+ .attr("rx", 4)
233
+ .attr("fill", "rgba(255, 255, 255, 0.20)")
234
+ .attr("stroke", "rgba(100, 116, 139, 0.18)");
235
+
236
+ panel.append("text")
237
+ .attr("class", "panel-title")
238
+ .attr("x", inner.left)
239
+ .attr("y", 14)
240
+ .attr("fill", textColor)
241
+ .style("font-family", fontFamily)
242
+ .style("font-size", `${labelSize}px`)
243
+ .style("font-weight", labelWeight)
244
+ .text(truncateText(entry.group, Math.max(16, Math.floor(panelWidth / 8))));
245
+
246
+ const g = panel.append("g")
247
+ .attr("class", "panel-plot")
248
+ .attr("transform", `translate(${inner.left}, ${inner.top})`);
249
+
250
+ const minY = d3.min(entry.values, d => d.value) ?? 0;
251
+ const maxY = d3.max(entry.values, d => d.value) ?? 0;
252
+ const spanY = Math.max(1, maxY - minY);
253
+ const yScale = d3.scaleLinear()
254
+ .domain([Math.min(0, minY - spanY * 0.12), maxY + spanY * 0.12])
255
+ .nice(3)
256
+ .range([innerHeight, 0]);
257
+
258
+ const yTicks = yScale.ticks(3);
259
+ yTicks.forEach(tick => {
260
+ const y = yScale(tick);
261
+ g.append("line")
262
+ .attr("class", "gridline y-axis-gridline")
263
+ .attr("x1", 0)
264
+ .attr("x2", innerWidth)
265
+ .attr("y1", y)
266
+ .attr("y2", y)
267
+ .attr("stroke", gridColor);
268
+ g.append("text")
269
+ .attr("class", "y-axis-tick")
270
+ .attr("x", -6)
271
+ .attr("y", y)
272
+ .attr("text-anchor", "end")
273
+ .attr("dominant-baseline", "middle")
274
+ .attr("fill", mutedText)
275
+ .style("font-family", annotationFamily)
276
+ .style("font-size", `${valueSize}px`)
277
+ .style("font-weight", valueWeight)
278
+ .text(formatLocalValue(tick, yUnit));
279
+ });
280
+
281
+ xTicks.forEach(tick => {
282
+ const x = xScaleBase(tick);
283
+ g.append("line")
284
+ .attr("class", "gridline x-axis-gridline")
285
+ .attr("x1", x)
286
+ .attr("x2", x)
287
+ .attr("y1", 0)
288
+ .attr("y2", innerHeight)
289
+ .attr("stroke", gridColor);
290
+ g.append("text")
291
+ .attr("class", "x-axis-tick")
292
+ .attr("x", x)
293
+ .attr("y", innerHeight + 18)
294
+ .attr("text-anchor", "middle")
295
+ .attr("fill", mutedText)
296
+ .style("font-family", annotationFamily)
297
+ .style("font-size", `${Math.max(7.5, valueSize - 1)}px`)
298
+ .style("font-weight", valueWeight)
299
+ .text(xFormat(tick));
300
+ });
301
+
302
+ g.append("line")
303
+ .attr("class", "axis-line x-axis-line")
304
+ .attr("x1", 0)
305
+ .attr("x2", innerWidth)
306
+ .attr("y1", innerHeight)
307
+ .attr("y2", innerHeight)
308
+ .attr("stroke", "rgba(71, 85, 105, 0.50)");
309
+ g.append("line")
310
+ .attr("class", "axis-line y-axis-line")
311
+ .attr("x1", 0)
312
+ .attr("x2", 0)
313
+ .attr("y1", 0)
314
+ .attr("y2", innerHeight)
315
+ .attr("stroke", "rgba(71, 85, 105, 0.50)");
316
+
317
+ const line = d3.line()
318
+ .x(d => xScaleBase(d.date))
319
+ .y(d => yScale(d.value))
320
+ .curve(d3.curveStepAfter);
321
+
322
+ g.append("path")
323
+ .datum(entry.values)
324
+ .attr("class", "step-line")
325
+ .attr("data-group", entry.group)
326
+ .attr("d", line)
327
+ .attr("fill", "none")
328
+ .attr("stroke", color)
329
+ .attr("stroke-width", 2.4)
330
+ .attr("stroke-linejoin", "round")
331
+ .attr("stroke-linecap", "round");
332
+
333
+ entry.values.forEach(point => {
334
+ g.append("circle")
335
+ .attr("class", "data-point")
336
+ .attr("data-group", entry.group)
337
+ .attr("data-x", String(point.rawX))
338
+ .attr("data-value", point.value)
339
+ .attr("cx", xScaleBase(point.date))
340
+ .attr("cy", yScale(point.value))
341
+ .attr("r", 2.7)
342
+ .attr("fill", color)
343
+ .attr("stroke", "#ffffff")
344
+ .attr("stroke-width", 0.8);
345
+ });
346
+
347
+ const last = entry.values[entry.values.length - 1];
348
+ const labelX = Math.min(innerWidth - 2, xScaleBase(last.date) + 4);
349
+ const labelY = Math.max(valueSize, Math.min(innerHeight - 2, yScale(last.value) - 5));
350
+ g.append("text")
351
+ .attr("class", "endpoint-label value-label")
352
+ .attr("data-group", entry.group)
353
+ .attr("x", labelX)
354
+ .attr("y", labelY)
355
+ .attr("text-anchor", "end")
356
+ .attr("fill", textColor)
357
+ .style("font-family", annotationFamily)
358
+ .style("font-size", `${valueSize}px`)
359
+ .style("font-weight", valueWeight)
360
+ .text(formatLocalValue(last.value, yUnit));
361
+ });
362
+
363
+ svg.append("text")
364
+ .attr("class", "scale-note")
365
+ .attr("x", width / 2)
366
+ .attr("y", height - 28)
367
+ .attr("text-anchor", "middle")
368
+ .attr("fill", mutedText)
369
+ .style("font-family", annotationFamily)
370
+ .style("font-size", `${Math.max(8, valueSize)}px`)
371
+ .text(`${readableLabel(yColumn.label || yField)} uses local panel y-scales; x-axis is chronological`);
372
+
373
+ svg.append("text")
374
+ .attr("class", "category-note")
375
+ .attr("x", width / 2)
376
+ .attr("y", height - 12)
377
+ .attr("text-anchor", "middle")
378
+ .attr("fill", mutedText)
379
+ .style("font-family", annotationFamily)
380
+ .style("font-size", `${Math.max(7.5, valueSize - 1)}px`)
381
+ .text(`${readableLabel(groupColumn.label || groupField)} panels ordered by latest value; group icons removed`);
382
+
383
+ return svg.node();
384
+ }
modules/chart_engine/template/d3-js/type31_small_multiples_of_stepped_line_graphs/small_multiple_step_line_graph_plain_chart_01.js ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Small Multiple Stepped Line Graph",
5
+ "chart_name": "small_multiple_step_line_graph_plain_chart_01",
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": "no",
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 dataColumns = chartUtils.schema.columns(jsonData);
29
+ const colorResolver = chartUtils.color.resolver(jsonData);
30
+ const chartUtilsFormatSample = chartUtils.format.autoText;
31
+ const chartUtilsTextSample = chartUtils.text.estimate;
32
+ const chartUtilsStandard = {
33
+ schema: chartUtils.schema,
34
+ format: chartUtils.format,
35
+ text: chartUtils.text,
36
+ color: chartUtils.color,
37
+ legendLayout: chartUtils.legend.layout,
38
+ legendDraw: chartUtils.legend.draw,
39
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
40
+ };
41
+ const standardChannels = chartUtils.schema.channels(jsonData, {
42
+ x: { fallbackIndex: 0 },
43
+ y: { fallbackIndex: 1 },
44
+ y2: { fallbackIndex: 2 },
45
+ y3: { fallbackIndex: 3 },
46
+ size: { fallbackIndex: 2 },
47
+ group: { fallbackIndex: 2 },
48
+ group2: { fallbackIndex: 3 },
49
+ group3: { fallbackIndex: 4 }
50
+ });
51
+ const variables = jsonData.variables || {};
52
+ const typography = jsonData.typography || {};
53
+ const sourceColors = jsonData.colors || {};
54
+ const colorField = sourceColors.field || {};
55
+
56
+ d3.select(containerSelector).html("");
57
+
58
+ const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
59
+ const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
60
+ const groupColumn = chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || {};
61
+ const xField = xColumn.name;
62
+ const yField = yColumn.name;
63
+ const groupField = groupColumn.name;
64
+ const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
65
+
66
+ const width = Number(variables.width) || 600;
67
+ const height = Number(variables.height) || 600;
68
+ const labelTypography = typography.label || {};
69
+ const titleTypography = typography.title || labelTypography;
70
+ const valueTypography = typography.annotation || labelTypography;
71
+ const fontFamily = labelTypography.font_family || titleTypography.font_family || "Arial, sans-serif";
72
+ const titleFamily = titleTypography.font_family || fontFamily;
73
+ const valueFamily = valueTypography.font_family || fontFamily;
74
+ const textColor = sourceColors.text_color || "#1f2937";
75
+ const mutedText = "#64748b";
76
+ const baseLabelSize = parseFloat(labelTypography.font_size) || 11;
77
+ const baseTitleSize = parseFloat(titleTypography.font_size) || 13;
78
+ const baseValueSize = parseFloat(valueTypography.font_size) || 10.5;
79
+ const labelWeight = labelTypography.font_weight || "500";
80
+ const titleWeight = titleTypography.font_weight || "700";
81
+ const valueWeight = valueTypography.font_weight || "650";
82
+
83
+ const svg = d3.select(containerSelector)
84
+ .append("svg")
85
+ .attr("width", "100%")
86
+ .attr("height", height)
87
+ .attr("viewBox", `0 0 ${width} ${height}`)
88
+ .attr("style", "max-width: 100%; height: auto;")
89
+ .attr("xmlns", "http://www.w3.org/2000/svg")
90
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
91
+ .attr("class", "small-multiple-step-line-chart-root");
92
+
93
+ if (!xField || !yField || !groupField) {
94
+ svg.append("text")
95
+ .attr("class", "chart-message")
96
+ .attr("x", width / 2)
97
+ .attr("y", height / 2)
98
+ .attr("text-anchor", "middle")
99
+ .attr("fill", textColor)
100
+ .style("font-family", fontFamily)
101
+ .style("font-size", "16px")
102
+ .text("Missing stepped-line fields");
103
+ return svg.node();
104
+ }
105
+
106
+ function rgba(color, opacity) {
107
+ const c = d3.rgb(color);
108
+ return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
109
+ }
110
+
111
+ function readableLabel(value) {
112
+ return String(value ?? "")
113
+ .replace(/_/g, " ")
114
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
115
+ .replace(/\s+/g, " ")
116
+ .trim();
117
+ }
118
+
119
+ function parseTemporalDate(value) {
120
+ if (value instanceof Date && Number.isFinite(value.getTime())) return value;
121
+ if (typeof value === "number" && Number.isFinite(value)) return new Date(Date.UTC(value, 0, 1));
122
+ const text = String(value ?? "").trim();
123
+ if (!text) return null;
124
+
125
+ let match = text.match(/^(-?\d{4})$/);
126
+ if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
127
+
128
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
129
+ if (match) {
130
+ return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
131
+ }
132
+
133
+ match = text.match(/^q([1-4])\s*(-?\d{4})$/i) || text.match(/^(-?\d{4})\s*q([1-4])$/i);
134
+ if (match) {
135
+ const year = match[1].length === 4 ? Number(match[1]) : Number(match[2]);
136
+ const quarter = match[1].length === 4 ? Number(match[2]) : Number(match[1]);
137
+ return new Date(Date.UTC(year, (quarter - 1) * 3, 1));
138
+ }
139
+
140
+ const parsed = new Date(text);
141
+ if (Number.isFinite(parsed.getTime())) {
142
+ return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()));
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function compactNumber(value) {
148
+ const numeric = Number(value);
149
+ if (!Number.isFinite(numeric)) return String(value ?? "");
150
+ const abs = Math.abs(numeric);
151
+ if (abs >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
152
+ if (abs >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
153
+ if (abs >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
154
+ return d3.format(",.4~g")(numeric);
155
+ }
156
+
157
+ function formatLocalValue(value, includeUnit = true) {
158
+ const formatted = compactNumber(value);
159
+ if (!includeUnit || !yUnit) return formatted;
160
+ if (yUnit === "%") return `${formatted}%`;
161
+ const currencyWithSuffix = yUnit.match(/^([£$€¥])\s*([A-Za-z]+)$/);
162
+ if (currencyWithSuffix) return `${currencyWithSuffix[1]}${formatted}${currencyWithSuffix[2]}`;
163
+ if (/^[£$€¥]$/.test(yUnit)) return `${yUnit}${formatted}`;
164
+ return `${formatted} ${yUnit}`;
165
+ }
166
+
167
+ function wrapText(text, maxChars) {
168
+ const words = readableLabel(text).split(/\s+/).filter(Boolean);
169
+ if (!words.length) return [String(text ?? "")];
170
+ const lines = [];
171
+ let current = "";
172
+ words.forEach(word => {
173
+ const next = current ? `${current} ${word}` : word;
174
+ if (next.length <= maxChars) {
175
+ current = next;
176
+ return;
177
+ }
178
+ if (current) lines.push(current);
179
+ if (word.length <= maxChars) {
180
+ current = word;
181
+ return;
182
+ }
183
+ for (let i = 0; i < word.length; i += maxChars) {
184
+ lines.push(word.slice(i, i + maxChars));
185
+ }
186
+ current = "";
187
+ });
188
+ if (current) lines.push(current);
189
+ return lines.length ? lines : [String(text ?? "")];
190
+ }
191
+
192
+ function selectEvenTicks(values, maxTicks) {
193
+ if (values.length <= maxTicks) return values;
194
+ const selected = [];
195
+ for (let i = 0; i < maxTicks; i += 1) {
196
+ const index = Math.round(i * (values.length - 1) / (maxTicks - 1));
197
+ const value = values[index];
198
+ if (!selected.includes(value)) selected.push(value);
199
+ }
200
+ return selected;
201
+ }
202
+
203
+ const parsedRows = chartData.map((d, index) => {
204
+ const date = parseTemporalDate(d[xField]);
205
+ const value = Number(d[yField]);
206
+ const group = readableLabel(d[groupField]);
207
+ return {
208
+ raw: d,
209
+ index,
210
+ date,
211
+ time: date ? date.getTime() : NaN,
212
+ xLabel: String(d[xField] ?? ""),
213
+ value,
214
+ group
215
+ };
216
+ }).filter(d => d.date && Number.isFinite(d.time) && Number.isFinite(d.value) && d.group);
217
+
218
+ if (parsedRows.length === 0) {
219
+ svg.append("text")
220
+ .attr("class", "chart-message")
221
+ .attr("x", width / 2)
222
+ .attr("y", height / 2)
223
+ .attr("text-anchor", "middle")
224
+ .attr("fill", textColor)
225
+ .style("font-family", fontFamily)
226
+ .style("font-size", "16px")
227
+ .text("Not enough temporal data");
228
+ return svg.node();
229
+ }
230
+
231
+ const groups = [];
232
+ parsedRows.forEach(d => {
233
+ if (!groups.includes(d.group)) groups.push(d.group);
234
+ });
235
+
236
+ const xLabelByTime = new Map();
237
+ parsedRows.forEach(d => {
238
+ if (!xLabelByTime.has(d.time)) xLabelByTime.set(d.time, d.xLabel);
239
+ });
240
+
241
+ const groupSeries = groups.map(group => {
242
+ const byTime = new Map();
243
+ parsedRows.filter(d => d.group === group).forEach(d => {
244
+ if (!byTime.has(d.time)) {
245
+ byTime.set(d.time, {
246
+ date: d.date,
247
+ time: d.time,
248
+ label: d.xLabel,
249
+ valueSum: 0,
250
+ count: 0
251
+ });
252
+ }
253
+ const entry = byTime.get(d.time);
254
+ entry.valueSum += d.value;
255
+ entry.count += 1;
256
+ });
257
+ const values = Array.from(byTime.values())
258
+ .map(d => ({
259
+ date: d.date,
260
+ time: d.time,
261
+ label: d.label,
262
+ value: d.valueSum / d.count
263
+ }))
264
+ .sort((a, b) => a.time - b.time);
265
+ return {
266
+ group,
267
+ values,
268
+ latestValue: values[values.length - 1]?.value,
269
+ meanValue: d3.mean(values, d => d.value) || 0
270
+ };
271
+ }).filter(d => d.values.length >= 2);
272
+
273
+ if (groupSeries.length === 0) {
274
+ svg.append("text")
275
+ .attr("class", "chart-message")
276
+ .attr("x", width / 2)
277
+ .attr("y", height / 2)
278
+ .attr("text-anchor", "middle")
279
+ .attr("fill", textColor)
280
+ .style("font-family", fontFamily)
281
+ .style("font-size", "16px")
282
+ .text("Not enough points per group");
283
+ return svg.node();
284
+ }
285
+
286
+ const xValues = Array.from(new Set(parsedRows.map(d => d.time)))
287
+ .sort((a, b) => a - b)
288
+ .map(time => new Date(time));
289
+ const allValues = groupSeries.flatMap(series => series.values.map(d => d.value));
290
+ let yMin = d3.min(allValues);
291
+ let yMax = d3.max(allValues);
292
+ if (yMin === yMax) {
293
+ const pad = Math.max(1, Math.abs(yMin) * 0.12);
294
+ yMin -= pad;
295
+ yMax += pad;
296
+ } else {
297
+ const pad = (yMax - yMin) * 0.12;
298
+ yMin -= pad;
299
+ yMax += pad;
300
+ }
301
+ const rawMin = d3.min(allValues);
302
+ const rawMax = d3.max(allValues);
303
+ if (rawMin >= 0) yMin = Math.max(0, yMin);
304
+ if (rawMax <= 0) yMax = Math.min(0, yMax);
305
+
306
+ const columns = groupSeries.length <= 2 ? groupSeries.length : (groupSeries.length <= 4 ? 2 : 3);
307
+ const rows = Math.ceil(groupSeries.length / columns);
308
+ const outer = { top: 22, right: 28, bottom: 38, left: 30 };
309
+ const gapX = groupSeries.length > 4 ? 18 : 24;
310
+ const gapY = rows > 1 ? 26 : 18;
311
+ const panelWidth = (width - outer.left - outer.right - gapX * (columns - 1)) / columns;
312
+ const panelHeight = (height - outer.top - outer.bottom - gapY * (rows - 1)) / rows;
313
+ const plot = {
314
+ left: Math.min(54, Math.max(40, panelWidth * 0.2)),
315
+ right: 18,
316
+ top: 46,
317
+ bottom: 34
318
+ };
319
+ const innerWidth = Math.max(82, panelWidth - plot.left - plot.right);
320
+ const innerHeight = Math.max(70, panelHeight - plot.top - plot.bottom);
321
+
322
+ const xScale = d3.scaleUtc()
323
+ .domain(d3.extent(xValues))
324
+ .range([0, innerWidth]);
325
+ const yScale = d3.scaleLinear()
326
+ .domain([yMin, yMax])
327
+ .nice(4)
328
+ .range([innerHeight, 0]);
329
+ const yTicks = yScale.ticks(innerHeight < 92 ? 3 : 4);
330
+ const xTickCount = innerWidth < 135 ? 2 : (innerWidth < 220 ? 3 : 4);
331
+ const xTicks = selectEvenTicks(xValues, xTickCount);
332
+ const palette = sourceColors.available_colors || d3.schemeTableau10 || d3.schemeCategory10;
333
+ const colorForGroup = group => {
334
+ if (colorField[group]) return colorField[group];
335
+ const index = groupSeries.findIndex(d => d.group === group);
336
+ return palette[index % palette.length] || "#2563eb";
337
+ };
338
+
339
+ function formatXTick(date) {
340
+ const label = xLabelByTime.get(date.getTime());
341
+ if (label) return String(label);
342
+ return d3.utcFormat("%Y")(date);
343
+ }
344
+
345
+ const stepLine = d3.line()
346
+ .defined(d => Number.isFinite(d.value))
347
+ .x(d => xScale(d.date))
348
+ .y(d => yScale(d.value))
349
+ .curve(d3.curveStepAfter);
350
+
351
+ svg.append("text")
352
+ .attr("class", "scale-note axis-title")
353
+ .attr("x", outer.left)
354
+ .attr("y", 12)
355
+ .attr("fill", mutedText)
356
+ .style("font-family", fontFamily)
357
+ .style("font-size", "10px")
358
+ .style("font-weight", "600")
359
+ .text(`Shared ${readableLabel(yColumn.label || yField)} scale: ${formatLocalValue(yScale.domain()[0])} to ${formatLocalValue(yScale.domain()[1])}`);
360
+
361
+ groupSeries.forEach((series, index) => {
362
+ const row = Math.floor(index / columns);
363
+ const col = index % columns;
364
+ const rowCount = row === rows - 1 ? groupSeries.length - row * columns : columns;
365
+ const rowOffset = (columns - rowCount) * (panelWidth + gapX) / 2;
366
+ const panelX = outer.left + rowOffset + col * (panelWidth + gapX);
367
+ const panelY = outer.top + row * (panelHeight + gapY);
368
+ const color = colorForGroup(series.group);
369
+
370
+ const panel = svg.append("g")
371
+ .attr("class", "subplot step-line-panel")
372
+ .attr("data-group", series.group)
373
+ .attr("transform", `translate(${panelX}, ${panelY})`);
374
+
375
+ panel.append("rect")
376
+ .attr("class", "subplot-background")
377
+ .attr("width", panelWidth)
378
+ .attr("height", panelHeight)
379
+ .attr("rx", 6)
380
+ .attr("fill", rgba(textColor, 0.025))
381
+ .attr("stroke", rgba(textColor, 0.08))
382
+ .attr("stroke-width", 1);
383
+
384
+ panel.append("line")
385
+ .attr("class", "group-swatch")
386
+ .attr("x1", 12)
387
+ .attr("x2", 32)
388
+ .attr("y1", 17)
389
+ .attr("y2", 17)
390
+ .attr("stroke", color)
391
+ .attr("stroke-width", 3)
392
+ .attr("stroke-linecap", "round");
393
+
394
+ const titleLines = wrapText(series.group, Math.max(8, Math.floor((panelWidth - 50) / 7.2))).slice(0, 2);
395
+ const title = panel.append("text")
396
+ .attr("class", "group-label")
397
+ .attr("x", 40)
398
+ .attr("y", 14)
399
+ .attr("fill", textColor)
400
+ .style("font-family", titleFamily)
401
+ .style("font-size", `${Math.max(9.5, Math.min(baseTitleSize, 12.5))}px`)
402
+ .style("font-weight", titleWeight);
403
+ titleLines.forEach((line, lineIndex) => {
404
+ title.append("tspan")
405
+ .attr("x", 40)
406
+ .attr("dy", lineIndex === 0 ? 0 : 12)
407
+ .text(line);
408
+ });
409
+
410
+ const chart = panel.append("g")
411
+ .attr("class", "panel-plot")
412
+ .attr("transform", `translate(${plot.left}, ${plot.top})`);
413
+
414
+ yTicks.forEach(tick => {
415
+ chart.append("line")
416
+ .attr("class", "gridline y-gridline")
417
+ .attr("x1", 0)
418
+ .attr("x2", innerWidth)
419
+ .attr("y1", yScale(tick))
420
+ .attr("y2", yScale(tick))
421
+ .attr("stroke", rgba(textColor, 0.14))
422
+ .attr("stroke-width", 1);
423
+ chart.append("text")
424
+ .attr("class", "axis-tick y-axis-tick")
425
+ .attr("x", -7)
426
+ .attr("y", yScale(tick))
427
+ .attr("text-anchor", "end")
428
+ .attr("dominant-baseline", "middle")
429
+ .attr("fill", rgba(textColor, 0.68))
430
+ .style("font-family", fontFamily)
431
+ .style("font-size", `${Math.max(8, Math.min(baseLabelSize, 9.5))}px`)
432
+ .style("font-weight", labelWeight)
433
+ .text(formatLocalValue(tick));
434
+ });
435
+
436
+ chart.append("line")
437
+ .attr("class", "axis-line x-axis-line")
438
+ .attr("x1", 0)
439
+ .attr("x2", innerWidth)
440
+ .attr("y1", innerHeight)
441
+ .attr("y2", innerHeight)
442
+ .attr("stroke", rgba(textColor, 0.34))
443
+ .attr("stroke-width", 1);
444
+
445
+ xTicks.forEach(tick => {
446
+ chart.append("line")
447
+ .attr("class", "axis-tick-line")
448
+ .attr("x1", xScale(tick))
449
+ .attr("x2", xScale(tick))
450
+ .attr("y1", innerHeight)
451
+ .attr("y2", innerHeight + 4)
452
+ .attr("stroke", rgba(textColor, 0.38))
453
+ .attr("stroke-width", 1);
454
+ chart.append("text")
455
+ .attr("class", "axis-tick x-axis-tick")
456
+ .attr("x", xScale(tick))
457
+ .attr("y", innerHeight + 17)
458
+ .attr("text-anchor", "middle")
459
+ .attr("fill", rgba(textColor, 0.72))
460
+ .style("font-family", fontFamily)
461
+ .style("font-size", `${Math.max(7.8, Math.min(baseLabelSize, 9.2))}px`)
462
+ .style("font-weight", labelWeight)
463
+ .text(formatXTick(tick));
464
+ });
465
+
466
+ chart.append("path")
467
+ .datum(series.values)
468
+ .attr("class", "mark step-line data-line")
469
+ .attr("data-tag", "mark")
470
+ .attr("fill", "none")
471
+ .attr("stroke", color)
472
+ .attr("stroke-width", 2.6)
473
+ .attr("stroke-linecap", "round")
474
+ .attr("stroke-linejoin", "round")
475
+ .attr("d", stepLine);
476
+
477
+ chart.selectAll(".step-point")
478
+ .data(series.values)
479
+ .enter()
480
+ .append("circle")
481
+ .attr("class", "data-point step-point")
482
+ .attr("data-tag", "mark")
483
+ .attr("cx", d => xScale(d.date))
484
+ .attr("cy", d => yScale(d.value))
485
+ .attr("r", 2.8)
486
+ .attr("fill", "#ffffff")
487
+ .attr("stroke", color)
488
+ .attr("stroke-width", 1.4);
489
+
490
+ const lastPoint = series.values[series.values.length - 1];
491
+ if (lastPoint) {
492
+ chart.append("text")
493
+ .attr("class", "value-label latest-value")
494
+ .attr("x", innerWidth)
495
+ .attr("y", Math.max(8, Math.min(innerHeight - 6, yScale(lastPoint.value) - 8)))
496
+ .attr("text-anchor", "end")
497
+ .attr("fill", color)
498
+ .style("font-family", valueFamily)
499
+ .style("font-size", `${Math.max(8.2, Math.min(baseValueSize, 10.5))}px`)
500
+ .style("font-weight", valueWeight)
501
+ .text(formatLocalValue(lastPoint.value));
502
+ }
503
+ });
504
+
505
+ svg.append("text")
506
+ .attr("class", "category-note")
507
+ .attr("x", width / 2)
508
+ .attr("y", height - 8)
509
+ .attr("text-anchor", "middle")
510
+ .attr("fill", mutedText)
511
+ .style("font-family", fontFamily)
512
+ .style("font-size", "9.5px")
513
+ .text(`${readableLabel(xColumn.label || xField)} order: chronological; panels share the same y scale`);
514
+
515
+ return svg.node();
516
+ }