Ray1ee01 commited on
Commit
21ce175
·
verified ·
1 Parent(s): 34be5a5

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type32_area_chart/area_plain_chart_01.js ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Area Chart",
5
+ "chart_name": "area_plain_chart_01",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["temporal"], ["numerical"]],
8
+ "required_fields_range": [[5, 30], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": ["primary"],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 400,
15
+ "min_width": 800,
16
+ "background": "dark",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_title": "yes",
20
+ "has_x_axis": "yes",
21
+ "has_y_axis": "yes",
22
+ "chart_for": "trend"
23
+ }
24
+ REQUIREMENTS_END
25
+ */
26
+
27
+ function makeChart(containerSelector, data) {
28
+ const jsonData = data || {};
29
+ const chartData = jsonData.data?.data || [];
30
+ const dataColumns = chartUtils.schema.columns(jsonData);
31
+ const variables = jsonData.variables || {};
32
+ const typography = jsonData.typography || {};
33
+ const colorResolver = chartUtils.color.resolver(jsonData);
34
+ const titles = jsonData.titles || {};
35
+ const metadata = jsonData.metadata || {};
36
+
37
+ d3.select(containerSelector).html("");
38
+
39
+ const xColumn = dataColumns[0] || {};
40
+ const yColumn = dataColumns[1] || {};
41
+ const xField = xColumn.name;
42
+ const yField = yColumn.name;
43
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
44
+ const titleFont = typography.title?.font_family || fontFamily;
45
+
46
+ const width = Math.max(1536, Number(variables.width) || 1536);
47
+ const height = Math.max(864, Number(variables.height) || Math.round(width * 0.5625));
48
+ const bgTop = "#030A17";
49
+ const bgBottom = "#071426";
50
+ const panelStroke = "#2E3D54";
51
+ const gridColor = "#536178";
52
+ const textPrimary = "#F8FAFF";
53
+ const textMuted = "#C7CDD8";
54
+ const accent = colorResolver.other("primary", { fallback: "#FF7FA5" }).value || "#FF7FA5";
55
+ const accentSoft = "#FF9FBC";
56
+ const accentDark = "#6F2543";
57
+ const idSuffix = String(containerSelector || "area-plain-01").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24);
58
+
59
+ const svg = d3.select(containerSelector)
60
+ .append("svg")
61
+ .attr("width", "100%")
62
+ .attr("height", height)
63
+ .attr("viewBox", `0 0 ${width} ${height}`)
64
+ .attr("style", "max-width: 100%; height: auto;")
65
+ .attr("xmlns", "http://www.w3.org/2000/svg")
66
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
67
+ .attr("class", "area-plain-chart-01-reference-layout");
68
+
69
+ const addEmptyState = message => {
70
+ svg.append("rect").attr("width", width).attr("height", height).attr("fill", bgTop);
71
+ svg.append("text")
72
+ .attr("x", width / 2)
73
+ .attr("y", height / 2)
74
+ .attr("text-anchor", "middle")
75
+ .attr("fill", textPrimary)
76
+ .style("font-family", fontFamily)
77
+ .style("font-size", "16px")
78
+ .text(message);
79
+ return svg.node();
80
+ };
81
+
82
+ if (!xField || !yField) return addEmptyState("Missing area chart fields");
83
+
84
+ const parseDate = value => {
85
+ const raw = String(value ?? "").trim();
86
+ if (/^-?\d{1,6}$/.test(raw)) return new Date(Number(raw), 0, 1);
87
+ const helper = chartUtils.format.parseDate(value);
88
+ if (helper instanceof Date && Number.isFinite(helper.getTime())) return helper;
89
+ const parsed = Date.parse(raw);
90
+ return Number.isFinite(parsed) ? new Date(parsed) : null;
91
+ };
92
+ const readableName = value => String(value ?? "")
93
+ .replace(/_/g, " ")
94
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
95
+ .replace(/\s+/g, " ")
96
+ .trim();
97
+ const wrapWords = (value, maxChars, maxLines = 3) => {
98
+ const words = String(value ?? "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
99
+ const lines = [];
100
+ let current = "";
101
+ words.forEach(word => {
102
+ const next = current ? `${current} ${word}` : word;
103
+ if (next.length <= maxChars || !current) current = next;
104
+ else {
105
+ lines.push(current);
106
+ current = word;
107
+ }
108
+ });
109
+ if (current) lines.push(current);
110
+ if (lines.length <= maxLines) return lines;
111
+ return lines.slice(0, maxLines - 1).concat(lines.slice(maxLines - 1).join(" "));
112
+ };
113
+ const wrapWordsByPx = (value, maxPx, fontPx, maxLines = 3, charFactor = 0.56) => {
114
+ const approxCharPx = fontPx * charFactor;
115
+ const words = String(value ?? "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
116
+ const lines = [];
117
+ let current = "";
118
+ const widthOf = text => text.length * approxCharPx;
119
+ words.forEach(word => {
120
+ const next = current ? `${current} ${word}` : word;
121
+ if (widthOf(next) <= maxPx || !current) current = next;
122
+ else {
123
+ lines.push(current);
124
+ current = word;
125
+ }
126
+ });
127
+ if (current) lines.push(current);
128
+ if (lines.length <= maxLines) return lines;
129
+ return lines.slice(0, maxLines - 1).concat(lines.slice(maxLines - 1).join(" "));
130
+ };
131
+ const ellipsizeByPx = (value, maxPx, fontPx, charFactor = 0.55) => {
132
+ const text = String(value ?? "").replace(/\s+/g, " ").trim();
133
+ const approxCharPx = fontPx * charFactor;
134
+ const maxChars = Math.max(4, Math.floor(maxPx / approxCharPx));
135
+ if (text.length <= maxChars) return text;
136
+ return `${text.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
137
+ };
138
+ const formatYear = date => date instanceof Date ? String(date.getFullYear()) : "";
139
+ const formatValue = value => {
140
+ const numberValue = Number(value);
141
+ if (!Number.isFinite(numberValue)) return String(value ?? "");
142
+ return d3.format(",.2f")(numberValue).replace(/\.00$/, ".00");
143
+ };
144
+ const formatUnit = unit => {
145
+ const clean = String(unit ?? "").trim();
146
+ if (!clean) return "";
147
+ if (clean === "£m") return "million £";
148
+ return clean;
149
+ };
150
+ const conciseMetricName = column => {
151
+ const fromName = readableName(column?.label || column?.name || "");
152
+ const base = (fromName || readableName(column?.description || "")).replace(/^Total\s+/i, "");
153
+ const unit = formatUnit(column?.unit);
154
+ return unit ? `${base} (${unit})` : base;
155
+ };
156
+
157
+ const rows = chartData.map((row, index) => {
158
+ const date = parseDate(row[xField]);
159
+ const value = Number(row[yField]);
160
+ return { raw: row, date, value, index };
161
+ }).filter(row => row.date && Number.isFinite(row.date.getTime()) && Number.isFinite(row.value))
162
+ .sort((a, b) => a.date - b.date || a.index - b.index);
163
+ if (rows.length < 2) return addEmptyState("Area chart needs at least two points");
164
+
165
+ const defs = svg.append("defs");
166
+ const bgId = `area-plain-01-bg-${idSuffix}`;
167
+ const bg = defs.append("linearGradient").attr("id", bgId).attr("x1", "0%").attr("y1", "0%").attr("x2", "100%").attr("y2", "100%");
168
+ bg.append("stop").attr("offset", "0%").attr("stop-color", bgTop);
169
+ bg.append("stop").attr("offset", "65%").attr("stop-color", bgBottom);
170
+ bg.append("stop").attr("offset", "100%").attr("stop-color", "#010612");
171
+
172
+ const fillId = `area-plain-01-fill-${idSuffix}`;
173
+ const fill = defs.append("linearGradient").attr("id", fillId).attr("x1", "0%").attr("y1", "0%").attr("x2", "0%").attr("y2", "100%");
174
+ fill.append("stop").attr("offset", "0%").attr("stop-color", accent).attr("stop-opacity", 0.78);
175
+ fill.append("stop").attr("offset", "55%").attr("stop-color", accentDark).attr("stop-opacity", 0.58);
176
+ fill.append("stop").attr("offset", "100%").attr("stop-color", "#080D18").attr("stop-opacity", 0.12);
177
+
178
+ const glowId = `area-plain-01-glow-${idSuffix}`;
179
+ const glow = defs.append("filter").attr("id", glowId).attr("x", "-20%").attr("y", "-20%").attr("width", "140%").attr("height", "140%");
180
+ glow.append("feGaussianBlur").attr("stdDeviation", 3).attr("result", "blur");
181
+ glow.append("feMerge").selectAll("feMergeNode").data(["blur", "SourceGraphic"]).enter().append("feMergeNode").attr("in", d => d);
182
+
183
+ svg.append("rect").attr("class", "chart-style-background").attr("width", width).attr("height", height).attr("fill", `url(#${bgId})`);
184
+
185
+ const rawTitle = String(titles.main_title || metadata.title || readableName(yField)).trim();
186
+ const titleWords = rawTitle.split(/\s+/).filter(Boolean);
187
+ const kicker = titleWords.slice(0, Math.min(4, titleWords.length)).join(" ").toUpperCase();
188
+ const rest = titleWords.slice(Math.min(4, titleWords.length));
189
+ const emphasis = rest.length > 1 ? rest[rest.length - 1] : "Upward";
190
+ const headline = rest.length > 1 ? rest.slice(0, -1).join(" ") : rawTitle;
191
+ const subtitle = String(titles.sub_title || metadata.description || `${readableName(yField)} over time.`).trim();
192
+
193
+ const leftX = width * 0.045;
194
+ const leftColumnRight = width * 0.305;
195
+ const leftColumnW = leftColumnRight - leftX;
196
+ const titleG = svg.append("g")
197
+ .attr("class", "header-title-art-slot")
198
+ .attr("data-reserved-slot", "header_title_art_slot")
199
+ .attr("transform", `translate(${leftX}, ${height * 0.13})`);
200
+ const kickerFont = Math.max(16, Math.min(width * 0.017, leftColumnW / Math.max(12, kicker.length) * 1.35));
201
+ const kickerLines = wrapWordsByPx(kicker, leftColumnW, kickerFont, 2, 0.62);
202
+ kickerLines.forEach((lineText, i) => {
203
+ titleG.append("text")
204
+ .attr("class", "title-kicker chart-text")
205
+ .attr("x", 0)
206
+ .attr("y", i * kickerFont * 1.15)
207
+ .attr("fill", accentSoft)
208
+ .style("font-family", fontFamily)
209
+ .style("font-size", `${kickerFont}px`)
210
+ .style("font-weight", "900")
211
+ .style("letter-spacing", "2px")
212
+ .text(lineText);
213
+ });
214
+
215
+ const headlineLines = wrapWords(headline, 15, 2);
216
+ const headlineFont = Math.max(44, width * 0.041);
217
+ const headlineStartY = height * 0.08 + Math.max(0, kickerLines.length - 1) * kickerFont * 1.1;
218
+ headlineLines.forEach((lineText, i) => {
219
+ titleG.append("text")
220
+ .attr("class", "title-headline chart-text")
221
+ .attr("x", 0)
222
+ .attr("y", headlineStartY + i * headlineFont * 1.08)
223
+ .attr("fill", textPrimary)
224
+ .style("font-family", titleFont)
225
+ .style("font-size", `${headlineFont}px`)
226
+ .style("font-weight", "900")
227
+ .text(lineText);
228
+ });
229
+ const emphasisY = headlineStartY + headlineLines.length * headlineFont * 1.08;
230
+ titleG.append("text")
231
+ .attr("class", "title-emphasis chart-text")
232
+ .attr("x", 0)
233
+ .attr("y", emphasisY)
234
+ .attr("fill", accentSoft)
235
+ .style("font-family", titleFont)
236
+ .style("font-size", `${headlineFont}px`)
237
+ .style("font-weight", "900")
238
+ .text(`${emphasis} ↗`);
239
+ titleG.append("line")
240
+ .attr("x1", 0)
241
+ .attr("x2", width * 0.085)
242
+ .attr("y1", emphasisY + height * 0.048)
243
+ .attr("y2", emphasisY + height * 0.048)
244
+ .attr("stroke", accentSoft)
245
+ .attr("stroke-width", 3)
246
+ .attr("stroke-linecap", "round");
247
+ wrapWordsByPx(subtitle, leftColumnW * 0.90, Math.max(18, width * 0.018), 4, 0.46).forEach((lineText, i) => {
248
+ titleG.append("text")
249
+ .attr("class", "title-subtitle chart-text")
250
+ .attr("x", 0)
251
+ .attr("y", emphasisY + height * 0.115 + i * Math.max(23, height * 0.035))
252
+ .attr("fill", textMuted)
253
+ .style("font-family", fontFamily)
254
+ .style("font-size", `${Math.max(18, width * 0.018)}px`)
255
+ .style("font-weight", "500")
256
+ .text(lineText);
257
+ });
258
+
259
+ const slotX = leftX + width * 0.04;
260
+ const slotY = height * 0.60;
261
+ const slotW = width * 0.21;
262
+ const slotH = height * 0.26;
263
+ const illustrationSlot = svg.append("g")
264
+ .attr("class", "left-illustration-slot decorative-image-slot")
265
+ .attr("data-reserved-slot", "left_illustration_slot")
266
+ .attr("data-asset-owner", "image2")
267
+ .attr("transform", `translate(${slotX}, ${slotY})`);
268
+ illustrationSlot.append("rect")
269
+ .attr("width", slotW)
270
+ .attr("height", slotH)
271
+ .attr("rx", 10)
272
+ .attr("fill", accent)
273
+ .attr("fill-opacity", 0.04)
274
+ .attr("stroke", accentSoft)
275
+ .attr("stroke-opacity", 0.18);
276
+ illustrationSlot.append("line")
277
+ .attr("x1", slotW * 0.12)
278
+ .attr("x2", slotW * 0.88)
279
+ .attr("y1", slotH * 0.82)
280
+ .attr("y2", slotH * 0.82)
281
+ .attr("stroke", accentSoft)
282
+ .attr("stroke-width", 2)
283
+ .attr("stroke-opacity", 0.35)
284
+ .attr("stroke-linecap", "round");
285
+
286
+ const panel = {
287
+ x: width * 0.335,
288
+ y: height * 0.065,
289
+ w: width * 0.62,
290
+ h: height * 0.86
291
+ };
292
+ svg.append("rect")
293
+ .attr("class", "chart-panel")
294
+ .attr("x", panel.x)
295
+ .attr("y", panel.y)
296
+ .attr("width", panel.w)
297
+ .attr("height", panel.h)
298
+ .attr("rx", 18)
299
+ .attr("fill", "#071121")
300
+ .attr("fill-opacity", 0.58)
301
+ .attr("stroke", panelStroke)
302
+ .attr("stroke-width", 1.2);
303
+
304
+ const legend = svg.append("g").attr("class", "legend-pill").attr("transform", `translate(${panel.x + 28}, ${panel.y + 30})`);
305
+ const legendText = conciseMetricName(yColumn);
306
+ const legendW = Math.min(panel.w * 0.42, Math.max(230, legendText.length * 8.2 + 68));
307
+ const fittedLegendText = ellipsizeByPx(legendText, legendW - 58, 17, 0.45);
308
+ legend.append("rect").attr("width", legendW).attr("height", 40).attr("rx", 15).attr("fill", "#111A2B").attr("stroke", panelStroke);
309
+ legend.append("circle").attr("cx", 24).attr("cy", 20).attr("r", 7).attr("fill", accent);
310
+ legend.append("text").attr("x", 42).attr("y", 26).attr("fill", textPrimary).style("font-family", fontFamily).style("font-size", "17px").text(fittedLegendText);
311
+
312
+ const plot = {
313
+ x: panel.x + panel.w * 0.07,
314
+ y: panel.y + panel.h * 0.19,
315
+ w: panel.w * 0.89,
316
+ h: panel.h * 0.70
317
+ };
318
+ const xInset = Math.min(78, Math.max(34, plot.w * 0.075));
319
+ const xScale = d3.scaleTime().domain(d3.extent(rows, d => d.date)).range([plot.x + xInset, plot.x + plot.w - xInset * 0.55]);
320
+ const maxY = d3.max(rows, d => d.value) || 1;
321
+ const yScale = d3.scaleLinear().domain([0, maxY * 1.03]).nice(6).range([plot.y + plot.h, plot.y]);
322
+ const yTicks = yScale.ticks(6);
323
+ yTicks.forEach(tick => {
324
+ const y = yScale(tick);
325
+ svg.append("line")
326
+ .attr("class", "y-grid-line")
327
+ .attr("x1", plot.x)
328
+ .attr("x2", plot.x + plot.w)
329
+ .attr("y1", y)
330
+ .attr("y2", y)
331
+ .attr("stroke", gridColor)
332
+ .attr("stroke-width", 1)
333
+ .attr("stroke-dasharray", "4 4")
334
+ .attr("opacity", 0.45);
335
+ svg.append("text")
336
+ .attr("class", "y-axis-label chart-text")
337
+ .attr("x", plot.x - 14)
338
+ .attr("y", y + 5)
339
+ .attr("text-anchor", "end")
340
+ .attr("fill", textMuted)
341
+ .style("font-family", fontFamily)
342
+ .style("font-size", "16px")
343
+ .text(d3.format(",")(tick));
344
+ });
345
+ svg.append("line").attr("x1", plot.x).attr("x2", plot.x).attr("y1", plot.y).attr("y2", plot.y + plot.h).attr("stroke", textMuted).attr("opacity", 0.7);
346
+ svg.append("line").attr("x1", plot.x).attr("x2", plot.x + plot.w).attr("y1", plot.y + plot.h).attr("y2", plot.y + plot.h).attr("stroke", textMuted).attr("opacity", 0.7);
347
+
348
+ const area = d3.area()
349
+ .x(d => xScale(d.date))
350
+ .y0(plot.y + plot.h)
351
+ .y1(d => yScale(d.value))
352
+ .curve(d3.curveLinear);
353
+ const line = d3.line()
354
+ .x(d => xScale(d.date))
355
+ .y(d => yScale(d.value))
356
+ .curve(d3.curveLinear);
357
+
358
+ svg.append("path")
359
+ .datum(rows)
360
+ .attr("class", "area-fill data-area")
361
+ .attr("d", area)
362
+ .attr("fill", `url(#${fillId})`);
363
+ svg.append("path")
364
+ .datum(rows)
365
+ .attr("class", "area-line-underlay")
366
+ .attr("d", line)
367
+ .attr("fill", "none")
368
+ .attr("stroke", accentSoft)
369
+ .attr("stroke-width", 8)
370
+ .attr("opacity", 0.28)
371
+ .attr("filter", `url(#${glowId})`);
372
+ svg.append("path")
373
+ .datum(rows)
374
+ .attr("class", "area-line data-line")
375
+ .attr("d", line)
376
+ .attr("fill", "none")
377
+ .attr("stroke", accentSoft)
378
+ .attr("stroke-width", 3.2);
379
+
380
+ const labelStep = Math.max(1, Math.ceil(rows.length / 8));
381
+ const labelRows = rows.filter((row, i) => i % labelStep === 0 || i === rows.length - 1);
382
+ const placedLabelBoxes = [];
383
+ const overlaps = (a, b, pad = 4) => !(a.x + a.w + pad <= b.x || b.x + b.w + pad <= a.x || a.y + a.h + pad <= b.y || b.y + b.h + pad <= a.y);
384
+ labelRows.forEach(row => {
385
+ const x = xScale(row.date);
386
+ const y = yScale(row.value);
387
+ svg.append("circle").attr("class", "point-marker").attr("cx", x).attr("cy", y).attr("r", 7).attr("fill", textPrimary).attr("stroke", accent).attr("stroke-width", 3);
388
+ const labelText = formatValue(row.value);
389
+ const labelW = Math.max(64, labelText.length * 9.2 + 20);
390
+ const labelH = 31;
391
+ const lx = Math.max(plot.x + 4, Math.min(plot.x + plot.w - labelW - 4, x - labelW / 2));
392
+ const baseLy = Math.max(plot.y + 4, y - labelH - 26);
393
+ const candidateYs = [
394
+ baseLy,
395
+ baseLy - 34,
396
+ baseLy + 34,
397
+ baseLy - 68,
398
+ baseLy + 68
399
+ ].map(candidate => Math.max(plot.y + 4, Math.min(plot.y + plot.h - labelH - 8, candidate)));
400
+ let ly = candidateYs[0];
401
+ let bestScore = Number.POSITIVE_INFINITY;
402
+ candidateYs.forEach(candidateY => {
403
+ const box = { x: lx, y: candidateY, w: labelW, h: labelH };
404
+ const overlapPenalty = placedLabelBoxes.reduce((sum, placed) => sum + (overlaps(box, placed, 5) ? 10000 : 0), 0);
405
+ const pointPenalty = Math.abs(candidateY + labelH - y) * 0.4;
406
+ const score = overlapPenalty + pointPenalty + Math.abs(candidateY - baseLy) * 0.2;
407
+ if (score < bestScore) {
408
+ bestScore = score;
409
+ ly = candidateY;
410
+ }
411
+ });
412
+ placedLabelBoxes.push({ x: lx, y: ly, w: labelW, h: labelH });
413
+ const pointerX = Math.max(10, Math.min(labelW - 10, x - lx));
414
+ const label = svg.append("g")
415
+ .attr("class", "point-value-label")
416
+ .attr("data-x", row.raw[xField])
417
+ .attr("data-value", row.value)
418
+ .attr("transform", `translate(${lx}, ${ly})`);
419
+ label.append("rect").attr("width", labelW).attr("height", labelH).attr("rx", 7).attr("fill", accent).attr("opacity", 0.95);
420
+ label.append("path").attr("d", `M ${pointerX - 8} ${labelH - 1} L ${pointerX + 8} ${labelH - 1} L ${pointerX} ${labelH + 13} Z`).attr("fill", accent).attr("opacity", 0.95);
421
+ label.append("text")
422
+ .attr("x", labelW / 2)
423
+ .attr("y", labelH * 0.64)
424
+ .attr("text-anchor", "middle")
425
+ .attr("fill", textPrimary)
426
+ .style("font-family", fontFamily)
427
+ .style("font-size", "15px")
428
+ .style("font-weight", "900")
429
+ .text(labelText);
430
+ svg.append("circle").attr("class", "x-axis-dot").attr("cx", x).attr("cy", plot.y + plot.h).attr("r", 4).attr("fill", accent);
431
+ });
432
+
433
+ const tickYears = rows.filter((row, i) => i % Math.max(1, Math.ceil(rows.length / 7)) === 0 || i === rows.length - 1);
434
+ tickYears.forEach(row => {
435
+ svg.append("text")
436
+ .attr("class", "x-axis-label chart-text")
437
+ .attr("x", xScale(row.date))
438
+ .attr("y", plot.y + plot.h + 42)
439
+ .attr("text-anchor", "middle")
440
+ .attr("fill", textMuted)
441
+ .style("font-family", fontFamily)
442
+ .style("font-size", "18px")
443
+ .text(formatYear(row.date));
444
+ });
445
+
446
+ return svg.node();
447
+ }
modules/chart_engine/template/d3-js/type32_area_chart/area_plain_chart_02.js ADDED
@@ -0,0 +1,482 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Area Chart",
5
+ "chart_name": "area_plain_chart_02",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["temporal"], ["numerical"]],
8
+ "required_fields_range": [[5, 30], [-100, 100]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["positive", "negative"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 900,
15
+ "min_width": 650,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_title": "yes",
20
+ "has_x_axis": "yes",
21
+ "has_y_axis": "yes",
22
+ "chart_for": "comparison"
23
+ }
24
+ REQUIREMENTS_END
25
+ */
26
+
27
+ function makeChart(containerSelector, data) {
28
+ const jsonData = data || {};
29
+ const chartData = jsonData.data?.data || [];
30
+ const dataColumns = chartUtils.schema.columns(jsonData);
31
+ const variables = jsonData.variables || {};
32
+ const typography = jsonData.typography || {};
33
+ const titles = jsonData.titles || {};
34
+ const metadata = jsonData.metadata || {};
35
+ const colorResolver = chartUtils.color.resolver(jsonData);
36
+
37
+ d3.select(containerSelector).html("");
38
+
39
+ const xColumn = dataColumns[0] || {};
40
+ const yColumn = dataColumns[1] || {};
41
+ const xField = xColumn.name;
42
+ const yField = yColumn.name;
43
+ const fontSans = typography.label?.font_family || "Arial, sans-serif";
44
+ const fontSerif = typography.title?.font_family || "Georgia, 'Times New Roman', serif";
45
+ const referenceSerif = "Georgia, 'Times New Roman', serif";
46
+
47
+ const width = Math.max(1080, Number(variables.width) || 1080);
48
+ const height = Math.max(1440, Number(variables.height) || Math.round(width * 1.333));
49
+ const blue = colorResolver.other("positive", { fallback: "#1267D8" }).value || "#1267D8";
50
+ const green = "#63AF48";
51
+ const pink = "#EF4774";
52
+ const ink = "#172033";
53
+ const muted = "#5B6472";
54
+ const grid = "#CDD5DF";
55
+ const idSuffix = String(containerSelector || "area-plain-02").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24);
56
+
57
+ const svg = d3.select(containerSelector)
58
+ .append("svg")
59
+ .attr("width", "100%")
60
+ .attr("height", height)
61
+ .attr("viewBox", `0 0 ${width} ${height}`)
62
+ .attr("style", "max-width: 100%; height: auto;")
63
+ .attr("xmlns", "http://www.w3.org/2000/svg")
64
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
65
+ .attr("class", "area-plain-chart-02-reference-layout");
66
+
67
+ const addEmptyState = message => {
68
+ svg.append("rect").attr("width", width).attr("height", height).attr("fill", "#F8FAFD");
69
+ svg.append("text")
70
+ .attr("x", width / 2)
71
+ .attr("y", height / 2)
72
+ .attr("text-anchor", "middle")
73
+ .attr("fill", ink)
74
+ .style("font-family", fontSans)
75
+ .style("font-size", "18px")
76
+ .text(message);
77
+ return svg.node();
78
+ };
79
+ if (!xField || !yField) return addEmptyState("Missing area chart fields");
80
+
81
+ const parseDate = value => {
82
+ const raw = String(value ?? "").trim();
83
+ if (/^-?\d{1,6}$/.test(raw)) return new Date(Number(raw), 0, 1);
84
+ const helper = chartUtils.format.parseDate(value);
85
+ if (helper instanceof Date && Number.isFinite(helper.getTime())) return helper;
86
+ const parsed = Date.parse(raw);
87
+ return Number.isFinite(parsed) ? new Date(parsed) : null;
88
+ };
89
+ const readableName = value => String(value ?? "")
90
+ .replace(/_/g, " ")
91
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
92
+ .replace(/\s+/g, " ")
93
+ .trim();
94
+ const wrapWordsByPx = (value, maxPx, fontPx, maxLines = 3, charFactor = 0.54) => {
95
+ const words = String(value ?? "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
96
+ const lines = [];
97
+ let current = "";
98
+ const widthOf = text => text.length * fontPx * charFactor;
99
+ words.forEach(word => {
100
+ const next = current ? `${current} ${word}` : word;
101
+ if (widthOf(next) <= maxPx || !current) current = next;
102
+ else {
103
+ lines.push(current);
104
+ current = word;
105
+ }
106
+ });
107
+ if (current) lines.push(current);
108
+ if (lines.length <= maxLines) return lines;
109
+ return lines.slice(0, maxLines - 1).concat(lines.slice(maxLines - 1).join(" "));
110
+ };
111
+ const formatYear = date => date instanceof Date ? String(date.getFullYear()) : "";
112
+ const formatValue = value => {
113
+ const n = Number(value);
114
+ if (!Number.isFinite(n)) return String(value ?? "");
115
+ const suffix = String(yColumn.unit || "").trim() === "%" ? "%" : "";
116
+ return `${d3.format(",.2f")(n)}${suffix}`;
117
+ };
118
+ const metricName = () => {
119
+ const base = readableName(yColumn.label || yColumn.name || yColumn.description || "Value");
120
+ const unit = String(yColumn.unit || "").trim();
121
+ return unit && !base.includes(unit) ? `${base} (${unit})` : base;
122
+ };
123
+
124
+ const rows = chartData.map((row, index) => {
125
+ const date = parseDate(row[xField]);
126
+ const value = Number(row[yField]);
127
+ return { raw: row, date, value, index };
128
+ }).filter(row => row.date && Number.isFinite(row.date.getTime()) && Number.isFinite(row.value))
129
+ .sort((a, b) => a.date - b.date || a.index - b.index);
130
+ if (rows.length < 2) return addEmptyState("Area chart needs at least two points");
131
+
132
+ const defs = svg.append("defs");
133
+ const bgId = `area-plain-02-bg-${idSuffix}`;
134
+ const bg = defs.append("linearGradient").attr("id", bgId).attr("x1", "0%").attr("y1", "0%").attr("x2", "100%").attr("y2", "100%");
135
+ bg.append("stop").attr("offset", "0%").attr("stop-color", "#FFFFFF");
136
+ bg.append("stop").attr("offset", "100%").attr("stop-color", "#F4F7FA");
137
+ const fillId = `area-plain-02-fill-${idSuffix}`;
138
+ const fill = defs.append("linearGradient").attr("id", fillId).attr("x1", "0%").attr("y1", "0%").attr("x2", "0%").attr("y2", "100%");
139
+ fill.append("stop").attr("offset", "0%").attr("stop-color", blue).attr("stop-opacity", 0.82);
140
+ fill.append("stop").attr("offset", "62%").attr("stop-color", "#8EC5FF").attr("stop-opacity", 0.58);
141
+ fill.append("stop").attr("offset", "100%").attr("stop-color", "#EAF4FF").attr("stop-opacity", 0.22);
142
+ const shadowId = `area-plain-02-shadow-${idSuffix}`;
143
+ const shadow = defs.append("filter").attr("id", shadowId).attr("x", "-20%").attr("y", "-20%").attr("width", "140%").attr("height", "150%");
144
+ shadow.append("feDropShadow").attr("dx", 0).attr("dy", 6).attr("stdDeviation", 8).attr("flood-color", "#718096").attr("flood-opacity", 0.18);
145
+
146
+ svg.append("rect").attr("width", width).attr("height", height).attr("fill", `url(#${bgId})`);
147
+
148
+ const illustration = svg.append("g")
149
+ .attr("class", "header-illustration-slot decorative-image-slot")
150
+ .attr("data-reserved-slot", "header_illustration_slot")
151
+ .attr("data-asset-owner", "image2")
152
+ .attr("transform", `translate(${width * 0.07}, ${height * 0.035})`);
153
+ const slotSize = width * 0.17;
154
+ illustration.append("circle")
155
+ .attr("cx", slotSize / 2)
156
+ .attr("cy", slotSize / 2)
157
+ .attr("r", slotSize / 2)
158
+ .attr("fill", "#EAF4FF")
159
+ .attr("stroke", "#FFFFFF")
160
+ .attr("stroke-width", 10)
161
+ .attr("filter", `url(#${shadowId})`);
162
+ illustration.append("circle")
163
+ .attr("cx", slotSize / 2)
164
+ .attr("cy", slotSize / 2)
165
+ .attr("r", slotSize * 0.31)
166
+ .attr("fill", blue)
167
+ .attr("fill-opacity", 0.10)
168
+ .attr("stroke", blue)
169
+ .attr("stroke-opacity", 0.22)
170
+ .attr("stroke-width", 3);
171
+
172
+ const rawTitle = readableName(titles.main_title || metadata.title || yField);
173
+ const subtitle = String(titles.sub_title || metadata.description || `${metricName()} over time.`).trim();
174
+ const titleG = svg.append("g")
175
+ .attr("class", "header-title-art-slot")
176
+ .attr("data-reserved-slot", "header_title_art_slot")
177
+ .attr("transform", `translate(${width * 0.27}, ${height * 0.075})`);
178
+ const titleFont = Math.max(42, width * 0.055);
179
+ const titleLines = wrapWordsByPx(rawTitle, width * 0.65, titleFont, 3, 0.45);
180
+ const drawRichLine = (line, y) => {
181
+ const words = line.split(/\s+/).filter(Boolean);
182
+ const text = titleG.append("text")
183
+ .attr("x", 0)
184
+ .attr("y", y)
185
+ .style("font-family", referenceSerif)
186
+ .style("font-size", `${titleFont}px`)
187
+ .style("font-weight", "800");
188
+ words.forEach((word, i) => {
189
+ const normalized = word.toLowerCase().replace(/[^a-z]/g, "");
190
+ const color = /surge|rise|recover|growth|increase|upward/.test(normalized)
191
+ ? green
192
+ : /decline|drop|loss|fall|decrease/.test(normalized)
193
+ ? pink
194
+ : ink;
195
+ text.append("tspan")
196
+ .attr("fill", color)
197
+ .text(`${word}${i < words.length - 1 ? " " : ""}`);
198
+ });
199
+ };
200
+ titleLines.forEach((line, i) => drawRichLine(line, i * titleFont * 1.02));
201
+ wrapWordsByPx(subtitle, width * 0.58, Math.max(21, width * 0.025), 2, 0.47).forEach((line, i) => {
202
+ titleG.append("text")
203
+ .attr("x", 0)
204
+ .attr("y", titleLines.length * titleFont * 1.02 + 34 + i * 34)
205
+ .attr("fill", muted)
206
+ .style("font-family", referenceSerif)
207
+ .style("font-size", `${Math.max(21, width * 0.025)}px`)
208
+ .style("font-style", "italic")
209
+ .text(line);
210
+ });
211
+
212
+ const plot = {
213
+ x: width * 0.075,
214
+ y: height * 0.265,
215
+ w: width * 0.87,
216
+ h: height * 0.56
217
+ };
218
+ const xInset = Math.max(18, plot.w * 0.02);
219
+ const xScale = d3.scaleTime().domain(d3.extent(rows, d => d.date)).range([plot.x + xInset, plot.x + plot.w - xInset]);
220
+ const minValue = d3.min(rows, d => d.value);
221
+ const maxValue = d3.max(rows, d => d.value);
222
+ const yMin = minValue < 0 ? minValue * 1.12 : 0;
223
+ const yMax = maxValue > 0 ? maxValue * 1.08 : 0;
224
+ const yScale = d3.scaleLinear().domain([yMin, yMax]).nice(6).range([plot.y + plot.h, plot.y]);
225
+ const zeroY = yScale(0);
226
+
227
+ const yTicks = yScale.ticks(10);
228
+ svg.append("text")
229
+ .attr("x", plot.x - 40)
230
+ .attr("y", plot.y - 36)
231
+ .attr("fill", "#4A5568")
232
+ .style("font-family", fontSans)
233
+ .style("font-size", "17px")
234
+ .style("font-weight", "800")
235
+ .style("letter-spacing", "0.5px")
236
+ .text(metricName().toUpperCase());
237
+
238
+ const autoTicks = rows.length <= 8 ? rows.map(d => d.date) : xScale.ticks(Math.min(6, rows.length));
239
+ const xTicks = [rows[0].date, ...autoTicks, rows[rows.length - 1].date]
240
+ .filter((tick, index, ticks) => ticks.findIndex(other => +other === +tick) === index)
241
+ .sort((a, b) => a - b);
242
+ if (xTicks.length >= 2) {
243
+ const last = xTicks[xTicks.length - 1];
244
+ const previous = xTicks[xTicks.length - 2];
245
+ if (xScale(last) - xScale(previous) < 120 && +previous !== +rows[0].date) {
246
+ xTicks.splice(xTicks.length - 2, 1);
247
+ }
248
+ }
249
+ xTicks.forEach((tick, i) => {
250
+ if (i % 2 !== 0 || i >= xTicks.length - 1) return;
251
+ const x1 = Math.max(plot.x, xScale(tick));
252
+ const x2 = Math.min(plot.x + plot.w, xScale(xTicks[i + 1]));
253
+ svg.append("rect")
254
+ .attr("x", x1)
255
+ .attr("y", plot.y)
256
+ .attr("width", Math.max(0, x2 - x1))
257
+ .attr("height", plot.h)
258
+ .attr("fill", "#EDF2F7")
259
+ .attr("opacity", 0.55);
260
+ });
261
+
262
+ yTicks.forEach(tick => {
263
+ const y = yScale(tick);
264
+ svg.append("line")
265
+ .attr("class", "y-grid-line")
266
+ .attr("x1", plot.x)
267
+ .attr("x2", plot.x + plot.w)
268
+ .attr("y1", y)
269
+ .attr("y2", y)
270
+ .attr("stroke", grid)
271
+ .attr("stroke-width", 1.2)
272
+ .attr("stroke-dasharray", "5 4");
273
+ svg.append("text")
274
+ .attr("class", "y-axis-label chart-text")
275
+ .attr("x", plot.x - 16)
276
+ .attr("y", y + 7)
277
+ .attr("text-anchor", "end")
278
+ .attr("fill", "#2D3748")
279
+ .style("font-family", fontSans)
280
+ .style("font-size", "25px")
281
+ .text(d3.format(",~g")(tick));
282
+ });
283
+ svg.append("line")
284
+ .attr("x1", plot.x)
285
+ .attr("x2", plot.x + plot.w + 12)
286
+ .attr("y1", zeroY)
287
+ .attr("y2", zeroY)
288
+ .attr("stroke", "#1F2937")
289
+ .attr("stroke-width", 3);
290
+
291
+ const area = d3.area()
292
+ .x(d => xScale(d.date))
293
+ .y0(zeroY)
294
+ .y1(d => yScale(d.value))
295
+ .curve(d3.curveLinear);
296
+ const line = d3.line()
297
+ .x(d => xScale(d.date))
298
+ .y(d => yScale(d.value))
299
+ .curve(d3.curveLinear);
300
+ svg.append("path")
301
+ .datum(rows)
302
+ .attr("class", "area-fill data-area")
303
+ .attr("d", area)
304
+ .attr("fill", `url(#${fillId})`);
305
+ svg.append("path")
306
+ .datum(rows)
307
+ .attr("class", "area-line-shadow")
308
+ .attr("d", line)
309
+ .attr("fill", "none")
310
+ .attr("stroke", blue)
311
+ .attr("stroke-width", 10)
312
+ .attr("opacity", 0.18);
313
+ svg.append("path")
314
+ .datum(rows)
315
+ .attr("class", "area-line data-line")
316
+ .attr("d", line)
317
+ .attr("fill", "none")
318
+ .attr("stroke", blue)
319
+ .attr("stroke-width", 5.5)
320
+ .attr("stroke-linejoin", "round");
321
+
322
+ xTicks.forEach(tick => {
323
+ svg.append("text")
324
+ .attr("class", "x-axis-label chart-text")
325
+ .attr("x", xScale(tick))
326
+ .attr("y", plot.y + plot.h + 38)
327
+ .attr("text-anchor", "middle")
328
+ .attr("fill", "#1F2937")
329
+ .style("font-family", fontSans)
330
+ .style("font-size", "23px")
331
+ .style("font-weight", "700")
332
+ .text(formatYear(tick));
333
+ });
334
+
335
+ const marker = (point, stroke = blue, r = 10) => {
336
+ svg.append("circle")
337
+ .attr("class", "point-marker")
338
+ .attr("cx", xScale(point.date))
339
+ .attr("cy", yScale(point.value))
340
+ .attr("r", r)
341
+ .attr("fill", "#FFFFFF")
342
+ .attr("stroke", stroke)
343
+ .attr("stroke-width", 5);
344
+ };
345
+ const addSmallCallout = (point, opts) => {
346
+ const x = xScale(point.date);
347
+ const y = yScale(point.value);
348
+ const w = opts.w || 145;
349
+ const h = opts.h || 86;
350
+ const cx = Math.max(plot.x + 12, Math.min(plot.x + plot.w - w - 8, x + (opts.dx || 16)));
351
+ const cy = Math.max(plot.y + 12, Math.min(plot.y + plot.h - h - 8, y + (opts.dy || -112)));
352
+ const g = svg.append("g")
353
+ .attr("class", "point-callout endpoint-callout")
354
+ .attr("data-x", point.raw[xField])
355
+ .attr("data-value", point.value)
356
+ .attr("transform", `translate(${cx}, ${cy})`);
357
+ g.append("rect")
358
+ .attr("width", w)
359
+ .attr("height", h)
360
+ .attr("rx", 11)
361
+ .attr("fill", "#FFFFFF")
362
+ .attr("stroke", "#D6DEE8")
363
+ .attr("filter", `url(#${shadowId})`);
364
+ const pointerX = Math.max(16, Math.min(w - 16, x - cx));
365
+ const pointerY = y > cy + h ? h : 0;
366
+ if (pointerY === h) {
367
+ g.append("path").attr("d", `M ${pointerX - 12} ${h - 1} L ${pointerX + 12} ${h - 1} L ${pointerX} ${h + 18} Z`).attr("fill", "#FFFFFF").attr("stroke", "#D6DEE8");
368
+ }
369
+ g.append("text")
370
+ .attr("x", w / 2)
371
+ .attr("y", 38)
372
+ .attr("text-anchor", "middle")
373
+ .attr("fill", opts.color || blue)
374
+ .style("font-family", fontSans)
375
+ .style("font-size", "28px")
376
+ .style("font-weight", "900")
377
+ .text(formatValue(point.value));
378
+ g.append("text")
379
+ .attr("x", w / 2)
380
+ .attr("y", 66)
381
+ .attr("text-anchor", "middle")
382
+ .attr("fill", muted)
383
+ .style("font-family", fontSerif)
384
+ .style("font-size", "18px")
385
+ .text(`in ${formatYear(point.date)}`);
386
+ };
387
+ const addLeaderCard = (point, opts) => {
388
+ const x = xScale(point.date);
389
+ const y = yScale(point.value);
390
+ const w = opts.w || 178;
391
+ const h = opts.h || 116;
392
+ const cx = Math.max(plot.x + 12, Math.min(plot.x + plot.w - w - 12, x + (opts.dx || -w / 2)));
393
+ const rawCy = y + (opts.dy || -210);
394
+ const separatedCy = Math.min(rawCy, y - h - 42);
395
+ const topLimit = opts.allowAbovePlot ? plot.y - 70 : plot.y + 10;
396
+ const cy = Math.max(topLimit, Math.min(plot.y + plot.h - h - 18, separatedCy));
397
+ const leaderX = Math.max(cx + 26, Math.min(cx + w - 26, x));
398
+ svg.append("line")
399
+ .attr("class", "callout-leader")
400
+ .attr("x1", leaderX)
401
+ .attr("x2", x)
402
+ .attr("y1", cy + h + 5)
403
+ .attr("y2", y - 12)
404
+ .attr("stroke", opts.color)
405
+ .attr("stroke-width", 2.4)
406
+ .attr("stroke-dasharray", "4 5");
407
+ const g = svg.append("g")
408
+ .attr("class", "leader-callout")
409
+ .attr("data-x", point.raw[xField])
410
+ .attr("data-value", point.value)
411
+ .attr("transform", `translate(${cx}, ${cy})`);
412
+ g.append("rect")
413
+ .attr("width", w)
414
+ .attr("height", h)
415
+ .attr("rx", 12)
416
+ .attr("fill", "#FFFFFF")
417
+ .attr("stroke", opts.color)
418
+ .attr("stroke-opacity", 0.55)
419
+ .attr("filter", `url(#${shadowId})`);
420
+ g.append("circle")
421
+ .attr("cx", w / 2)
422
+ .attr("cy", -2)
423
+ .attr("r", 34)
424
+ .attr("fill", opts.color);
425
+ g.append("text")
426
+ .attr("x", w / 2)
427
+ .attr("y", 4)
428
+ .attr("text-anchor", "middle")
429
+ .attr("fill", "#FFFFFF")
430
+ .style("font-family", fontSans)
431
+ .style("font-size", "34px")
432
+ .style("font-weight", "900")
433
+ .text(opts.symbol);
434
+ g.append("text")
435
+ .attr("x", w / 2)
436
+ .attr("y", 52)
437
+ .attr("text-anchor", "middle")
438
+ .attr("fill", ink)
439
+ .style("font-family", fontSerif)
440
+ .style("font-size", "22px")
441
+ .text(opts.label);
442
+ g.append("text")
443
+ .attr("x", w / 2)
444
+ .attr("y", 85)
445
+ .attr("text-anchor", "middle")
446
+ .attr("fill", opts.color)
447
+ .style("font-family", fontSans)
448
+ .style("font-size", "31px")
449
+ .style("font-weight", "900")
450
+ .text(formatValue(point.value));
451
+ g.append("text")
452
+ .attr("x", w / 2)
453
+ .attr("y", 108)
454
+ .attr("text-anchor", "middle")
455
+ .attr("fill", muted)
456
+ .style("font-family", fontSerif)
457
+ .style("font-size", "17px")
458
+ .text(`in ${formatYear(point.date)}`);
459
+ };
460
+
461
+ const first = rows[0];
462
+ const last = rows[rows.length - 1];
463
+ const min = rows.reduce((best, row) => row.value < best.value ? row : best, rows[0]);
464
+ const max = rows.reduce((best, row) => row.value > best.value ? row : best, rows[0]);
465
+ const keyPoints = [];
466
+ [first, min, max, last].forEach(point => {
467
+ if (!keyPoints.some(existing => existing.index === point.index)) keyPoints.push(point);
468
+ });
469
+ keyPoints.forEach(point => marker(point, point.index === min.index ? green : blue, point.index === max.index ? 11 : 10));
470
+ addSmallCallout(first, { color: blue, dx: 12, dy: -118, w: 145 });
471
+ if (min.index !== first.index && min.index !== last.index) {
472
+ addLeaderCard(min, { color: green, label: "Low of", symbol: "v", dx: -70, dy: -210, w: 150, h: 116 });
473
+ }
474
+ if (max.index !== first.index && max.index !== last.index) {
475
+ addLeaderCard(max, { color: blue, label: "High of", symbol: "^", dx: -120, dy: -240, w: 168, h: 118, allowAbovePlot: true });
476
+ }
477
+ if (last.index !== first.index) {
478
+ addSmallCallout(last, { color: blue, dx: -120, dy: 36, w: 128, h: 76 });
479
+ }
480
+
481
+ return svg.node();
482
+ }
modules/chart_engine/template/d3-js/type32_area_chart/area_plain_chart_03.js ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Area Chart",
5
+ "chart_name": "area_plain_chart_03",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["temporal"], ["numerical"]],
8
+ "required_fields_range": [[5, 30], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 900,
15
+ "min_width": 650,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_title": "yes",
20
+ "has_x_axis": "yes",
21
+ "has_y_axis": "yes",
22
+ "chart_for": "trend"
23
+ }
24
+ REQUIREMENTS_END
25
+ */
26
+
27
+ function makeChart(containerSelector, data) {
28
+ const jsonData = data || {};
29
+ const chartData = jsonData.data?.data || [];
30
+ const dataColumns = chartUtils.schema.columns(jsonData);
31
+ const variables = jsonData.variables || {};
32
+ const typography = jsonData.typography || {};
33
+ const titles = jsonData.titles || {};
34
+ const metadata = jsonData.metadata || {};
35
+ const colorResolver = chartUtils.color.resolver(jsonData);
36
+
37
+ d3.select(containerSelector).html("");
38
+
39
+ const xColumn = dataColumns[0] || {};
40
+ const yColumn = dataColumns[1] || {};
41
+ const xField = xColumn.name;
42
+ const yField = yColumn.name;
43
+ const fontSans = typography.label?.font_family || "Arial, sans-serif";
44
+ const referenceSans = "Inter, Arial, sans-serif";
45
+ const blue = colorResolver.other("primary", { fallback: "#1E6FE5" }).value || "#1E6FE5";
46
+ const lightBlue = "#9AD5FF";
47
+ const ink = "#071A3D";
48
+ const muted = "#2F3F5C";
49
+ const grid = "#C9DDF2";
50
+ const idSuffix = String(containerSelector || "area-plain-03").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24);
51
+
52
+ const width = Math.max(1080, Number(variables.width) || 1080);
53
+ const height = Math.max(1536, Number(variables.height) || Math.round(width * 1.42));
54
+ const svg = d3.select(containerSelector)
55
+ .append("svg")
56
+ .attr("width", "100%")
57
+ .attr("height", height)
58
+ .attr("viewBox", `0 0 ${width} ${height}`)
59
+ .attr("style", "max-width: 100%; height: auto;")
60
+ .attr("xmlns", "http://www.w3.org/2000/svg")
61
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
62
+ .attr("class", "area-plain-chart-03-reference-layout");
63
+
64
+ const addEmptyState = message => {
65
+ svg.append("rect").attr("width", width).attr("height", height).attr("fill", "#F4FAFF");
66
+ svg.append("text")
67
+ .attr("x", width / 2)
68
+ .attr("y", height / 2)
69
+ .attr("text-anchor", "middle")
70
+ .attr("fill", ink)
71
+ .style("font-family", fontSans)
72
+ .style("font-size", "20px")
73
+ .text(message);
74
+ return svg.node();
75
+ };
76
+ if (!xField || !yField) return addEmptyState("Missing area chart fields");
77
+
78
+ const parseDate = value => {
79
+ const raw = String(value ?? "").trim();
80
+ if (/^-?\d{1,6}$/.test(raw)) return new Date(Number(raw), 0, 1);
81
+ const helper = chartUtils.format.parseDate(value);
82
+ if (helper instanceof Date && Number.isFinite(helper.getTime())) return helper;
83
+ const parsed = Date.parse(raw);
84
+ return Number.isFinite(parsed) ? new Date(parsed) : null;
85
+ };
86
+ const readableName = value => String(value ?? "")
87
+ .replace(/_/g, " ")
88
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
89
+ .replace(/\s+/g, " ")
90
+ .trim();
91
+ const wrapWordsByPx = (value, maxPx, fontPx, maxLines = 2, charFactor = 0.52) => {
92
+ const words = String(value ?? "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
93
+ const lines = [];
94
+ let current = "";
95
+ const widthOf = text => text.length * fontPx * charFactor;
96
+ words.forEach(word => {
97
+ const next = current ? `${current} ${word}` : word;
98
+ if (widthOf(next) <= maxPx || !current) current = next;
99
+ else {
100
+ lines.push(current);
101
+ current = word;
102
+ }
103
+ });
104
+ if (current) lines.push(current);
105
+ if (lines.length <= maxLines) return lines;
106
+ return lines.slice(0, maxLines - 1).concat(lines.slice(maxLines - 1).join(" "));
107
+ };
108
+ const formatYear = date => date instanceof Date ? String(date.getFullYear()) : "";
109
+ const metricName = () => {
110
+ const base = readableName(yColumn.label || yColumn.name || yColumn.description || "Value");
111
+ const unit = String(yColumn.unit || "").trim();
112
+ if (/\bbillion/i.test(base)) return base.replace(/\s*\(\$?b\)\s*/i, "").replace(/\s*\(\$?\)\s*/i, "");
113
+ if (unit === "$" && !/\bbillion/i.test(base)) return `${base} (in billions)`;
114
+ return unit && !base.includes(unit) && unit !== "$" ? `${base} (${unit})` : base;
115
+ };
116
+ const hasCurrency = String(yColumn.unit || "").trim() === "$" || /\$|sales|revenue|cost|price/i.test(metricName());
117
+ const formatValue = value => {
118
+ const n = Number(value);
119
+ if (!Number.isFinite(n)) return String(value ?? "");
120
+ if (hasCurrency) return `$${d3.format(",.2f")(n)}B`;
121
+ const suffix = String(yColumn.unit || "").trim() === "%" ? "%" : "";
122
+ return `${d3.format(",.2f")(n)}${suffix}`;
123
+ };
124
+
125
+ const rows = chartData.map((row, index) => {
126
+ const date = parseDate(row[xField]);
127
+ const value = Number(row[yField]);
128
+ return { raw: row, date, value, index };
129
+ }).filter(row => row.date && Number.isFinite(row.date.getTime()) && Number.isFinite(row.value))
130
+ .sort((a, b) => a.date - b.date || a.index - b.index);
131
+ if (rows.length < 2) return addEmptyState("Area chart needs at least two points");
132
+
133
+ const defs = svg.append("defs");
134
+ const bgId = `area-plain-03-bg-${idSuffix}`;
135
+ const bg = defs.append("linearGradient").attr("id", bgId).attr("x1", "0%").attr("y1", "0%").attr("x2", "100%").attr("y2", "100%");
136
+ bg.append("stop").attr("offset", "0%").attr("stop-color", "#FFFFFF");
137
+ bg.append("stop").attr("offset", "100%").attr("stop-color", "#EAF7FF");
138
+ const fillId = `area-plain-03-fill-${idSuffix}`;
139
+ const fill = defs.append("linearGradient").attr("id", fillId).attr("x1", "0%").attr("y1", "0%").attr("x2", "0%").attr("y2", "100%");
140
+ fill.append("stop").attr("offset", "0%").attr("stop-color", blue).attr("stop-opacity", 0.92);
141
+ fill.append("stop").attr("offset", "58%").attr("stop-color", "#75BEFF").attr("stop-opacity", 0.62);
142
+ fill.append("stop").attr("offset", "100%").attr("stop-color", "#DDF3FF").attr("stop-opacity", 0.46);
143
+ const shadowId = `area-plain-03-shadow-${idSuffix}`;
144
+ const shadow = defs.append("filter").attr("id", shadowId).attr("x", "-20%").attr("y", "-20%").attr("width", "140%").attr("height", "150%");
145
+ shadow.append("feDropShadow").attr("dx", 0).attr("dy", 10).attr("stdDeviation", 15).attr("flood-color", "#6D8FB3").attr("flood-opacity", 0.18);
146
+ const softShadowId = `area-plain-03-soft-shadow-${idSuffix}`;
147
+ const softShadow = defs.append("filter").attr("id", softShadowId).attr("x", "-18%").attr("y", "-18%").attr("width", "136%").attr("height", "150%");
148
+ softShadow.append("feDropShadow").attr("dx", 0).attr("dy", 6).attr("stdDeviation", 9).attr("flood-color", "#7D9ABB").attr("flood-opacity", 0.16);
149
+
150
+ svg.append("rect").attr("width", width).attr("height", height).attr("fill", `url(#${bgId})`);
151
+
152
+ const rawTitle = readableName(titles.main_title || metadata.title || yField);
153
+ const titleWords = rawTitle.split(/\s+/).filter(Boolean);
154
+ const headlineCount = Math.min(3, Math.max(2, Math.ceil(titleWords.length * 0.45)));
155
+ const eyebrowText = titleWords.length > headlineCount ? titleWords.slice(0, -headlineCount).join(" ") : "";
156
+ const headlineText = titleWords.length > headlineCount ? titleWords.slice(-headlineCount).join(" ") : rawTitle;
157
+ const subtitle = String(titles.sub_title || metadata.description || `${metricName()} over time.`).trim();
158
+
159
+ const titleSlot = svg.append("g")
160
+ .attr("class", "header-title-art-slot")
161
+ .attr("data-reserved-slot", "header_title_art_slot")
162
+ .attr("transform", `translate(${width / 2}, ${height * 0.085})`);
163
+ if (eyebrowText) {
164
+ titleSlot.append("text")
165
+ .attr("x", 0)
166
+ .attr("y", 0)
167
+ .attr("text-anchor", "middle")
168
+ .attr("fill", ink)
169
+ .style("font-family", referenceSans)
170
+ .style("font-size", `${Math.max(34, width * 0.047)}px`)
171
+ .style("font-weight", "800")
172
+ .text(eyebrowText);
173
+ }
174
+ const headlineFont = Math.max(50, width * 0.070);
175
+ const headlineLines = wrapWordsByPx(headlineText, width * 0.84, headlineFont, 2, 0.50);
176
+ const headlineStartY = eyebrowText ? 92 : 28;
177
+ headlineLines.forEach((line, i) => {
178
+ titleSlot.append("text")
179
+ .attr("x", 0)
180
+ .attr("y", headlineStartY + i * headlineFont * 0.98)
181
+ .attr("text-anchor", "middle")
182
+ .attr("fill", blue)
183
+ .style("font-family", referenceSans)
184
+ .style("font-size", `${headlineFont}px`)
185
+ .style("font-weight", "900")
186
+ .text(line);
187
+ });
188
+ const underlineY = headlineStartY + (headlineLines.length - 1) * headlineFont * 0.98 + headlineFont * 0.62 + 4;
189
+ titleSlot.append("line")
190
+ .attr("x1", -52)
191
+ .attr("x2", 52)
192
+ .attr("y1", underlineY)
193
+ .attr("y2", underlineY)
194
+ .attr("stroke", blue)
195
+ .attr("stroke-width", 4)
196
+ .attr("stroke-linecap", "round");
197
+ wrapWordsByPx(subtitle, width * 0.60, 25, 2, 0.48).forEach((line, i) => {
198
+ titleSlot.append("text")
199
+ .attr("x", 0)
200
+ .attr("y", underlineY + 52 + i * 32)
201
+ .attr("text-anchor", "middle")
202
+ .attr("fill", muted)
203
+ .style("font-family", referenceSans)
204
+ .style("font-size", "25px")
205
+ .text(line);
206
+ });
207
+
208
+ const first = rows[0];
209
+ const last = rows[rows.length - 1];
210
+ const min = rows.reduce((best, row) => row.value < best.value ? row : best, rows[0]);
211
+ const max = rows.reduce((best, row) => row.value > best.value ? row : best, rows[0]);
212
+ const growth = first.value !== 0 ? ((last.value - first.value) / Math.abs(first.value)) * 100 : null;
213
+
214
+ const titleOverflowShift = Math.max(0, headlineLines.length - 1) * 58;
215
+ const metricBand = { x: width * 0.06, y: height * 0.258 + titleOverflowShift, w: width * 0.88, h: height * 0.095 };
216
+ const metricG = svg.append("g").attr("class", "summary-metric-card-row");
217
+ metricG.append("rect")
218
+ .attr("x", metricBand.x)
219
+ .attr("y", metricBand.y)
220
+ .attr("width", metricBand.w)
221
+ .attr("height", metricBand.h)
222
+ .attr("rx", 16)
223
+ .attr("fill", "#FFFFFF")
224
+ .attr("stroke", "#D7E5F4")
225
+ .attr("filter", `url(#${softShadowId})`);
226
+ const metricItems = [
227
+ {
228
+ slot: "growth_metric_icon_slot",
229
+ value: growth == null ? "n/a" : `${growth >= 0 ? "+" : ""}${d3.format(".0f")(growth >= 0 ? Math.floor(growth) : Math.ceil(growth))}%`,
230
+ label: "Total Growth",
231
+ detail: `${formatYear(first.date)}-${formatYear(last.date)}`
232
+ },
233
+ {
234
+ slot: "latest_value_metric_icon_slot",
235
+ value: formatValue(last.value),
236
+ label: `Sales in ${formatYear(last.date)}`,
237
+ detail: ""
238
+ },
239
+ {
240
+ slot: "start_value_metric_icon_slot",
241
+ value: formatValue(first.value),
242
+ label: `Sales in ${formatYear(first.date)}`,
243
+ detail: ""
244
+ }
245
+ ];
246
+ metricItems.forEach((item, i) => {
247
+ const cellX = metricBand.x + metricBand.w * i / 3;
248
+ const cellW = metricBand.w / 3;
249
+ if (i > 0) {
250
+ metricG.append("line")
251
+ .attr("x1", cellX)
252
+ .attr("x2", cellX)
253
+ .attr("y1", metricBand.y + 34)
254
+ .attr("y2", metricBand.y + metricBand.h - 34)
255
+ .attr("stroke", "#C5D7EA")
256
+ .attr("stroke-width", 1.5);
257
+ }
258
+ const icon = metricG.append("g")
259
+ .attr("class", "metric-icon-slot decorative-icon-slot")
260
+ .attr("data-reserved-slot", item.slot)
261
+ .attr("data-asset-owner", "image2")
262
+ .attr("transform", `translate(${cellX + 74}, ${metricBand.y + metricBand.h / 2})`);
263
+ icon.append("circle")
264
+ .attr("r", 44)
265
+ .attr("fill", "#E6F4FF");
266
+ icon.append("circle")
267
+ .attr("r", 25)
268
+ .attr("fill", "none")
269
+ .attr("stroke", blue)
270
+ .attr("stroke-opacity", 0.28)
271
+ .attr("stroke-width", 4);
272
+ metricG.append("text")
273
+ .attr("x", cellX + 150)
274
+ .attr("y", metricBand.y + 56)
275
+ .attr("fill", "#0B3EB5")
276
+ .style("font-family", referenceSans)
277
+ .style("font-size", "31px")
278
+ .style("font-weight", "900")
279
+ .text(item.value);
280
+ metricG.append("text")
281
+ .attr("x", cellX + 150)
282
+ .attr("y", metricBand.y + 89)
283
+ .attr("fill", ink)
284
+ .style("font-family", referenceSans)
285
+ .style("font-size", "19px")
286
+ .text(item.label);
287
+ if (item.detail) {
288
+ metricG.append("text")
289
+ .attr("x", cellX + 150)
290
+ .attr("y", metricBand.y + 116)
291
+ .attr("fill", ink)
292
+ .style("font-family", referenceSans)
293
+ .style("font-size", "19px")
294
+ .text(item.detail);
295
+ }
296
+ });
297
+
298
+ const panel = { x: width * 0.06, y: height * 0.368 + titleOverflowShift, w: width * 0.88, h: height * 0.575 };
299
+ svg.append("rect")
300
+ .attr("class", "chart-panel")
301
+ .attr("x", panel.x)
302
+ .attr("y", panel.y)
303
+ .attr("width", panel.w)
304
+ .attr("height", panel.h)
305
+ .attr("rx", 20)
306
+ .attr("fill", "#FFFFFF")
307
+ .attr("stroke", "#D7E5F4")
308
+ .attr("filter", `url(#${shadowId})`);
309
+
310
+ const plot = {
311
+ x: panel.x + panel.w * 0.08,
312
+ y: panel.y + panel.h * 0.115,
313
+ w: panel.w * 0.84,
314
+ h: panel.h * 0.77
315
+ };
316
+ svg.append("text")
317
+ .attr("x", panel.x + 30)
318
+ .attr("y", panel.y + 52)
319
+ .attr("fill", ink)
320
+ .style("font-family", referenceSans)
321
+ .style("font-size", "21px")
322
+ .style("font-weight", "700")
323
+ .text(metricName());
324
+
325
+ const xScale = d3.scaleTime().domain(d3.extent(rows, d => d.date)).range([plot.x, plot.x + plot.w]);
326
+ const minValue = d3.min(rows, d => d.value);
327
+ const maxValue = d3.max(rows, d => d.value);
328
+ const yStep = d3.tickStep(minValue, maxValue, 5) || 1;
329
+ const yMin = Math.max(0, Math.floor(minValue / yStep) * yStep);
330
+ const yMax = Math.ceil(maxValue / yStep) * yStep;
331
+ const yScale = d3.scaleLinear().domain([yMin, yMax]).range([plot.y + plot.h, plot.y]);
332
+ const yBase = yScale(yScale.domain()[0]);
333
+ const xTicks = xScale.ticks(Math.min(8, rows.length))
334
+ .filter((tick, index, ticks) => ticks.findIndex(other => +other === +tick) === index);
335
+ if (!xTicks.some(t => +t === +rows[0].date)) xTicks.unshift(rows[0].date);
336
+ if (!xTicks.some(t => +t === +rows[rows.length - 1].date)) xTicks.push(rows[rows.length - 1].date);
337
+ xTicks.sort((a, b) => a - b);
338
+ if (xTicks.length >= 2) {
339
+ const lastTick = xTicks[xTicks.length - 1];
340
+ const previousTick = xTicks[xTicks.length - 2];
341
+ if (xScale(lastTick) - xScale(previousTick) < 95 && +previousTick !== +rows[0].date) {
342
+ xTicks.splice(xTicks.length - 2, 1);
343
+ }
344
+ }
345
+ const yTicks = d3.range(yMin, yMax + yStep * 0.5, yStep);
346
+
347
+ yTicks.forEach(tick => {
348
+ const y = yScale(tick);
349
+ svg.append("line")
350
+ .attr("class", "y-grid-line")
351
+ .attr("x1", plot.x)
352
+ .attr("x2", plot.x + plot.w)
353
+ .attr("y1", y)
354
+ .attr("y2", y)
355
+ .attr("stroke", grid)
356
+ .attr("stroke-width", 1.2)
357
+ .attr("stroke-dasharray", "4 4");
358
+ svg.append("text")
359
+ .attr("class", "y-axis-label chart-text")
360
+ .attr("x", plot.x - 17)
361
+ .attr("y", y + 7)
362
+ .attr("text-anchor", "end")
363
+ .attr("fill", ink)
364
+ .style("font-family", referenceSans)
365
+ .style("font-size", "21px")
366
+ .text(d3.format(",.1f")(tick));
367
+ });
368
+ xTicks.forEach(tick => {
369
+ const x = xScale(tick);
370
+ svg.append("line")
371
+ .attr("class", "x-grid-line")
372
+ .attr("x1", x)
373
+ .attr("x2", x)
374
+ .attr("y1", plot.y)
375
+ .attr("y2", plot.y + plot.h)
376
+ .attr("stroke", "#D8EBFB")
377
+ .attr("stroke-width", 1)
378
+ .attr("opacity", 0.5);
379
+ svg.append("text")
380
+ .attr("class", "x-axis-label chart-text")
381
+ .attr("x", x)
382
+ .attr("y", plot.y + plot.h + 42)
383
+ .attr("text-anchor", "middle")
384
+ .attr("fill", ink)
385
+ .style("font-family", referenceSans)
386
+ .style("font-size", "23px")
387
+ .style("font-weight", "700")
388
+ .text(formatYear(tick));
389
+ });
390
+
391
+ const illustration = svg.append("g")
392
+ .attr("class", "plot-illustration-slot decorative-image-slot")
393
+ .attr("data-reserved-slot", "plot_shopping_globe_illustration_slot")
394
+ .attr("data-asset-owner", "image2")
395
+ .attr("transform", `translate(${plot.x + plot.w * 0.08}, ${plot.y + plot.h * 0.055})`);
396
+ const slotW = plot.w * 0.40;
397
+ const slotH = plot.h * 0.52;
398
+ illustration.append("rect")
399
+ .attr("x", 0)
400
+ .attr("y", slotH * 0.34)
401
+ .attr("width", slotW)
402
+ .attr("height", slotH * 0.58)
403
+ .attr("rx", 18)
404
+ .attr("fill", "#FFE49A")
405
+ .attr("opacity", 0.44)
406
+ .attr("stroke", "#F2B849")
407
+ .attr("stroke-opacity", 0.42);
408
+ illustration.append("circle")
409
+ .attr("cx", slotW * 0.48)
410
+ .attr("cy", slotH * 0.32)
411
+ .attr("r", slotW * 0.24)
412
+ .attr("fill", lightBlue)
413
+ .attr("opacity", 0.36)
414
+ .attr("stroke", blue)
415
+ .attr("stroke-opacity", 0.25);
416
+
417
+ const area = d3.area()
418
+ .x(d => xScale(d.date))
419
+ .y0(yBase)
420
+ .y1(d => yScale(d.value))
421
+ .curve(d3.curveLinear);
422
+ const line = d3.line()
423
+ .x(d => xScale(d.date))
424
+ .y(d => yScale(d.value))
425
+ .curve(d3.curveLinear);
426
+ svg.append("path")
427
+ .datum(rows)
428
+ .attr("class", "area-fill data-area")
429
+ .attr("d", area)
430
+ .attr("fill", `url(#${fillId})`);
431
+ svg.append("path")
432
+ .datum(rows)
433
+ .attr("class", "area-line data-line")
434
+ .attr("d", line)
435
+ .attr("fill", "none")
436
+ .attr("stroke", blue)
437
+ .attr("stroke-width", 6)
438
+ .attr("stroke-linejoin", "round");
439
+ const markerYears = new Set(xTicks.map(tick => formatYear(tick)));
440
+ rows.filter(point => markerYears.has(formatYear(point.date))).forEach(point => {
441
+ svg.append("circle")
442
+ .attr("class", "point-marker")
443
+ .attr("cx", xScale(point.date))
444
+ .attr("cy", yScale(point.value))
445
+ .attr("r", 8)
446
+ .attr("fill", "#FFFFFF")
447
+ .attr("stroke", blue)
448
+ .attr("stroke-width", 5);
449
+ });
450
+
451
+ const addEndpointPill = (point, side) => {
452
+ const x = xScale(point.date);
453
+ const y = yScale(point.value);
454
+ const w = Math.max(108, formatValue(point.value).length * 14);
455
+ const h = 50;
456
+ const desiredX = side === "left" ? x - w * 0.52 : x - w * 0.48;
457
+ const px = Math.max(panel.x + 60, Math.min(panel.x + panel.w - w - 12, desiredX));
458
+ const py = Math.max(panel.y + 30, y - h - 34);
459
+ const g = svg.append("g")
460
+ .attr("class", "endpoint-value-pill")
461
+ .attr("data-x", point.raw[xField])
462
+ .attr("data-value", point.value)
463
+ .attr("transform", `translate(${px}, ${py})`);
464
+ g.append("rect")
465
+ .attr("width", w)
466
+ .attr("height", h)
467
+ .attr("rx", 7)
468
+ .attr("fill", blue)
469
+ .attr("filter", `url(#${softShadowId})`);
470
+ const pointerX = Math.max(13, Math.min(w - 13, x - px));
471
+ g.append("path")
472
+ .attr("d", `M ${pointerX - 12} ${h - 1} L ${pointerX + 12} ${h - 1} L ${pointerX} ${h + 18} Z`)
473
+ .attr("fill", blue);
474
+ g.append("text")
475
+ .attr("x", w / 2)
476
+ .attr("y", 32)
477
+ .attr("text-anchor", "middle")
478
+ .attr("fill", "#FFFFFF")
479
+ .style("font-family", referenceSans)
480
+ .style("font-size", "23px")
481
+ .style("font-weight", "900")
482
+ .text(formatValue(point.value));
483
+ };
484
+ addEndpointPill(first, "left");
485
+ if (last.index !== first.index) addEndpointPill(last, "right");
486
+
487
+ return svg.node();
488
+ }
modules/chart_engine/template/d3-js/type32_area_chart/area_plain_chart_04.js ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Area Chart",
5
+ "chart_name": "area_plain_chart_04",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["temporal"], ["numerical"]],
8
+ "required_fields_range": [[3, 20], [0, 100]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": [],
13
+ "supported_effects": ["gradient", "opacity"],
14
+ "min_height": 900,
15
+ "min_width": 650,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_title": "yes",
20
+ "has_x_axis": "yes",
21
+ "has_y_axis": "yes",
22
+ "chart_for": "trend"
23
+ }
24
+ REQUIREMENTS_END
25
+ */
26
+
27
+ function makeChart(containerSelector, data) {
28
+ const jsonData = data || {};
29
+ const chartData = jsonData.data?.data || [];
30
+ const dataColumns = chartUtils.schema.columns(jsonData);
31
+ const variables = jsonData.variables || {};
32
+ const titles = jsonData.titles || {};
33
+ const metadata = jsonData.metadata || {};
34
+
35
+ d3.select(containerSelector).html("");
36
+
37
+ const xColumn = dataColumns[0] || {};
38
+ const yColumn = dataColumns[1] || {};
39
+ const xField = xColumn.name;
40
+ const yField = yColumn.name;
41
+ const width = Math.max(1080, Number(variables.width) || 1080);
42
+ const height = Math.max(1536, Number(variables.height) || Math.round(width * 1.42));
43
+ const navy = "#08275A";
44
+ const blue = "#155BB8";
45
+ const red = "#E3422D";
46
+ const muted = "#1B2E4E";
47
+ const grid = "#BBD5EF";
48
+ const referenceSans = "'Arial Narrow', Impact, Arial, sans-serif";
49
+ const bodySans = "Inter, Arial, sans-serif";
50
+ const idSuffix = String(containerSelector || "area-plain-04").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24);
51
+
52
+ const svg = d3.select(containerSelector)
53
+ .append("svg")
54
+ .attr("width", "100%")
55
+ .attr("height", height)
56
+ .attr("viewBox", `0 0 ${width} ${height}`)
57
+ .attr("style", "max-width: 100%; height: auto;")
58
+ .attr("xmlns", "http://www.w3.org/2000/svg")
59
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
60
+ .attr("class", "area-plain-chart-04-reference-layout");
61
+
62
+ const addEmptyState = message => {
63
+ svg.append("rect").attr("width", width).attr("height", height).attr("fill", "#F2FAFF");
64
+ svg.append("text").attr("x", width / 2).attr("y", height / 2).attr("text-anchor", "middle").attr("fill", navy).style("font-family", bodySans).style("font-size", "20px").text(message);
65
+ return svg.node();
66
+ };
67
+ if (!xField || !yField) return addEmptyState("Missing area chart fields");
68
+
69
+ const parseDate = value => {
70
+ const raw = String(value ?? "").trim();
71
+ if (/^-?\d{1,6}$/.test(raw)) return new Date(Number(raw), 0, 1);
72
+ const helper = chartUtils.format.parseDate(value);
73
+ if (helper instanceof Date && Number.isFinite(helper.getTime())) return helper;
74
+ const parsed = Date.parse(raw);
75
+ return Number.isFinite(parsed) ? new Date(parsed) : null;
76
+ };
77
+ const readableName = value => String(value ?? "").replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/\s+/g, " ").trim();
78
+ const wrapWords = (value, maxChars, maxLines = 3) => {
79
+ const words = String(value ?? "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
80
+ const lines = [];
81
+ let current = "";
82
+ words.forEach(word => {
83
+ const next = current ? `${current} ${word}` : word;
84
+ if (next.length <= maxChars || !current) current = next;
85
+ else { lines.push(current); current = word; }
86
+ });
87
+ if (current) lines.push(current);
88
+ if (lines.length <= maxLines) return lines;
89
+ return lines.slice(0, maxLines - 1).concat(lines.slice(maxLines - 1).join(" "));
90
+ };
91
+ const formatYear = date => date instanceof Date ? String(date.getFullYear()) : "";
92
+ const formatValue = value => {
93
+ const n = Number(value);
94
+ if (!Number.isFinite(n)) return String(value ?? "");
95
+ const suffix = String(yColumn.unit || "").trim() === "%" || /percent|frequency|rate/i.test(yColumn.name || "") ? "%" : "";
96
+ return `${d3.format(".1f")(n)}${suffix}`;
97
+ };
98
+ const axisTitle = (() => {
99
+ const base = readableName(yColumn.label || yColumn.name || yColumn.description || "Value");
100
+ const unit = String(yColumn.unit || "").trim();
101
+ if (unit && !base.includes(unit)) return `${base} (${unit})`;
102
+ if (!base.includes("%") && /frequency|rate|delay/i.test(base)) return `${base} (%)`;
103
+ return base;
104
+ })();
105
+
106
+ const rows = chartData.map((row, index) => {
107
+ const date = parseDate(row[xField]);
108
+ const value = Number(row[yField]);
109
+ return { raw: row, date, value, index };
110
+ }).filter(row => row.date && Number.isFinite(row.date.getTime()) && Number.isFinite(row.value))
111
+ .sort((a, b) => a.date - b.date || a.index - b.index);
112
+ if (rows.length < 2) return addEmptyState("Area chart needs at least two points");
113
+
114
+ const defs = svg.append("defs");
115
+ const bgId = `area-plain-04-bg-${idSuffix}`;
116
+ const bg = defs.append("linearGradient").attr("id", bgId).attr("x1", "0%").attr("y1", "0%").attr("x2", "100%").attr("y2", "100%");
117
+ bg.append("stop").attr("offset", "0%").attr("stop-color", "#FFFFFF");
118
+ bg.append("stop").attr("offset", "100%").attr("stop-color", "#E4F7FF");
119
+ const fillId = `area-plain-04-fill-${idSuffix}`;
120
+ const fill = defs.append("linearGradient").attr("id", fillId).attr("x1", "0%").attr("y1", "0%").attr("x2", "0%").attr("y2", "100%");
121
+ fill.append("stop").attr("offset", "0%").attr("stop-color", "#1D6BD2").attr("stop-opacity", 0.94);
122
+ fill.append("stop").attr("offset", "100%").attr("stop-color", "#DDF4FF").attr("stop-opacity", 0.35);
123
+ const shadowId = `area-plain-04-shadow-${idSuffix}`;
124
+ const shadow = defs.append("filter").attr("id", shadowId).attr("x", "-20%").attr("y", "-20%").attr("width", "140%").attr("height", "150%");
125
+ shadow.append("feDropShadow").attr("dx", 0).attr("dy", 8).attr("stdDeviation", 14).attr("flood-color", "#73A7D8").attr("flood-opacity", 0.17);
126
+
127
+ svg.append("rect").attr("width", width).attr("height", height).attr("fill", `url(#${bgId})`);
128
+
129
+ const rawTitle = readableName(titles.main_title || metadata.title || yField);
130
+ const subtitle = String(titles.sub_title || metadata.description || `${axisTitle} over time.`).trim();
131
+ const titleG = svg.append("g")
132
+ .attr("class", "header-title-art-slot")
133
+ .attr("data-reserved-slot", "header_title_art_slot")
134
+ .attr("transform", `translate(${width * 0.055}, ${height * 0.064})`);
135
+ const titleFont = 60;
136
+ wrapWords(rawTitle.toUpperCase(), 23, 3).forEach((line, i) => {
137
+ titleG.append("text")
138
+ .attr("x", 0)
139
+ .attr("y", i * 74)
140
+ .attr("fill", i === 0 ? navy : blue)
141
+ .style("font-family", referenceSans)
142
+ .style("font-size", `${titleFont}px`)
143
+ .style("font-weight", "900")
144
+ .text(line);
145
+ });
146
+ titleG.append("line")
147
+ .attr("x1", 0).attr("x2", 138)
148
+ .attr("y1", 226).attr("y2", 226)
149
+ .attr("stroke", "#F0C319").attr("stroke-width", 5);
150
+ wrapWords(subtitle, 48, 3).forEach((line, i) => {
151
+ titleG.append("text")
152
+ .attr("x", 0)
153
+ .attr("y", 278 + i * 36)
154
+ .attr("fill", muted)
155
+ .style("font-family", bodySans)
156
+ .style("font-size", "25px")
157
+ .text(line);
158
+ });
159
+
160
+ const asset = svg.append("g")
161
+ .attr("class", "header-aviation-illustration-slot decorative-image-slot")
162
+ .attr("data-reserved-slot", "header_aviation_illustration_slot")
163
+ .attr("data-asset-owner", "image2")
164
+ .attr("transform", `translate(${width * 0.64}, ${height * 0.075})`);
165
+ asset.append("circle").attr("cx", 155).attr("cy", 160).attr("r", 145).attr("fill", "#9ED8FF").attr("opacity", 0.32);
166
+ asset.append("ellipse").attr("cx", 170).attr("cy", 245).attr("rx", 170).attr("ry", 45).attr("fill", "#FFFFFF").attr("opacity", 0.42);
167
+ asset.append("path").attr("d", "M65,110 L165,150 L308,112 L320,134 L190,198 L78,164 Z").attr("fill", "#FFFFFF").attr("stroke", blue).attr("stroke-opacity", 0.28);
168
+
169
+ const plot = { x: width * 0.11, y: height * 0.40, w: width * 0.82, h: height * 0.39 };
170
+ const minValue = d3.min(rows, d => d.value);
171
+ const maxValue = d3.max(rows, d => d.value);
172
+ const yStep = d3.tickStep(0, maxValue, 5) || 1;
173
+ const yMax = Math.ceil(maxValue / yStep) * yStep;
174
+ const yScale = d3.scaleLinear().domain([0, yMax]).range([plot.y + plot.h, plot.y]);
175
+ const xScale = d3.scaleTime().domain(d3.extent(rows, d => d.date)).range([plot.x + 44, plot.x + plot.w - 12]);
176
+ const xTicks = rows.filter((_, i) => i % Math.ceil(rows.length / 8) === 0 || i === rows.length - 1);
177
+ const yTicks = d3.range(0, yMax + yStep * 0.5, yStep);
178
+ const baselineY = yScale(0);
179
+
180
+ yTicks.forEach(tick => {
181
+ const y = yScale(tick);
182
+ svg.append("line")
183
+ .attr("x1", plot.x)
184
+ .attr("x2", plot.x + plot.w)
185
+ .attr("y1", y)
186
+ .attr("y2", y)
187
+ .attr("stroke", grid)
188
+ .attr("stroke-width", 1.2)
189
+ .attr("stroke-dasharray", "5 5");
190
+ svg.append("text")
191
+ .attr("x", plot.x - 13)
192
+ .attr("y", y + 7)
193
+ .attr("text-anchor", "end")
194
+ .attr("fill", navy)
195
+ .style("font-family", bodySans)
196
+ .style("font-size", "24px")
197
+ .style("font-weight", "700")
198
+ .text(`${d3.format("~g")(tick)}${String(yColumn.unit || "").trim() === "%" ? "%" : ""}`);
199
+ });
200
+ svg.append("text")
201
+ .attr("transform", `translate(${plot.x - 72}, ${plot.y + plot.h / 2}) rotate(-90)`)
202
+ .attr("text-anchor", "middle")
203
+ .attr("fill", "#44648B")
204
+ .style("font-family", bodySans)
205
+ .style("font-size", "25px")
206
+ .style("font-weight", "800")
207
+ .style("letter-spacing", "2px")
208
+ .text(axisTitle.toUpperCase());
209
+
210
+ const area = d3.area().x(d => xScale(d.date)).y0(baselineY).y1(d => yScale(d.value)).curve(d3.curveLinear);
211
+ const line = d3.line().x(d => xScale(d.date)).y(d => yScale(d.value)).curve(d3.curveLinear);
212
+ svg.append("path").datum(rows).attr("class", "area-fill data-area").attr("d", area).attr("fill", `url(#${fillId})`);
213
+ svg.append("path").datum(rows).attr("d", line).attr("fill", "none").attr("stroke", blue).attr("stroke-width", 8).attr("stroke-linejoin", "round");
214
+ svg.append("path").datum(rows).attr("d", line).attr("fill", "none").attr("stroke", "#083E91").attr("stroke-width", 3.5);
215
+
216
+ xTicks.forEach(point => {
217
+ const x = xScale(point.date);
218
+ const y = yScale(point.value);
219
+ svg.append("line")
220
+ .attr("x1", x).attr("x2", x)
221
+ .attr("y1", y + 12).attr("y2", baselineY)
222
+ .attr("stroke", "#D6E6F6")
223
+ .attr("stroke-width", 1.4)
224
+ .attr("stroke-dasharray", "4 5");
225
+ svg.append("circle").attr("cx", x).attr("cy", y).attr("r", 8.5).attr("fill", "#FFFFFF").attr("stroke", blue).attr("stroke-width", 4);
226
+ svg.append("text")
227
+ .attr("x", x)
228
+ .attr("y", y - 36)
229
+ .attr("text-anchor", "middle")
230
+ .attr("fill", navy)
231
+ .style("font-family", bodySans)
232
+ .style("font-size", "20px")
233
+ .style("font-weight", "900")
234
+ .text(formatYear(point.date));
235
+ svg.append("text")
236
+ .attr("x", x)
237
+ .attr("y", y - 8)
238
+ .attr("text-anchor", "middle")
239
+ .attr("fill", red)
240
+ .style("font-family", bodySans)
241
+ .style("font-size", "25px")
242
+ .style("font-weight", "900")
243
+ .text(formatValue(point.value));
244
+ svg.append("text")
245
+ .attr("x", x)
246
+ .attr("y", baselineY + 42)
247
+ .attr("text-anchor", "middle")
248
+ .attr("fill", navy)
249
+ .style("font-family", bodySans)
250
+ .style("font-size", "22px")
251
+ .style("font-weight", "800")
252
+ .text(formatYear(point.date));
253
+ });
254
+ svg.append("line").attr("x1", plot.x).attr("x2", plot.x + plot.w).attr("y1", baselineY).attr("y2", baselineY).attr("stroke", navy).attr("stroke-width", 3);
255
+
256
+ return svg.node();
257
+ }