Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- modules/chart_engine/template/d3-js/type32_area_chart/area_plain_chart_01.js +447 -0
- modules/chart_engine/template/d3-js/type32_area_chart/area_plain_chart_02.js +482 -0
- modules/chart_engine/template/d3-js/type32_area_chart/area_plain_chart_03.js +488 -0
- modules/chart_engine/template/d3-js/type32_area_chart/area_plain_chart_04.js +257 -0
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 |
+
}
|