Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- modules/chart_engine/template/d3-js/area_chart/area_chart_01.js +713 -0
- modules/chart_engine/template/d3-js/area_chart/area_chart_02.js +576 -0
- modules/chart_engine/template/d3-js/area_chart/area_chart_03.js +545 -0
- modules/chart_engine/template/d3-js/area_chart/area_chart_04.js +718 -0
- modules/chart_engine/template/d3-js/area_chart/layered_area_chart_03.js +343 -0
- modules/chart_engine/template/d3-js/area_chart/multiple_area_chart_01.js +547 -0
- modules/chart_engine/template/d3-js/area_chart/range_area_chart_01.js +475 -0
- modules/chart_engine/template/d3-js/area_chart/small_multiple_area_chart_01.js +220 -0
- modules/chart_engine/template/d3-js/area_chart/small_multiple_area_chart_02.js +220 -0
- modules/chart_engine/template/d3-js/area_chart/spline_area_chart_01.js +241 -0
- modules/chart_engine/template/d3-js/area_chart/spline_area_chart_02.js +241 -0
- modules/chart_engine/template/d3-js/area_chart/spline_layered_area_chart_01.js +595 -0
- modules/chart_engine/template/d3-js/area_chart/spline_multiple_area_chart_01.js +572 -0
- modules/chart_engine/template/d3-js/area_chart/spline_stacked_area_chart_01.js +631 -0
- modules/chart_engine/template/d3-js/area_chart/stacked_area_chart_01.js +450 -0
- modules/chart_engine/template/d3-js/area_chart/stacked_area_chart_02.js +394 -0
modules/chart_engine/template/d3-js/area_chart/area_chart_01.js
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Area Chart",
|
| 5 |
+
"chart_name": "area_chart_01",
|
| 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": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "no"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
const jsonData = data || {};
|
| 27 |
+
const chartData = jsonData.data?.data || [];
|
| 28 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 29 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 30 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 31 |
+
const chartUtilsStandard = {
|
| 32 |
+
schema: chartUtils.schema,
|
| 33 |
+
format: chartUtils.format,
|
| 34 |
+
text: chartUtils.text,
|
| 35 |
+
color: chartUtils.color,
|
| 36 |
+
legendLayout: chartUtils.legend.layout,
|
| 37 |
+
legendDraw: chartUtils.legend.draw,
|
| 38 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 39 |
+
};
|
| 40 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 41 |
+
x: { fallbackIndex: 0 },
|
| 42 |
+
y: { fallbackIndex: 1 },
|
| 43 |
+
y2: { fallbackIndex: 2 },
|
| 44 |
+
y3: { fallbackIndex: 3 },
|
| 45 |
+
size: { fallbackIndex: 2 },
|
| 46 |
+
group: { fallbackIndex: 2 },
|
| 47 |
+
group2: { fallbackIndex: 3 },
|
| 48 |
+
group3: { fallbackIndex: 4 }
|
| 49 |
+
});
|
| 50 |
+
const variables = jsonData.variables || {};
|
| 51 |
+
const typography = jsonData.typography || {};
|
| 52 |
+
const sourceColors = jsonData.colors || {};
|
| 53 |
+
const images = jsonData.images || {};
|
| 54 |
+
|
| 55 |
+
d3.select(containerSelector).html("");
|
| 56 |
+
|
| 57 |
+
const xColumn = dataColumns[0] || {};
|
| 58 |
+
const yColumn = dataColumns[1] || {};
|
| 59 |
+
const xField = xColumn.name;
|
| 60 |
+
const yField = yColumn.name;
|
| 61 |
+
|
| 62 |
+
const width = Math.max(640, Number(variables.width) || 800);
|
| 63 |
+
const height = Math.max(950, Number(variables.height) || 600);
|
| 64 |
+
const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
|
| 65 |
+
const titleFontFamily = typography.title?.font_family || fontFamily;
|
| 66 |
+
const textColor = sourceColors.text_color || "#10214A";
|
| 67 |
+
const mutedText = "#5F6B7A";
|
| 68 |
+
const pageFill = "#F5FAFD";
|
| 69 |
+
const panelFill = "#FFFFFF";
|
| 70 |
+
const border = "#E2EAF3";
|
| 71 |
+
const blue = sourceColors.other?.primary || "#1E5CB8";
|
| 72 |
+
const teal = sourceColors.other?.secondary || "#24B897";
|
| 73 |
+
const green = "#2E9346";
|
| 74 |
+
const idSuffix = String(containerSelector || "area-chart-01")
|
| 75 |
+
.replace(/[^a-zA-Z0-9_-]/g, "")
|
| 76 |
+
.slice(0, 24) || "area01";
|
| 77 |
+
|
| 78 |
+
const svg = d3.select(containerSelector)
|
| 79 |
+
.append("svg")
|
| 80 |
+
.attr("width", "100%")
|
| 81 |
+
.attr("height", height)
|
| 82 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 83 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 84 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 85 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 86 |
+
.attr("class", "area-chart-01-reference-layout");
|
| 87 |
+
|
| 88 |
+
const addEmptyState = message => {
|
| 89 |
+
svg.append("rect")
|
| 90 |
+
.attr("width", width)
|
| 91 |
+
.attr("height", height)
|
| 92 |
+
.attr("fill", pageFill);
|
| 93 |
+
svg.append("text")
|
| 94 |
+
.attr("x", width / 2)
|
| 95 |
+
.attr("y", height / 2)
|
| 96 |
+
.attr("text-anchor", "middle")
|
| 97 |
+
.attr("fill", textColor)
|
| 98 |
+
.style("font-family", fontFamily)
|
| 99 |
+
.style("font-size", "16px")
|
| 100 |
+
.text(message);
|
| 101 |
+
return svg.node();
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
if (!xField || !yField) {
|
| 105 |
+
return addEmptyState("Missing area chart fields");
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const readableName = value => String(value ?? "")
|
| 109 |
+
.replace(/_/g, " ")
|
| 110 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 111 |
+
.replace(/\s+/g, " ")
|
| 112 |
+
.trim();
|
| 113 |
+
|
| 114 |
+
const shortLabel = (value, maxChars = 28) => {
|
| 115 |
+
const clean = readableName(value);
|
| 116 |
+
if (clean.length <= maxChars) return clean;
|
| 117 |
+
return `${clean.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
|
| 118 |
+
};
|
| 119 |
+
const truncateText = (value, maxChars = 28) => {
|
| 120 |
+
const clean = String(value ?? "").replace(/\s+/g, " ").trim();
|
| 121 |
+
if (clean.length <= maxChars) return clean;
|
| 122 |
+
return `${clean.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
const parseTimeValue = value => {
|
| 126 |
+
const raw = String(value ?? "").trim();
|
| 127 |
+
if (!raw) return null;
|
| 128 |
+
if (/^\d{4}$/.test(raw)) return new Date(Number(raw), 0, 1);
|
| 129 |
+
{
|
| 130 |
+
const parsedByHelper = chartUtils.format.parseDate(value);
|
| 131 |
+
if (parsedByHelper instanceof Date && Number.isFinite(parsedByHelper.getTime())) return parsedByHelper;
|
| 132 |
+
}
|
| 133 |
+
const parsed = Date.parse(raw);
|
| 134 |
+
if (Number.isFinite(parsed)) return new Date(parsed);
|
| 135 |
+
const numeric = Number(raw);
|
| 136 |
+
if (Number.isFinite(numeric)) return new Date(numeric, 0, 1);
|
| 137 |
+
return null;
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
const unitRaw = String(yColumn.unit || "").trim();
|
| 141 |
+
const unit = unitRaw.toLowerCase() === "none" ? "" : unitRaw;
|
| 142 |
+
const valueBaseTitle = readableName(yColumn.label || yField);
|
| 143 |
+
const valueTitle = shortLabel(`${valueBaseTitle}${unit ? ` (${unit})` : ""}`, 34);
|
| 144 |
+
const axisTitle = `${shortLabel(valueBaseTitle, unit ? 28 : 34).toUpperCase()}${unit ? ` (${unit})` : ""}`;
|
| 145 |
+
const formatNumber = value => {
|
| 146 |
+
const numberValue = Number(value) || 0;
|
| 147 |
+
const abs = Math.abs(numberValue);
|
| 148 |
+
const spec = abs >= 1000 ? ",.0f" : abs >= 100 ? ",.1f" : abs >= 10 ? ",.1f" : abs >= 1 ? ",.1f" : ",.3f";
|
| 149 |
+
return d3.format(spec)(numberValue)
|
| 150 |
+
.replace(/\.0$/, "")
|
| 151 |
+
.replace(/(\.\d*?)0+$/, "$1")
|
| 152 |
+
.replace(/\.$/, "");
|
| 153 |
+
};
|
| 154 |
+
const formatLocalValue = value => {
|
| 155 |
+
const formatted = formatNumber(value);
|
| 156 |
+
if (!unit) return formatted;
|
| 157 |
+
if (unit === "%") return `${formatted}%`;
|
| 158 |
+
if (unit === "$" || unit.toLowerCase() === "usd") return `$${formatted}`;
|
| 159 |
+
if (unit.startsWith("$")) return `$${formatted}${unit.slice(1)}`;
|
| 160 |
+
return `${formatted} ${unit}`;
|
| 161 |
+
};
|
| 162 |
+
const formatLabelValueParts = value => {
|
| 163 |
+
const formatted = formatNumber(value);
|
| 164 |
+
if (!unit) return { valueText: formatted, unitText: "" };
|
| 165 |
+
if (unit === "%") return { valueText: `${formatted}%`, unitText: "" };
|
| 166 |
+
if (unit === "$" || unit.toLowerCase() === "usd") return { valueText: `$${formatted}`, unitText: "" };
|
| 167 |
+
if (unit.startsWith("$")) return { valueText: `$${formatted}${unit.slice(1)}`, unitText: "" };
|
| 168 |
+
return { valueText: formatted, unitText: unit };
|
| 169 |
+
};
|
| 170 |
+
const formatDateLabel = date => {
|
| 171 |
+
if (!(date instanceof Date) || !Number.isFinite(date.getTime())) return "";
|
| 172 |
+
const year = date.getFullYear();
|
| 173 |
+
return String(year);
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
const rows = chartData
|
| 177 |
+
.map((row, sourceIndex) => {
|
| 178 |
+
const date = parseTimeValue(row[xField]);
|
| 179 |
+
const value = Number(row[yField]);
|
| 180 |
+
return {
|
| 181 |
+
rawX: String(row[xField] ?? ""),
|
| 182 |
+
date,
|
| 183 |
+
value,
|
| 184 |
+
sourceIndex
|
| 185 |
+
};
|
| 186 |
+
})
|
| 187 |
+
.filter(row => row.date && Number.isFinite(row.date.getTime()) && Number.isFinite(row.value))
|
| 188 |
+
.sort((a, b) => a.date - b.date || a.sourceIndex - b.sourceIndex);
|
| 189 |
+
|
| 190 |
+
if (rows.length < 2) {
|
| 191 |
+
return addEmptyState("Area chart needs at least two valid points");
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
const minValue = Math.min(0, d3.min(rows, d => d.value) || 0);
|
| 195 |
+
const maxValue = d3.max(rows, d => d.value) || 1;
|
| 196 |
+
const valueRange = Math.max(0, maxValue - minValue);
|
| 197 |
+
const yPadding = valueRange === 0
|
| 198 |
+
? Math.max(1, Math.abs(maxValue) * 0.12)
|
| 199 |
+
: Math.max(valueRange * 0.12, Math.abs(maxValue) * 0.04, 0.001);
|
| 200 |
+
const yTop = maxValue + yPadding;
|
| 201 |
+
const firstPoint = rows[0];
|
| 202 |
+
const lastPoint = rows[rows.length - 1];
|
| 203 |
+
const peakPoint = rows.reduce((max, row) => row.value > max.value ? row : max, rows[0]);
|
| 204 |
+
const troughPoint = rows.reduce((min, row) => row.value < min.value ? row : min, rows[0]);
|
| 205 |
+
const delta = lastPoint.value - firstPoint.value;
|
| 206 |
+
const deltaPct = firstPoint.value === 0 ? 0 : (delta / Math.abs(firstPoint.value)) * 100;
|
| 207 |
+
const trendWord = delta < 0 ? "reduction" : delta > 0 ? "increase" : "change";
|
| 208 |
+
const absDeltaPct = Math.abs(deltaPct);
|
| 209 |
+
const deltaText = Math.abs(firstPoint.value) < 1 || absDeltaPct > 999
|
| 210 |
+
? `${delta >= 0 ? "+" : "-"}${formatLocalValue(Math.abs(delta))} ${trendWord}`
|
| 211 |
+
: `${absDeltaPct.toFixed(1).replace(/\.0$/, "")}% ${trendWord}`;
|
| 212 |
+
|
| 213 |
+
const panelX = -Math.max(104, width * 0.18);
|
| 214 |
+
const panelY = Math.max(18, height * 0.035);
|
| 215 |
+
const panelW = width - panelX * 2;
|
| 216 |
+
const footerH = 0;
|
| 217 |
+
const footerGap = 0;
|
| 218 |
+
const panelH = height - panelY * 2;
|
| 219 |
+
|
| 220 |
+
const chartPadX = Math.max(48, panelW * 0.085);
|
| 221 |
+
const chartPadRight = Math.max(28, panelW * 0.035);
|
| 222 |
+
const plotX = panelX + chartPadX;
|
| 223 |
+
const plotRight = panelX + panelW - chartPadRight;
|
| 224 |
+
const plotBottom = panelY + panelH - Math.max(52, panelH * 0.11);
|
| 225 |
+
const plotTop = panelY + Math.max(76, panelH * 0.17);
|
| 226 |
+
const plotW = Math.max(120, plotRight - plotX);
|
| 227 |
+
const plotH = Math.max(120, plotBottom - plotTop);
|
| 228 |
+
const labelFont = rows.length > 12 ? 10.5 : rows.length > 8 ? 11.5 : 14;
|
| 229 |
+
const valueFont = rows.length > 12 ? 12.5 : rows.length > 8 ? 15.5 : 25;
|
| 230 |
+
const unitFont = rows.length > 12 ? 9.5 : rows.length > 8 ? 10.5 : 14;
|
| 231 |
+
const calloutW = Math.min(260, Math.max(205, panelW * 0.32));
|
| 232 |
+
const calloutH = Math.max(52, Math.min(68, panelH * 0.14));
|
| 233 |
+
const calloutX = panelX + panelW - calloutW - Math.max(20, panelW * 0.03);
|
| 234 |
+
const calloutY = panelY + Math.max(16, panelH * 0.035);
|
| 235 |
+
const calloutAvoidBox = {
|
| 236 |
+
left: calloutX - 8,
|
| 237 |
+
right: calloutX + calloutW + 8,
|
| 238 |
+
top: calloutY - 8,
|
| 239 |
+
bottom: calloutY + calloutH + 10
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
const xScale = d3.scaleTime()
|
| 243 |
+
.domain(d3.extent(rows, d => d.date))
|
| 244 |
+
.range([plotX, plotRight]);
|
| 245 |
+
const yScale = d3.scaleLinear()
|
| 246 |
+
.domain([minValue, yTop])
|
| 247 |
+
.nice(4)
|
| 248 |
+
.range([plotBottom, plotTop]);
|
| 249 |
+
|
| 250 |
+
const defs = svg.append("defs");
|
| 251 |
+
const shadow = defs.append("filter")
|
| 252 |
+
.attr("id", `area-chart-01-shadow-${idSuffix}`)
|
| 253 |
+
.attr("x", "-12%")
|
| 254 |
+
.attr("y", "-12%")
|
| 255 |
+
.attr("width", "124%")
|
| 256 |
+
.attr("height", "124%");
|
| 257 |
+
shadow.append("feDropShadow")
|
| 258 |
+
.attr("dx", 0)
|
| 259 |
+
.attr("dy", 10)
|
| 260 |
+
.attr("stdDeviation", 10)
|
| 261 |
+
.attr("flood-color", "#A8B7C8")
|
| 262 |
+
.attr("flood-opacity", 0.22);
|
| 263 |
+
|
| 264 |
+
const areaGradientId = `area-chart-01-fill-${idSuffix}`;
|
| 265 |
+
const areaGradient = defs.append("linearGradient")
|
| 266 |
+
.attr("id", areaGradientId)
|
| 267 |
+
.attr("gradientUnits", "userSpaceOnUse")
|
| 268 |
+
.attr("x1", plotX)
|
| 269 |
+
.attr("x2", plotRight)
|
| 270 |
+
.attr("y1", plotTop)
|
| 271 |
+
.attr("y2", plotBottom);
|
| 272 |
+
areaGradient.append("stop").attr("offset", "0%").attr("stop-color", blue).attr("stop-opacity", 0.98);
|
| 273 |
+
areaGradient.append("stop").attr("offset", "58%").attr("stop-color", "#218BD2").attr("stop-opacity", 0.90);
|
| 274 |
+
areaGradient.append("stop").attr("offset", "100%").attr("stop-color", teal).attr("stop-opacity", 0.94);
|
| 275 |
+
|
| 276 |
+
const lineGradientId = `area-chart-01-line-${idSuffix}`;
|
| 277 |
+
const lineGradient = defs.append("linearGradient")
|
| 278 |
+
.attr("id", lineGradientId)
|
| 279 |
+
.attr("gradientUnits", "userSpaceOnUse")
|
| 280 |
+
.attr("x1", plotX)
|
| 281 |
+
.attr("x2", plotRight);
|
| 282 |
+
lineGradient.append("stop").attr("offset", "0%").attr("stop-color", "#104CAD");
|
| 283 |
+
lineGradient.append("stop").attr("offset", "100%").attr("stop-color", "#219B66");
|
| 284 |
+
|
| 285 |
+
const dotPatternId = `area-chart-01-dot-pattern-${idSuffix}`;
|
| 286 |
+
const dotPattern = defs.append("pattern")
|
| 287 |
+
.attr("id", dotPatternId)
|
| 288 |
+
.attr("width", 7)
|
| 289 |
+
.attr("height", 7)
|
| 290 |
+
.attr("patternUnits", "userSpaceOnUse");
|
| 291 |
+
dotPattern.append("circle")
|
| 292 |
+
.attr("cx", 2)
|
| 293 |
+
.attr("cy", 2)
|
| 294 |
+
.attr("r", 0.85)
|
| 295 |
+
.attr("fill", "#FFFFFF")
|
| 296 |
+
.attr("opacity", 0.34);
|
| 297 |
+
|
| 298 |
+
const clipId = `area-chart-01-plot-clip-${idSuffix}`;
|
| 299 |
+
defs.append("clipPath")
|
| 300 |
+
.attr("id", clipId)
|
| 301 |
+
.append("rect")
|
| 302 |
+
.attr("x", panelX)
|
| 303 |
+
.attr("y", panelY)
|
| 304 |
+
.attr("width", panelW)
|
| 305 |
+
.attr("height", panelH)
|
| 306 |
+
.attr("rx", 18);
|
| 307 |
+
|
| 308 |
+
const area = d3.area()
|
| 309 |
+
.x(d => xScale(d.date))
|
| 310 |
+
.y0(plotBottom)
|
| 311 |
+
.y1(d => yScale(d.value))
|
| 312 |
+
.curve(d3.curveMonotoneX);
|
| 313 |
+
const line = d3.line()
|
| 314 |
+
.x(d => xScale(d.date))
|
| 315 |
+
.y(d => yScale(d.value))
|
| 316 |
+
.curve(d3.curveMonotoneX);
|
| 317 |
+
|
| 318 |
+
const root = svg.append("g").attr("class", "area-chart-01-reference-panel");
|
| 319 |
+
root.append("rect")
|
| 320 |
+
.attr("class", "chart-style-background")
|
| 321 |
+
.attr("width", width)
|
| 322 |
+
.attr("height", height)
|
| 323 |
+
.attr("fill", pageFill)
|
| 324 |
+
.attr("fill-opacity", 0);
|
| 325 |
+
root.append("rect")
|
| 326 |
+
.attr("class", "chart-style-panel chart-card-panel")
|
| 327 |
+
.attr("x", panelX)
|
| 328 |
+
.attr("y", panelY)
|
| 329 |
+
.attr("width", panelW)
|
| 330 |
+
.attr("height", panelH)
|
| 331 |
+
.attr("rx", 20)
|
| 332 |
+
.attr("fill", panelFill)
|
| 333 |
+
.attr("stroke", border)
|
| 334 |
+
.attr("filter", `url(#area-chart-01-shadow-${idSuffix})`);
|
| 335 |
+
|
| 336 |
+
const yTicks = yScale.ticks(5).filter(tick => tick >= minValue && tick <= yTop);
|
| 337 |
+
const gridLayer = root.append("g").attr("class", "plot-grid-layer");
|
| 338 |
+
yTicks.forEach(tick => {
|
| 339 |
+
const y = yScale(tick);
|
| 340 |
+
gridLayer.append("line")
|
| 341 |
+
.attr("class", "y-grid-line")
|
| 342 |
+
.attr("x1", plotX)
|
| 343 |
+
.attr("x2", plotRight)
|
| 344 |
+
.attr("y1", y)
|
| 345 |
+
.attr("y2", y)
|
| 346 |
+
.attr("stroke", "#E5EDF6")
|
| 347 |
+
.attr("stroke-width", 1);
|
| 348 |
+
gridLayer.append("text")
|
| 349 |
+
.attr("class", "y-axis-tick chart-text")
|
| 350 |
+
.attr("x", plotX - 10)
|
| 351 |
+
.attr("y", y + 4)
|
| 352 |
+
.attr("text-anchor", "end")
|
| 353 |
+
.attr("fill", "#39506B")
|
| 354 |
+
.style("font-family", fontFamily)
|
| 355 |
+
.style("font-size", "10px")
|
| 356 |
+
.text(formatNumber(tick));
|
| 357 |
+
});
|
| 358 |
+
root.append("text")
|
| 359 |
+
.attr("class", "y-axis-title chart-text")
|
| 360 |
+
.attr("transform", `translate(${panelX + 26}, ${plotTop + plotH / 2}) rotate(-90)`)
|
| 361 |
+
.attr("text-anchor", "middle")
|
| 362 |
+
.attr("fill", "#334660")
|
| 363 |
+
.style("font-family", titleFontFamily)
|
| 364 |
+
.style("font-size", "11px")
|
| 365 |
+
.style("font-weight", "900")
|
| 366 |
+
.style("letter-spacing", "0px")
|
| 367 |
+
.text(axisTitle);
|
| 368 |
+
|
| 369 |
+
const guideLayer = root.append("g").attr("class", "point-guide-lines");
|
| 370 |
+
rows.forEach(row => {
|
| 371 |
+
const x = xScale(row.date);
|
| 372 |
+
const y = yScale(row.value);
|
| 373 |
+
guideLayer.append("line")
|
| 374 |
+
.attr("class", "point-guide-line")
|
| 375 |
+
.attr("x1", x)
|
| 376 |
+
.attr("x2", x)
|
| 377 |
+
.attr("y1", y + 6)
|
| 378 |
+
.attr("y2", plotBottom)
|
| 379 |
+
.attr("stroke", "#FFFFFF")
|
| 380 |
+
.attr("stroke-width", 1.1)
|
| 381 |
+
.attr("stroke-dasharray", "4 5")
|
| 382 |
+
.attr("opacity", 0.65)
|
| 383 |
+
.attr("data-source-index", row.sourceIndex)
|
| 384 |
+
.attr("data-x", row.rawX)
|
| 385 |
+
.attr("data-value", row.value);
|
| 386 |
+
});
|
| 387 |
+
|
| 388 |
+
const plotLayer = root.append("g").attr("class", "area-plot-layer").attr("clip-path", `url(#${clipId})`);
|
| 389 |
+
plotLayer.append("path")
|
| 390 |
+
.datum(rows)
|
| 391 |
+
.attr("class", "area-fill data-area")
|
| 392 |
+
.attr("d", area)
|
| 393 |
+
.attr("fill", `url(#${areaGradientId})`)
|
| 394 |
+
.attr("data-point-count", rows.length)
|
| 395 |
+
.attr("data-first-x", firstPoint.rawX)
|
| 396 |
+
.attr("data-first-value", firstPoint.value)
|
| 397 |
+
.attr("data-last-x", lastPoint.rawX)
|
| 398 |
+
.attr("data-last-value", lastPoint.value)
|
| 399 |
+
.attr("data-min-value", troughPoint.value)
|
| 400 |
+
.attr("data-max-value", peakPoint.value);
|
| 401 |
+
plotLayer.append("path")
|
| 402 |
+
.datum(rows)
|
| 403 |
+
.attr("class", "area-dot-pattern decorative-pattern")
|
| 404 |
+
.attr("d", area)
|
| 405 |
+
.attr("fill", `url(#${dotPatternId})`)
|
| 406 |
+
.attr("opacity", 0.9);
|
| 407 |
+
plotLayer.append("path")
|
| 408 |
+
.datum(rows)
|
| 409 |
+
.attr("class", "area-line-underlay")
|
| 410 |
+
.attr("d", line)
|
| 411 |
+
.attr("fill", "none")
|
| 412 |
+
.attr("stroke", "#FFFFFF")
|
| 413 |
+
.attr("stroke-width", 5.2)
|
| 414 |
+
.attr("stroke-linejoin", "round")
|
| 415 |
+
.attr("stroke-linecap", "round")
|
| 416 |
+
.attr("opacity", 0.95);
|
| 417 |
+
plotLayer.append("path")
|
| 418 |
+
.datum(rows)
|
| 419 |
+
.attr("class", "area-line data-line")
|
| 420 |
+
.attr("d", line)
|
| 421 |
+
.attr("fill", "none")
|
| 422 |
+
.attr("stroke", `url(#${lineGradientId})`)
|
| 423 |
+
.attr("stroke-width", 3)
|
| 424 |
+
.attr("stroke-linejoin", "round")
|
| 425 |
+
.attr("stroke-linecap", "round");
|
| 426 |
+
|
| 427 |
+
const pointLayer = root.append("g").attr("class", "area-data-points");
|
| 428 |
+
const labelIndexSet = new Set();
|
| 429 |
+
rows.forEach((_, index) => {
|
| 430 |
+
if (rows.length <= 10 || index === 0 || index === rows.length - 1 || index % Math.ceil(rows.length / 6) === 0) {
|
| 431 |
+
labelIndexSet.add(index);
|
| 432 |
+
}
|
| 433 |
+
});
|
| 434 |
+
labelIndexSet.add(rows.indexOf(peakPoint));
|
| 435 |
+
labelIndexSet.add(rows.indexOf(troughPoint));
|
| 436 |
+
const labelIndices = [...labelIndexSet].sort((a, b) => a - b);
|
| 437 |
+
const labelLayouts = new Map();
|
| 438 |
+
const estimateTextWidth = (text, fontSize, weightFactor = 0.58) => String(text || "").length * fontSize * weightFactor;
|
| 439 |
+
const labelBounds = layout => {
|
| 440 |
+
const bottomLineY = layout.unitText ? layout.unitY : layout.valueY;
|
| 441 |
+
return {
|
| 442 |
+
left: layout.x - layout.width / 2,
|
| 443 |
+
right: layout.x + layout.width / 2,
|
| 444 |
+
top: layout.yearY - labelFont - 2,
|
| 445 |
+
bottom: bottomLineY + 4
|
| 446 |
+
};
|
| 447 |
+
};
|
| 448 |
+
const boundsOverlap = (a, b, pad = 4) => a.left - pad < b.right
|
| 449 |
+
&& a.right + pad > b.left
|
| 450 |
+
&& a.top - pad < b.bottom
|
| 451 |
+
&& a.bottom + pad > b.top;
|
| 452 |
+
const updateLabelLines = layout => {
|
| 453 |
+
layout.valueY = layout.yearY + valueFont + 2;
|
| 454 |
+
layout.unitY = layout.valueY + unitFont + 3;
|
| 455 |
+
layout.bounds = labelBounds(layout);
|
| 456 |
+
};
|
| 457 |
+
const createLabelLayout = index => {
|
| 458 |
+
const row = rows[index];
|
| 459 |
+
const x = xScale(row.date);
|
| 460 |
+
const y = yScale(row.value);
|
| 461 |
+
const { valueText: labelValueText, unitText } = formatLabelValueParts(row.value);
|
| 462 |
+
const yearText = formatDateLabel(row.date);
|
| 463 |
+
const progress = rows.length === 1 ? 0 : index / (rows.length - 1);
|
| 464 |
+
const widthEstimate = Math.max(
|
| 465 |
+
estimateTextWidth(yearText, labelFont, 0.62),
|
| 466 |
+
estimateTextWidth(labelValueText, valueFont, 0.64),
|
| 467 |
+
estimateTextWidth(unitText, unitFont, 0.56)
|
| 468 |
+
) + 12;
|
| 469 |
+
const nearNeighbor = labelIndices.some(other => {
|
| 470 |
+
if (other === index) return false;
|
| 471 |
+
return Math.abs(xScale(rows[other].date) - x) < Math.max(58, widthEstimate * 0.9);
|
| 472 |
+
});
|
| 473 |
+
const edgeLabel = index === 0 || index === rows.length - 1;
|
| 474 |
+
const bottomReserve = valueFont + (unitText ? unitFont + 5 : 0) + 8;
|
| 475 |
+
const enoughBelow = y < plotBottom - bottomReserve - labelFont - 16;
|
| 476 |
+
let side = !edgeLabel && rows.length > 8 && index % 2 === 0 && nearNeighbor && enoughBelow
|
| 477 |
+
? "below"
|
| 478 |
+
: "above";
|
| 479 |
+
const minYearY = panelY + labelFont + 18;
|
| 480 |
+
const maxYearY = plotBottom - bottomReserve;
|
| 481 |
+
const aboveClearance = valueFont + (unitText ? unitFont + 5 : 0) + 25;
|
| 482 |
+
let preferredYearY = side === "below"
|
| 483 |
+
? y + Math.max(28, labelFont + 13)
|
| 484 |
+
: y - Math.max(aboveClearance, panelH * 0.088);
|
| 485 |
+
const layout = {
|
| 486 |
+
index,
|
| 487 |
+
row,
|
| 488 |
+
pointX: x,
|
| 489 |
+
pointY: y,
|
| 490 |
+
x: Math.max(panelX + 38 + widthEstimate / 2, Math.min(panelX + panelW - 38 - widthEstimate / 2, x)),
|
| 491 |
+
yearY: Math.max(minYearY, Math.min(maxYearY, preferredYearY)),
|
| 492 |
+
side,
|
| 493 |
+
width: widthEstimate,
|
| 494 |
+
yearText,
|
| 495 |
+
valueText: labelValueText,
|
| 496 |
+
unitText,
|
| 497 |
+
canMoveBelow: enoughBelow,
|
| 498 |
+
minYearY,
|
| 499 |
+
maxYearY,
|
| 500 |
+
color: d3.interpolateRgb("#124AA5", "#249747")(progress)
|
| 501 |
+
};
|
| 502 |
+
updateLabelLines(layout);
|
| 503 |
+
if (layout.side === "above" && enoughBelow && boundsOverlap(layout.bounds, calloutAvoidBox, 0)) {
|
| 504 |
+
layout.side = "below";
|
| 505 |
+
preferredYearY = y + Math.max(28, labelFont + 13);
|
| 506 |
+
layout.yearY = Math.max(minYearY, Math.min(maxYearY, preferredYearY));
|
| 507 |
+
updateLabelLines(layout);
|
| 508 |
+
}
|
| 509 |
+
return layout;
|
| 510 |
+
};
|
| 511 |
+
const layoutGroups = { above: [], below: [] };
|
| 512 |
+
labelIndices.forEach(index => {
|
| 513 |
+
const layout = createLabelLayout(index);
|
| 514 |
+
labelLayouts.set(index, layout);
|
| 515 |
+
layoutGroups[layout.side].push(layout);
|
| 516 |
+
});
|
| 517 |
+
Object.values(layoutGroups).forEach(group => {
|
| 518 |
+
group.sort((a, b) => a.x - b.x);
|
| 519 |
+
group.forEach((layout, groupIndex) => {
|
| 520 |
+
if (groupIndex === 0) return;
|
| 521 |
+
const previous = group[groupIndex - 1];
|
| 522 |
+
if (!boundsOverlap(layout.bounds, previous.bounds, 5)) return;
|
| 523 |
+
|
| 524 |
+
const targetX = previous.bounds.right + 7 + layout.width / 2;
|
| 525 |
+
const maxX = panelX + panelW - 38 - layout.width / 2;
|
| 526 |
+
if (targetX <= maxX) {
|
| 527 |
+
layout.x = targetX;
|
| 528 |
+
} else {
|
| 529 |
+
const laneShift = Math.max(18, layout.bounds.bottom - layout.bounds.top + 7);
|
| 530 |
+
layout.yearY += layout.side === "above" ? -laneShift : laneShift;
|
| 531 |
+
const bottomReserve = valueFont + (layout.unitText ? unitFont + 5 : 0) + 8;
|
| 532 |
+
layout.yearY = Math.max(panelY + labelFont + 18, Math.min(plotBottom - bottomReserve, layout.yearY));
|
| 533 |
+
}
|
| 534 |
+
updateLabelLines(layout);
|
| 535 |
+
});
|
| 536 |
+
});
|
| 537 |
+
labelLayouts.forEach(layout => {
|
| 538 |
+
if (layout.side !== "above" || !layout.canMoveBelow || !boundsOverlap(layout.bounds, calloutAvoidBox, 0)) return;
|
| 539 |
+
layout.side = "below";
|
| 540 |
+
layout.yearY = Math.max(
|
| 541 |
+
layout.minYearY,
|
| 542 |
+
Math.min(layout.maxYearY, layout.pointY + Math.max(28, labelFont + 13))
|
| 543 |
+
);
|
| 544 |
+
updateLabelLines(layout);
|
| 545 |
+
});
|
| 546 |
+
|
| 547 |
+
rows.forEach((row, index) => {
|
| 548 |
+
const x = xScale(row.date);
|
| 549 |
+
const y = yScale(row.value);
|
| 550 |
+
const progress = rows.length === 1 ? 0 : index / (rows.length - 1);
|
| 551 |
+
const pointColor = d3.interpolateRgb(blue, green)(progress);
|
| 552 |
+
const point = pointLayer.append("g")
|
| 553 |
+
.attr("class", "area-point")
|
| 554 |
+
.attr("data-source-index", row.sourceIndex)
|
| 555 |
+
.attr("data-x", row.rawX)
|
| 556 |
+
.attr("data-value", row.value)
|
| 557 |
+
.attr("transform", `translate(${x}, ${y})`);
|
| 558 |
+
point.append("circle")
|
| 559 |
+
.attr("class", "area-point-outer")
|
| 560 |
+
.attr("r", rows.length <= 7 ? 8.6 : 7.2)
|
| 561 |
+
.attr("fill", "#FFFFFF")
|
| 562 |
+
.attr("stroke", "#DDE8F3")
|
| 563 |
+
.attr("stroke-width", 0.7);
|
| 564 |
+
point.append("circle")
|
| 565 |
+
.attr("class", "area-point-inner")
|
| 566 |
+
.attr("r", rows.length <= 7 ? 6.0 : 5.1)
|
| 567 |
+
.attr("fill", pointColor);
|
| 568 |
+
|
| 569 |
+
if (!labelIndexSet.has(index)) return;
|
| 570 |
+
|
| 571 |
+
const labelLayout = labelLayouts.get(index);
|
| 572 |
+
const labelGroup = root.append("g")
|
| 573 |
+
.attr("class", "point-label-group")
|
| 574 |
+
.attr("data-source-index", row.sourceIndex)
|
| 575 |
+
.attr("data-x", row.rawX)
|
| 576 |
+
.attr("data-value", row.value);
|
| 577 |
+
labelGroup.append("text")
|
| 578 |
+
.attr("class", "point-year-label chart-text")
|
| 579 |
+
.attr("x", labelLayout.x)
|
| 580 |
+
.attr("y", labelLayout.yearY)
|
| 581 |
+
.attr("text-anchor", "middle")
|
| 582 |
+
.attr("fill", labelLayout.color)
|
| 583 |
+
.attr("stroke", "#FFFFFF")
|
| 584 |
+
.attr("stroke-width", 2.2)
|
| 585 |
+
.attr("paint-order", "stroke fill")
|
| 586 |
+
.style("font-family", titleFontFamily)
|
| 587 |
+
.style("font-size", `${labelFont}px`)
|
| 588 |
+
.style("font-weight", "900")
|
| 589 |
+
.text(labelLayout.yearText);
|
| 590 |
+
labelGroup.append("text")
|
| 591 |
+
.attr("class", "point-value-label chart-text")
|
| 592 |
+
.attr("x", labelLayout.x)
|
| 593 |
+
.attr("y", labelLayout.valueY)
|
| 594 |
+
.attr("text-anchor", "middle")
|
| 595 |
+
.attr("fill", labelLayout.color)
|
| 596 |
+
.attr("stroke", "#FFFFFF")
|
| 597 |
+
.attr("stroke-width", 2.4)
|
| 598 |
+
.attr("paint-order", "stroke fill")
|
| 599 |
+
.style("font-family", titleFontFamily)
|
| 600 |
+
.style("font-size", `${valueFont}px`)
|
| 601 |
+
.style("font-weight", "900")
|
| 602 |
+
.attr("data-source-index", row.sourceIndex)
|
| 603 |
+
.attr("data-x", row.rawX)
|
| 604 |
+
.attr("data-value", row.value)
|
| 605 |
+
.text(labelLayout.valueText);
|
| 606 |
+
if (labelLayout.unitText) {
|
| 607 |
+
labelGroup.append("text")
|
| 608 |
+
.attr("class", "point-unit-label chart-text")
|
| 609 |
+
.attr("x", labelLayout.x)
|
| 610 |
+
.attr("y", labelLayout.unitY)
|
| 611 |
+
.attr("text-anchor", "middle")
|
| 612 |
+
.attr("fill", "#26394F")
|
| 613 |
+
.attr("stroke", "#FFFFFF")
|
| 614 |
+
.attr("stroke-width", 2)
|
| 615 |
+
.attr("paint-order", "stroke fill")
|
| 616 |
+
.style("font-family", fontFamily)
|
| 617 |
+
.style("font-size", `${unitFont}px`)
|
| 618 |
+
.style("font-weight", "700")
|
| 619 |
+
.attr("data-source-index", row.sourceIndex)
|
| 620 |
+
.attr("data-x", row.rawX)
|
| 621 |
+
.attr("data-value", row.value)
|
| 622 |
+
.text(labelLayout.unitText);
|
| 623 |
+
}
|
| 624 |
+
});
|
| 625 |
+
|
| 626 |
+
root.append("line")
|
| 627 |
+
.attr("class", "x-axis-line")
|
| 628 |
+
.attr("x1", plotX)
|
| 629 |
+
.attr("x2", plotRight)
|
| 630 |
+
.attr("y1", plotBottom)
|
| 631 |
+
.attr("y2", plotBottom)
|
| 632 |
+
.attr("stroke", "#9CAFC5")
|
| 633 |
+
.attr("stroke-width", 1.5);
|
| 634 |
+
root.append("line")
|
| 635 |
+
.attr("class", "y-axis-line")
|
| 636 |
+
.attr("x1", plotX)
|
| 637 |
+
.attr("x2", plotX)
|
| 638 |
+
.attr("y1", plotTop)
|
| 639 |
+
.attr("y2", plotBottom)
|
| 640 |
+
.attr("stroke", "#B8C7D7")
|
| 641 |
+
.attr("stroke-width", 1.2);
|
| 642 |
+
|
| 643 |
+
const xTickCount = rows.length <= 7 ? rows.length : 6;
|
| 644 |
+
const xTickDates = rows.length <= 7
|
| 645 |
+
? rows.map(row => row.date)
|
| 646 |
+
: xScale.ticks(xTickCount);
|
| 647 |
+
xTickDates.forEach(tick => {
|
| 648 |
+
const x = xScale(tick);
|
| 649 |
+
root.append("line")
|
| 650 |
+
.attr("class", "x-axis-tick")
|
| 651 |
+
.attr("x1", x)
|
| 652 |
+
.attr("x2", x)
|
| 653 |
+
.attr("y1", plotBottom)
|
| 654 |
+
.attr("y2", plotBottom + 5)
|
| 655 |
+
.attr("stroke", "#9CAFC5")
|
| 656 |
+
.attr("stroke-width", 1);
|
| 657 |
+
root.append("text")
|
| 658 |
+
.attr("class", "x-axis-label chart-text")
|
| 659 |
+
.attr("x", x)
|
| 660 |
+
.attr("y", plotBottom + 22)
|
| 661 |
+
.attr("text-anchor", "middle")
|
| 662 |
+
.attr("fill", "#0E2C6B")
|
| 663 |
+
.style("font-family", titleFontFamily)
|
| 664 |
+
.style("font-size", "11px")
|
| 665 |
+
.style("font-weight", "900")
|
| 666 |
+
.text(formatDateLabel(tick));
|
| 667 |
+
});
|
| 668 |
+
|
| 669 |
+
const callout = root.append("g")
|
| 670 |
+
.attr("class", "insight-callout")
|
| 671 |
+
.attr("transform", `translate(${calloutX}, ${calloutY})`);
|
| 672 |
+
callout.append("rect")
|
| 673 |
+
.attr("class", "insight-callout-card")
|
| 674 |
+
.attr("width", calloutW)
|
| 675 |
+
.attr("height", calloutH)
|
| 676 |
+
.attr("rx", 18)
|
| 677 |
+
.attr("fill", "#FFFFFF")
|
| 678 |
+
.attr("stroke", border)
|
| 679 |
+
.attr("filter", `url(#area-chart-01-shadow-${idSuffix})`);
|
| 680 |
+
const calloutIcon = callout.append("g").attr("class", "callout-icon-slot decorative-icon-slot");
|
| 681 |
+
calloutIcon.append("circle")
|
| 682 |
+
.attr("cx", 30)
|
| 683 |
+
.attr("cy", calloutH / 2)
|
| 684 |
+
.attr("r", 21)
|
| 685 |
+
.attr("fill", delta <= 0 ? green : blue);
|
| 686 |
+
calloutIcon.append("path")
|
| 687 |
+
.attr("d", delta <= 0 ? "M 24 25 L 30 32 L 38 20" : "M 23 34 L 30 25 L 38 31")
|
| 688 |
+
.attr("fill", "none")
|
| 689 |
+
.attr("stroke", "#FFFFFF")
|
| 690 |
+
.attr("stroke-width", 4)
|
| 691 |
+
.attr("stroke-linecap", "round")
|
| 692 |
+
.attr("stroke-linejoin", "round")
|
| 693 |
+
.attr("transform", `translate(0, ${calloutH / 2 - 28})`);
|
| 694 |
+
callout.append("text")
|
| 695 |
+
.attr("class", "callout-title chart-text")
|
| 696 |
+
.attr("x", 62)
|
| 697 |
+
.attr("y", calloutH / 2 - 5)
|
| 698 |
+
.attr("fill", textColor)
|
| 699 |
+
.style("font-family", titleFontFamily)
|
| 700 |
+
.style("font-size", "13px")
|
| 701 |
+
.style("font-weight", "900")
|
| 702 |
+
.text(delta <= 0 ? "Lower trend." : "Rising trend.");
|
| 703 |
+
callout.append("text")
|
| 704 |
+
.attr("class", "callout-subtitle chart-text")
|
| 705 |
+
.attr("x", 62)
|
| 706 |
+
.attr("y", calloutH / 2 + 13)
|
| 707 |
+
.attr("fill", mutedText)
|
| 708 |
+
.style("font-family", fontFamily)
|
| 709 |
+
.style("font-size", "11px")
|
| 710 |
+
.text(truncateText(`${formatLocalValue(firstPoint.value)} to ${formatLocalValue(lastPoint.value)}`, 28));
|
| 711 |
+
|
| 712 |
+
return svg.node();
|
| 713 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/area_chart_02.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Area Chart",
|
| 5 |
+
"chart_name": "area_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": 400,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes",
|
| 21 |
+
"chart_for": "comparison"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data || {};
|
| 28 |
+
const chartData = jsonData.data?.data || [];
|
| 29 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 30 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 31 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 32 |
+
const chartUtilsStandard = {
|
| 33 |
+
schema: chartUtils.schema,
|
| 34 |
+
format: chartUtils.format,
|
| 35 |
+
text: chartUtils.text,
|
| 36 |
+
color: chartUtils.color,
|
| 37 |
+
legendLayout: chartUtils.legend.layout,
|
| 38 |
+
legendDraw: chartUtils.legend.draw,
|
| 39 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 40 |
+
};
|
| 41 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 42 |
+
x: { fallbackIndex: 0 },
|
| 43 |
+
y: { fallbackIndex: 1 },
|
| 44 |
+
y2: { fallbackIndex: 2 },
|
| 45 |
+
y3: { fallbackIndex: 3 },
|
| 46 |
+
size: { fallbackIndex: 2 },
|
| 47 |
+
group: { fallbackIndex: 2 },
|
| 48 |
+
group2: { fallbackIndex: 3 },
|
| 49 |
+
group3: { fallbackIndex: 4 }
|
| 50 |
+
});
|
| 51 |
+
const variables = jsonData.variables || {};
|
| 52 |
+
const typography = jsonData.typography || {};
|
| 53 |
+
const sourceColors = jsonData.colors || {};
|
| 54 |
+
|
| 55 |
+
d3.select(containerSelector).html("");
|
| 56 |
+
|
| 57 |
+
const xColumn = dataColumns[0] || {};
|
| 58 |
+
const yColumn = dataColumns[1] || {};
|
| 59 |
+
const xField = xColumn.name;
|
| 60 |
+
const yField = yColumn.name;
|
| 61 |
+
|
| 62 |
+
const width = Math.max(820, Number(variables.width) || 820);
|
| 63 |
+
const height = Math.max(1180, Number(variables.height) || 1180);
|
| 64 |
+
const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
|
| 65 |
+
const titleFontFamily = typography.title?.font_family || fontFamily;
|
| 66 |
+
const textColor = sourceColors.text_color || "#101A33";
|
| 67 |
+
const mutedText = "#34425B";
|
| 68 |
+
const pageFill = "#FFFFFF";
|
| 69 |
+
const panelFill = "#FFFFFF";
|
| 70 |
+
const border = "#D4DCE8";
|
| 71 |
+
const red = sourceColors.other?.primary || sourceColors.other?.positive || "#B3121C";
|
| 72 |
+
const blue = sourceColors.other?.secondary || sourceColors.other?.negative || "#B3121C";
|
| 73 |
+
const neutralLine = "#7F8EA3";
|
| 74 |
+
const idSuffix = String(containerSelector || "area-chart-02")
|
| 75 |
+
.replace(/[^a-zA-Z0-9_-]/g, "")
|
| 76 |
+
.slice(0, 24) || "area02";
|
| 77 |
+
|
| 78 |
+
const svg = d3.select(containerSelector)
|
| 79 |
+
.append("svg")
|
| 80 |
+
.attr("width", "100%")
|
| 81 |
+
.attr("height", height)
|
| 82 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 83 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 84 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 85 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 86 |
+
.attr("class", "area-chart-02-reference-layout");
|
| 87 |
+
|
| 88 |
+
const addEmptyState = message => {
|
| 89 |
+
svg.append("rect")
|
| 90 |
+
.attr("width", width)
|
| 91 |
+
.attr("height", height)
|
| 92 |
+
.attr("fill", pageFill);
|
| 93 |
+
svg.append("text")
|
| 94 |
+
.attr("x", width / 2)
|
| 95 |
+
.attr("y", height / 2)
|
| 96 |
+
.attr("text-anchor", "middle")
|
| 97 |
+
.attr("fill", textColor)
|
| 98 |
+
.style("font-family", fontFamily)
|
| 99 |
+
.style("font-size", "16px")
|
| 100 |
+
.text(message);
|
| 101 |
+
return svg.node();
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
if (!xField || !yField) {
|
| 105 |
+
return addEmptyState("Missing area chart fields");
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const readableName = value => String(value ?? "")
|
| 109 |
+
.replace(/_/g, " ")
|
| 110 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 111 |
+
.replace(/\s+/g, " ")
|
| 112 |
+
.trim();
|
| 113 |
+
|
| 114 |
+
const wrapWords = (value, maxChars, maxLines = 2) => {
|
| 115 |
+
const words = String(value ?? "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
|
| 116 |
+
if (!words.length) return [""];
|
| 117 |
+
const lines = [];
|
| 118 |
+
let current = "";
|
| 119 |
+
words.forEach(word => {
|
| 120 |
+
const candidate = current ? `${current} ${word}` : word;
|
| 121 |
+
if (candidate.length <= maxChars || !current) {
|
| 122 |
+
current = candidate;
|
| 123 |
+
} else {
|
| 124 |
+
lines.push(current);
|
| 125 |
+
current = word;
|
| 126 |
+
}
|
| 127 |
+
});
|
| 128 |
+
if (current) lines.push(current);
|
| 129 |
+
if (lines.length <= maxLines) return lines;
|
| 130 |
+
return lines.slice(0, maxLines - 1).concat(lines.slice(maxLines - 1).join(" "));
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const parseTimeValue = value => {
|
| 134 |
+
const raw = String(value ?? "").trim();
|
| 135 |
+
if (!raw) return null;
|
| 136 |
+
if (/^-?\d{1,6}$/.test(raw)) return new Date(Number(raw), 0, 1);
|
| 137 |
+
{
|
| 138 |
+
const parsedByHelper = chartUtils.format.parseDate(value);
|
| 139 |
+
if (parsedByHelper instanceof Date && Number.isFinite(parsedByHelper.getTime())) return parsedByHelper;
|
| 140 |
+
}
|
| 141 |
+
const parsed = Date.parse(raw);
|
| 142 |
+
if (Number.isFinite(parsed)) return new Date(parsed);
|
| 143 |
+
return null;
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
const unitRaw = String(yColumn.unit || "").trim();
|
| 147 |
+
const unit = unitRaw.toLowerCase() === "none" ? "" : unitRaw;
|
| 148 |
+
const valueBaseTitle = readableName(yColumn.label || yColumn.description || yField);
|
| 149 |
+
const axisDescriptor = readableName(yColumn.description || yColumn.label || yField);
|
| 150 |
+
let axisTitle = `${readableName(yField).toUpperCase()}${unit ? ` (${unit})` : ""}`;
|
| 151 |
+
if (/trust/i.test(axisDescriptor) && /\b0\s*-\s*100\b/.test(axisDescriptor)) {
|
| 152 |
+
axisTitle = "PUBLIC TRUST INDEX (0-100)";
|
| 153 |
+
} else if (axisDescriptor && axisDescriptor.length <= 34) {
|
| 154 |
+
axisTitle = `${axisDescriptor.toUpperCase()}${unit ? ` (${unit})` : ""}`;
|
| 155 |
+
}
|
| 156 |
+
const formatNumber = value => {
|
| 157 |
+
const numberValue = Number(value);
|
| 158 |
+
if (!Number.isFinite(numberValue)) return String(value ?? "");
|
| 159 |
+
const abs = Math.abs(numberValue);
|
| 160 |
+
const spec = abs >= 1000 ? ".0f" : abs >= 100 ? ".1f" : abs >= 10 ? ".1f" : abs >= 1 ? ".2f" : ".3f";
|
| 161 |
+
return d3.format(spec)(numberValue)
|
| 162 |
+
.replace(/\.0$/, "")
|
| 163 |
+
.replace(/(\.\d*?)0+$/, "$1")
|
| 164 |
+
.replace(/\.$/, "");
|
| 165 |
+
};
|
| 166 |
+
const formatLocalValue = value => {
|
| 167 |
+
const formatted = formatNumber(value);
|
| 168 |
+
if (!unit) return formatted;
|
| 169 |
+
if (unit === "%") return `${formatted}%`;
|
| 170 |
+
if (unit === "$" || unit.toLowerCase() === "usd") return `$${formatted}`;
|
| 171 |
+
if (unit.startsWith("$")) return `$${formatted}${unit.slice(1)}`;
|
| 172 |
+
return `${formatted} ${unit}`;
|
| 173 |
+
};
|
| 174 |
+
const formatCalloutValue = value => {
|
| 175 |
+
const numberValue = Number(value);
|
| 176 |
+
if (!Number.isFinite(numberValue)) return String(value ?? "");
|
| 177 |
+
const formatted = d3.format(".2f")(numberValue);
|
| 178 |
+
if (!unit) return formatted;
|
| 179 |
+
if (unit === "%") return `${formatted}%`;
|
| 180 |
+
if (unit === "$" || unit.toLowerCase() === "usd") return `$${formatted}`;
|
| 181 |
+
if (unit.startsWith("$")) return `$${formatted}${unit.slice(1)}`;
|
| 182 |
+
return `${formatted} ${unit}`;
|
| 183 |
+
};
|
| 184 |
+
const formatDateLabel = date => {
|
| 185 |
+
if (!(date instanceof Date) || !Number.isFinite(date.getTime())) return "";
|
| 186 |
+
return String(date.getFullYear());
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
const rows = chartData
|
| 190 |
+
.map((row, sourceIndex) => {
|
| 191 |
+
const date = parseTimeValue(row[xField]);
|
| 192 |
+
const value = Number(row[yField]);
|
| 193 |
+
return {
|
| 194 |
+
rawX: String(row[xField] ?? ""),
|
| 195 |
+
date,
|
| 196 |
+
value,
|
| 197 |
+
sourceIndex
|
| 198 |
+
};
|
| 199 |
+
})
|
| 200 |
+
.filter(row => row.date && Number.isFinite(row.date.getTime()) && Number.isFinite(row.value))
|
| 201 |
+
.sort((a, b) => a.date - b.date || a.sourceIndex - b.sourceIndex);
|
| 202 |
+
|
| 203 |
+
if (rows.length < 2) {
|
| 204 |
+
return addEmptyState("Area chart needs at least two valid points");
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
const processedRows = [];
|
| 208 |
+
rows.forEach((row, index) => {
|
| 209 |
+
processedRows.push(row);
|
| 210 |
+
if (index >= rows.length - 1) return;
|
| 211 |
+
const next = rows[index + 1];
|
| 212 |
+
if (!((row.value >= 0 && next.value < 0) || (row.value < 0 && next.value >= 0))) return;
|
| 213 |
+
const ratio = Math.abs(row.value) / (Math.abs(row.value) + Math.abs(next.value));
|
| 214 |
+
const zeroTime = new Date(row.date.getTime() + ratio * (next.date.getTime() - row.date.getTime()));
|
| 215 |
+
processedRows.push({
|
| 216 |
+
rawX: `${row.rawX}-${next.rawX}`,
|
| 217 |
+
date: zeroTime,
|
| 218 |
+
value: 0,
|
| 219 |
+
sourceIndex: `zero-${index}`,
|
| 220 |
+
isZeroPoint: true
|
| 221 |
+
});
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
const minObserved = d3.min(rows, d => d.value) ?? 0;
|
| 225 |
+
const maxObserved = d3.max(rows, d => d.value) ?? 1;
|
| 226 |
+
const minValue = Math.min(0, minObserved);
|
| 227 |
+
const maxValue = Math.max(0, maxObserved);
|
| 228 |
+
const valueRange = Math.max(1, maxValue - minValue);
|
| 229 |
+
const yPadding = Math.max(valueRange * 0.08, 1);
|
| 230 |
+
const yDomain = [minValue - yPadding, maxValue + yPadding];
|
| 231 |
+
if (minObserved >= 0) yDomain[0] = 0;
|
| 232 |
+
if (maxObserved <= 0) yDomain[1] = 0;
|
| 233 |
+
|
| 234 |
+
const firstPoint = rows[0];
|
| 235 |
+
const lastPoint = rows[rows.length - 1];
|
| 236 |
+
const peakPoint = rows.reduce((max, row) => row.value > max.value ? row : max, rows[0]);
|
| 237 |
+
const troughPoint = rows.reduce((min, row) => row.value < min.value ? row : min, rows[0]);
|
| 238 |
+
const delta = lastPoint.value - firstPoint.value;
|
| 239 |
+
const trendColor = delta < 0 ? red : blue;
|
| 240 |
+
const trendWord = delta < 0 ? "declined" : delta > 0 ? "rebounded" : "held steady";
|
| 241 |
+
|
| 242 |
+
const panelX = Math.max(18, width * 0.018);
|
| 243 |
+
const panelY = Math.max(8, height * 0.012);
|
| 244 |
+
const panelW = width - panelX * 2;
|
| 245 |
+
const panelH = height - panelY * 2;
|
| 246 |
+
const plotX = panelX + Math.max(62, panelW * 0.075);
|
| 247 |
+
const plotRight = panelX + panelW - Math.max(12, panelW * 0.016);
|
| 248 |
+
const plotTop = panelY + Math.max(58, panelH * 0.082);
|
| 249 |
+
const plotBottom = panelY + panelH - Math.max(58, panelH * 0.082);
|
| 250 |
+
const plotW = Math.max(160, plotRight - plotX);
|
| 251 |
+
const plotH = Math.max(160, plotBottom - plotTop);
|
| 252 |
+
|
| 253 |
+
const xScale = d3.scaleTime()
|
| 254 |
+
.domain(d3.extent(rows, d => d.date))
|
| 255 |
+
.range([plotX, plotRight]);
|
| 256 |
+
const yScale = d3.scaleLinear()
|
| 257 |
+
.domain(yDomain)
|
| 258 |
+
.nice(5)
|
| 259 |
+
.range([plotBottom, plotTop]);
|
| 260 |
+
const zeroY = yScale(0);
|
| 261 |
+
|
| 262 |
+
const defs = svg.append("defs");
|
| 263 |
+
const shadow = defs.append("filter")
|
| 264 |
+
.attr("id", `area-chart-02-shadow-${idSuffix}`)
|
| 265 |
+
.attr("x", "-12%")
|
| 266 |
+
.attr("y", "-12%")
|
| 267 |
+
.attr("width", "124%")
|
| 268 |
+
.attr("height", "124%");
|
| 269 |
+
shadow.append("feDropShadow")
|
| 270 |
+
.attr("dx", 0)
|
| 271 |
+
.attr("dy", 8)
|
| 272 |
+
.attr("stdDeviation", 9)
|
| 273 |
+
.attr("flood-color", "#B9C2CD")
|
| 274 |
+
.attr("flood-opacity", 0.20);
|
| 275 |
+
|
| 276 |
+
const positiveGradientId = `area-chart-02-positive-${idSuffix}`;
|
| 277 |
+
const positiveGradient = defs.append("linearGradient")
|
| 278 |
+
.attr("id", positiveGradientId)
|
| 279 |
+
.attr("gradientUnits", "userSpaceOnUse")
|
| 280 |
+
.attr("x1", 0)
|
| 281 |
+
.attr("x2", 0)
|
| 282 |
+
.attr("y1", plotTop)
|
| 283 |
+
.attr("y2", zeroY);
|
| 284 |
+
positiveGradient.append("stop").attr("offset", "0%").attr("stop-color", red).attr("stop-opacity", 0.98);
|
| 285 |
+
positiveGradient.append("stop").attr("offset", "54%").attr("stop-color", red).attr("stop-opacity", 0.74);
|
| 286 |
+
positiveGradient.append("stop").attr("offset", "100%").attr("stop-color", red).attr("stop-opacity", 0.08);
|
| 287 |
+
|
| 288 |
+
const negativeGradientId = `area-chart-02-negative-${idSuffix}`;
|
| 289 |
+
const negativeGradient = defs.append("linearGradient")
|
| 290 |
+
.attr("id", negativeGradientId)
|
| 291 |
+
.attr("gradientUnits", "userSpaceOnUse")
|
| 292 |
+
.attr("x1", 0)
|
| 293 |
+
.attr("x2", 0)
|
| 294 |
+
.attr("y1", zeroY)
|
| 295 |
+
.attr("y2", plotBottom);
|
| 296 |
+
negativeGradient.append("stop").attr("offset", "0%").attr("stop-color", red).attr("stop-opacity", 0.18);
|
| 297 |
+
negativeGradient.append("stop").attr("offset", "100%").attr("stop-color", red).attr("stop-opacity", 0.74);
|
| 298 |
+
|
| 299 |
+
const clipId = `area-chart-02-clip-${idSuffix}`;
|
| 300 |
+
defs.append("clipPath")
|
| 301 |
+
.attr("id", clipId)
|
| 302 |
+
.append("rect")
|
| 303 |
+
.attr("x", plotX)
|
| 304 |
+
.attr("y", plotTop)
|
| 305 |
+
.attr("width", plotW)
|
| 306 |
+
.attr("height", plotH);
|
| 307 |
+
|
| 308 |
+
const redArea = d3.area()
|
| 309 |
+
.x(d => xScale(d.date))
|
| 310 |
+
.y0(zeroY)
|
| 311 |
+
.y1(d => yScale(d.value))
|
| 312 |
+
.curve(d3.curveMonotoneX);
|
| 313 |
+
const dataLine = d3.line()
|
| 314 |
+
.x(d => xScale(d.date))
|
| 315 |
+
.y(d => yScale(d.value))
|
| 316 |
+
.curve(d3.curveMonotoneX);
|
| 317 |
+
|
| 318 |
+
const root = svg.append("g").attr("class", "area-chart-02-reference-panel");
|
| 319 |
+
root.append("rect")
|
| 320 |
+
.attr("class", "chart-style-background")
|
| 321 |
+
.attr("width", width)
|
| 322 |
+
.attr("height", height)
|
| 323 |
+
.attr("fill", pageFill)
|
| 324 |
+
.attr("fill-opacity", 0);
|
| 325 |
+
root.append("rect")
|
| 326 |
+
.attr("class", "chart-style-panel chart-card-panel")
|
| 327 |
+
.attr("x", panelX)
|
| 328 |
+
.attr("y", panelY)
|
| 329 |
+
.attr("width", panelW)
|
| 330 |
+
.attr("height", panelH)
|
| 331 |
+
.attr("rx", 0)
|
| 332 |
+
.attr("fill", panelFill)
|
| 333 |
+
.attr("fill-opacity", 0)
|
| 334 |
+
.attr("stroke", "none");
|
| 335 |
+
|
| 336 |
+
root.append("text")
|
| 337 |
+
.attr("class", "chart-axis-caption chart-text")
|
| 338 |
+
.attr("x", plotX)
|
| 339 |
+
.attr("y", plotTop - 22)
|
| 340 |
+
.attr("fill", mutedText)
|
| 341 |
+
.style("font-family", titleFontFamily)
|
| 342 |
+
.style("font-size", "16px")
|
| 343 |
+
.style("font-weight", "900")
|
| 344 |
+
.style("letter-spacing", "0px")
|
| 345 |
+
.text(axisTitle);
|
| 346 |
+
|
| 347 |
+
const niceYDomain = yScale.domain();
|
| 348 |
+
const yTicks = yScale.ticks(6).filter(tick => tick >= niceYDomain[0] && tick <= niceYDomain[1]);
|
| 349 |
+
const gridLayer = root.append("g").attr("class", "plot-grid-layer");
|
| 350 |
+
yTicks.forEach(tick => {
|
| 351 |
+
const y = yScale(tick);
|
| 352 |
+
gridLayer.append("line")
|
| 353 |
+
.attr("class", "y-grid-line")
|
| 354 |
+
.attr("x1", plotX)
|
| 355 |
+
.attr("x2", plotRight)
|
| 356 |
+
.attr("y1", y)
|
| 357 |
+
.attr("y2", y)
|
| 358 |
+
.attr("stroke", tick === 0 ? "#AEB9C8" : "#C9D0DC")
|
| 359 |
+
.attr("stroke-width", tick === 0 ? 1.2 : 1)
|
| 360 |
+
.attr("stroke-dasharray", tick === 0 ? null : "6 7")
|
| 361 |
+
.attr("opacity", tick === 0 ? 0.9 : 0.72);
|
| 362 |
+
gridLayer.append("text")
|
| 363 |
+
.attr("class", "y-axis-tick chart-text")
|
| 364 |
+
.attr("x", plotX - 22)
|
| 365 |
+
.attr("y", y + 6)
|
| 366 |
+
.attr("text-anchor", "end")
|
| 367 |
+
.attr("fill", "#31405A")
|
| 368 |
+
.style("font-family", fontFamily)
|
| 369 |
+
.style("font-size", "18px")
|
| 370 |
+
.style("font-weight", "700")
|
| 371 |
+
.text(formatNumber(tick));
|
| 372 |
+
});
|
| 373 |
+
|
| 374 |
+
const plotLayer = root.append("g")
|
| 375 |
+
.attr("class", "area-plot-layer")
|
| 376 |
+
.attr("clip-path", `url(#${clipId})`);
|
| 377 |
+
plotLayer.append("path")
|
| 378 |
+
.datum(rows)
|
| 379 |
+
.attr("class", "area-fill data-area")
|
| 380 |
+
.attr("d", redArea)
|
| 381 |
+
.attr("fill", `url(#${positiveGradientId})`)
|
| 382 |
+
.attr("data-point-count", rows.length)
|
| 383 |
+
.attr("data-first-x", firstPoint.rawX)
|
| 384 |
+
.attr("data-first-value", firstPoint.value)
|
| 385 |
+
.attr("data-last-x", lastPoint.rawX)
|
| 386 |
+
.attr("data-last-value", lastPoint.value)
|
| 387 |
+
.attr("data-min-x", troughPoint.rawX)
|
| 388 |
+
.attr("data-min-value", troughPoint.value)
|
| 389 |
+
.attr("data-max-x", peakPoint.rawX)
|
| 390 |
+
.attr("data-max-value", peakPoint.value);
|
| 391 |
+
plotLayer.append("path")
|
| 392 |
+
.datum(rows)
|
| 393 |
+
.attr("class", "area-line-underlay")
|
| 394 |
+
.attr("d", dataLine)
|
| 395 |
+
.attr("fill", "none")
|
| 396 |
+
.attr("stroke", "#FFFFFF")
|
| 397 |
+
.attr("stroke-width", 4.8)
|
| 398 |
+
.attr("stroke-linejoin", "round")
|
| 399 |
+
.attr("stroke-linecap", "round")
|
| 400 |
+
.attr("opacity", 0.94);
|
| 401 |
+
plotLayer.append("path")
|
| 402 |
+
.datum(rows)
|
| 403 |
+
.attr("class", "area-line data-line")
|
| 404 |
+
.attr("d", dataLine)
|
| 405 |
+
.attr("fill", "none")
|
| 406 |
+
.attr("stroke", red)
|
| 407 |
+
.attr("stroke-width", 3.6)
|
| 408 |
+
.attr("stroke-linejoin", "round")
|
| 409 |
+
.attr("stroke-linecap", "round");
|
| 410 |
+
|
| 411 |
+
root.append("line")
|
| 412 |
+
.attr("class", "zero-line")
|
| 413 |
+
.attr("x1", plotX)
|
| 414 |
+
.attr("x2", plotRight)
|
| 415 |
+
.attr("y1", zeroY)
|
| 416 |
+
.attr("y2", zeroY)
|
| 417 |
+
.attr("stroke", neutralLine)
|
| 418 |
+
.attr("stroke-width", 1.2);
|
| 419 |
+
root.append("line")
|
| 420 |
+
.attr("class", "x-axis-line")
|
| 421 |
+
.attr("x1", plotX)
|
| 422 |
+
.attr("x2", plotRight)
|
| 423 |
+
.attr("y1", plotBottom)
|
| 424 |
+
.attr("y2", plotBottom)
|
| 425 |
+
.attr("stroke", "#BBC6D3")
|
| 426 |
+
.attr("stroke-width", 1.1);
|
| 427 |
+
root.append("line")
|
| 428 |
+
.attr("class", "y-axis-line")
|
| 429 |
+
.attr("x1", plotX)
|
| 430 |
+
.attr("x2", plotX)
|
| 431 |
+
.attr("y1", plotTop)
|
| 432 |
+
.attr("y2", plotBottom)
|
| 433 |
+
.attr("stroke", "#BBC6D3")
|
| 434 |
+
.attr("stroke-width", 1.1);
|
| 435 |
+
|
| 436 |
+
const tickMap = new Map();
|
| 437 |
+
xScale.ticks(6).concat([firstPoint.date, lastPoint.date]).forEach(tick => {
|
| 438 |
+
tickMap.set(formatDateLabel(tick), tick);
|
| 439 |
+
});
|
| 440 |
+
const xTickDates = Array.from(tickMap.values()).sort((a, b) => a - b);
|
| 441 |
+
xTickDates.forEach(tick => {
|
| 442 |
+
const x = xScale(tick);
|
| 443 |
+
root.append("text")
|
| 444 |
+
.attr("class", "x-axis-label chart-text")
|
| 445 |
+
.attr("x", x)
|
| 446 |
+
.attr("y", plotBottom + 34)
|
| 447 |
+
.attr("text-anchor", "middle")
|
| 448 |
+
.attr("fill", "#26394F")
|
| 449 |
+
.style("font-family", fontFamily)
|
| 450 |
+
.style("font-size", "17px")
|
| 451 |
+
.style("font-weight", "600")
|
| 452 |
+
.text(formatDateLabel(tick));
|
| 453 |
+
});
|
| 454 |
+
|
| 455 |
+
const keyPointSourceIndices = new Set([firstPoint, troughPoint, lastPoint].map(row => String(row.sourceIndex)));
|
| 456 |
+
const pointLayer = root.append("g").attr("class", "area-data-points");
|
| 457 |
+
rows.forEach(row => {
|
| 458 |
+
if (!keyPointSourceIndices.has(String(row.sourceIndex))) return;
|
| 459 |
+
const x = xScale(row.date);
|
| 460 |
+
const y = yScale(row.value);
|
| 461 |
+
const pointColor = red;
|
| 462 |
+
const point = pointLayer.append("g")
|
| 463 |
+
.attr("class", "area-point")
|
| 464 |
+
.attr("data-source-index", row.sourceIndex)
|
| 465 |
+
.attr("data-x", row.rawX)
|
| 466 |
+
.attr("data-value", row.value)
|
| 467 |
+
.attr("transform", `translate(${x}, ${y})`);
|
| 468 |
+
point.append("circle")
|
| 469 |
+
.attr("class", "area-point-outer")
|
| 470 |
+
.attr("r", 7.2)
|
| 471 |
+
.attr("fill", "#FFFFFF")
|
| 472 |
+
.attr("stroke", pointColor)
|
| 473 |
+
.attr("stroke-width", 2.3);
|
| 474 |
+
point.append("circle")
|
| 475 |
+
.attr("class", "area-point-inner")
|
| 476 |
+
.attr("r", 4.2)
|
| 477 |
+
.attr("fill", pointColor);
|
| 478 |
+
});
|
| 479 |
+
|
| 480 |
+
const labelRows = [];
|
| 481 |
+
const addLabelRow = (row, role) => {
|
| 482 |
+
if (labelRows.some(item => item.row.sourceIndex === row.sourceIndex)) return;
|
| 483 |
+
labelRows.push({ row, role });
|
| 484 |
+
};
|
| 485 |
+
addLabelRow(firstPoint, "first");
|
| 486 |
+
addLabelRow(troughPoint, "min");
|
| 487 |
+
addLabelRow(lastPoint, "last");
|
| 488 |
+
labelRows.sort((a, b) => a.row.date - b.row.date);
|
| 489 |
+
|
| 490 |
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
| 491 |
+
const boxFrom = (x, y, w, h) => ({ left: x, right: x + w, top: y, bottom: y + h, width: w, height: h });
|
| 492 |
+
const boxesOverlap = (a, b, pad = 0) => !(a.right + pad <= b.left || b.right + pad <= a.left || a.bottom + pad <= b.top || b.bottom + pad <= a.top);
|
| 493 |
+
const placedLabelBoxes = [];
|
| 494 |
+
|
| 495 |
+
const labelLayer = root.append("g").attr("class", "key-point-labels");
|
| 496 |
+
labelRows.forEach((item, index) => {
|
| 497 |
+
const row = item.row;
|
| 498 |
+
const x = xScale(row.date);
|
| 499 |
+
const y = yScale(row.value);
|
| 500 |
+
const valueText = formatCalloutValue(row.value);
|
| 501 |
+
const yearText = formatDateLabel(row.date);
|
| 502 |
+
const isMin = item.role === "min";
|
| 503 |
+
const labelW = Math.max(72, valueText.length * 13 + 28);
|
| 504 |
+
const labelH = isMin ? 42 : 40;
|
| 505 |
+
const referenceOffsets = isMin
|
| 506 |
+
? [{ dx: -labelW - 14, dy: -54 }, { dx: -labelW / 2, dy: -62 }, { dx: 14, dy: -56 }, { dx: -labelW - 8, dy: 20 }]
|
| 507 |
+
: item.role === "first"
|
| 508 |
+
? [{ dx: 14, dy: -52 }, { dx: 6, dy: -46 }, { dx: -labelW / 2, dy: -50 }]
|
| 509 |
+
: [{ dx: -labelW + 8, dy: -52 }, { dx: -labelW - 6, dy: -46 }, { dx: -labelW / 2, dy: -50 }];
|
| 510 |
+
let best = { tx: clamp(x + referenceOffsets[0].dx, plotX + 4, plotRight - labelW - 4), ty: clamp(y + referenceOffsets[0].dy, plotTop + 4, plotBottom - labelH - 4), score: Number.POSITIVE_INFINITY };
|
| 511 |
+
referenceOffsets.forEach(offset => {
|
| 512 |
+
const candidateX = clamp(x + offset.dx, plotX + 4, plotRight - labelW - 4);
|
| 513 |
+
const candidateY = clamp(y + offset.dy, plotTop + 4, plotBottom - labelH - 4);
|
| 514 |
+
const box = boxFrom(candidateX, candidateY, labelW, labelH);
|
| 515 |
+
const overlapPenalty = placedLabelBoxes.reduce((sum, placed) => sum + (boxesOverlap(box, placed, 5) ? 60000 : 0), 0);
|
| 516 |
+
const pointDx = Math.abs(candidateX + labelW / 2 - x);
|
| 517 |
+
const pointDy = Math.abs(candidateY + labelH / 2 - y);
|
| 518 |
+
const rolePenalty = isMin ? Math.abs(candidateY + labelH - (y - 18)) : Math.abs(candidateY + labelH - (y - 14));
|
| 519 |
+
const score = overlapPenalty + pointDx * 0.45 + pointDy * 0.15 + rolePenalty;
|
| 520 |
+
if (score < best.score) best = { tx: candidateX, ty: candidateY, score };
|
| 521 |
+
});
|
| 522 |
+
const tx = best.tx;
|
| 523 |
+
const ty = best.ty;
|
| 524 |
+
placedLabelBoxes.push(boxFrom(tx, ty, labelW, labelH));
|
| 525 |
+
const fillColor = isMin ? "#F8FAFD" : red;
|
| 526 |
+
const strokeColor = isMin ? "#25314A" : red;
|
| 527 |
+
const textFill = isMin ? "#25314A" : "#FFFFFF";
|
| 528 |
+
const label = labelLayer.append("g")
|
| 529 |
+
.attr("class", `key-point-label ${item.role}-point-label`)
|
| 530 |
+
.attr("data-source-index", row.sourceIndex)
|
| 531 |
+
.attr("data-x", row.rawX)
|
| 532 |
+
.attr("data-value", row.value)
|
| 533 |
+
.attr("transform", `translate(${tx}, ${ty})`);
|
| 534 |
+
|
| 535 |
+
if (isMin) {
|
| 536 |
+
const leaderStartX = x - tx;
|
| 537 |
+
const leaderStartY = y - ty;
|
| 538 |
+
const leaderEndX = labelW * 0.78;
|
| 539 |
+
const leaderEndY = labelH;
|
| 540 |
+
label.append("path")
|
| 541 |
+
.attr("class", "key-point-leader")
|
| 542 |
+
.attr("d", `M ${leaderEndX} ${leaderEndY} L ${leaderStartX} ${leaderStartY}`)
|
| 543 |
+
.attr("fill", "none")
|
| 544 |
+
.attr("stroke", strokeColor)
|
| 545 |
+
.attr("stroke-width", 1.4)
|
| 546 |
+
.attr("opacity", 0.92);
|
| 547 |
+
} else {
|
| 548 |
+
const notchX = clamp(x - tx, 12, labelW - 12);
|
| 549 |
+
label.append("path")
|
| 550 |
+
.attr("class", "key-point-label-notch")
|
| 551 |
+
.attr("d", `M ${notchX - 7} ${labelH - 1} L ${notchX} ${labelH + 10} L ${notchX + 7} ${labelH - 1} Z`)
|
| 552 |
+
.attr("fill", fillColor);
|
| 553 |
+
}
|
| 554 |
+
label.append("rect")
|
| 555 |
+
.attr("class", "key-point-label-bg")
|
| 556 |
+
.attr("width", labelW)
|
| 557 |
+
.attr("height", labelH)
|
| 558 |
+
.attr("rx", isMin ? 7 : 6)
|
| 559 |
+
.attr("fill", fillColor)
|
| 560 |
+
.attr("stroke", strokeColor)
|
| 561 |
+
.attr("stroke-width", isMin ? 1.4 : 0)
|
| 562 |
+
.attr("opacity", 0.98);
|
| 563 |
+
label.append("text")
|
| 564 |
+
.attr("class", "key-point-value chart-text")
|
| 565 |
+
.attr("x", labelW / 2)
|
| 566 |
+
.attr("y", isMin ? 27 : 27)
|
| 567 |
+
.attr("text-anchor", "middle")
|
| 568 |
+
.attr("fill", textFill)
|
| 569 |
+
.style("font-family", titleFontFamily)
|
| 570 |
+
.style("font-size", "21px")
|
| 571 |
+
.style("font-weight", "900")
|
| 572 |
+
.text(valueText);
|
| 573 |
+
});
|
| 574 |
+
|
| 575 |
+
return svg.node();
|
| 576 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/area_chart_03.js
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Area Chart",
|
| 5 |
+
"chart_name": "area_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": 400,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes",
|
| 21 |
+
"chart_for": "trend"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data || {};
|
| 28 |
+
const chartData = jsonData.data?.data || [];
|
| 29 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 30 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 31 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 32 |
+
const chartUtilsStandard = {
|
| 33 |
+
schema: chartUtils.schema,
|
| 34 |
+
format: chartUtils.format,
|
| 35 |
+
text: chartUtils.text,
|
| 36 |
+
color: chartUtils.color,
|
| 37 |
+
legendLayout: chartUtils.legend.layout,
|
| 38 |
+
legendDraw: chartUtils.legend.draw,
|
| 39 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 40 |
+
};
|
| 41 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 42 |
+
x: { fallbackIndex: 0 },
|
| 43 |
+
y: { fallbackIndex: 1 },
|
| 44 |
+
y2: { fallbackIndex: 2 },
|
| 45 |
+
y3: { fallbackIndex: 3 },
|
| 46 |
+
size: { fallbackIndex: 2 },
|
| 47 |
+
group: { fallbackIndex: 2 },
|
| 48 |
+
group2: { fallbackIndex: 3 },
|
| 49 |
+
group3: { fallbackIndex: 4 }
|
| 50 |
+
});
|
| 51 |
+
const variables = jsonData.variables || {};
|
| 52 |
+
const typography = jsonData.typography || {};
|
| 53 |
+
const sourceColors = jsonData.colors || {};
|
| 54 |
+
|
| 55 |
+
d3.select(containerSelector).html("");
|
| 56 |
+
|
| 57 |
+
const xColumn = dataColumns[0] || {};
|
| 58 |
+
const yColumn = dataColumns[1] || {};
|
| 59 |
+
const xField = xColumn.name;
|
| 60 |
+
const yField = yColumn.name;
|
| 61 |
+
|
| 62 |
+
const width = Math.max(900, Number(variables.width) || 900);
|
| 63 |
+
const height = Math.max(1180, Number(variables.height) || 1180);
|
| 64 |
+
const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
|
| 65 |
+
const titleFontFamily = typography.title?.font_family || fontFamily;
|
| 66 |
+
const textColor = sourceColors.text_color || "#27313D";
|
| 67 |
+
const pageFill = "#F7FAFC";
|
| 68 |
+
const panelFill = "#FFFFFF";
|
| 69 |
+
const idSuffix = String(containerSelector || "area-chart-03")
|
| 70 |
+
.replace(/[^a-zA-Z0-9_-]/g, "")
|
| 71 |
+
.slice(0, 24) || "area03";
|
| 72 |
+
|
| 73 |
+
const svg = d3.select(containerSelector)
|
| 74 |
+
.append("svg")
|
| 75 |
+
.attr("width", "100%")
|
| 76 |
+
.attr("height", height)
|
| 77 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 78 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 79 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 80 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 81 |
+
.attr("class", "area-chart-03-reference-layout");
|
| 82 |
+
|
| 83 |
+
const addEmptyState = message => {
|
| 84 |
+
svg.append("rect")
|
| 85 |
+
.attr("width", width)
|
| 86 |
+
.attr("height", height)
|
| 87 |
+
.attr("fill", pageFill);
|
| 88 |
+
svg.append("text")
|
| 89 |
+
.attr("x", width / 2)
|
| 90 |
+
.attr("y", height / 2)
|
| 91 |
+
.attr("text-anchor", "middle")
|
| 92 |
+
.attr("fill", textColor)
|
| 93 |
+
.style("font-family", fontFamily)
|
| 94 |
+
.style("font-size", "16px")
|
| 95 |
+
.text(message);
|
| 96 |
+
return svg.node();
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
if (!xField || !yField) {
|
| 100 |
+
return addEmptyState("Missing area chart fields");
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const readableName = value => String(value ?? "")
|
| 104 |
+
.replace(/_/g, " ")
|
| 105 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 106 |
+
.replace(/\s+/g, " ")
|
| 107 |
+
.trim();
|
| 108 |
+
|
| 109 |
+
const shortText = (value, maxChars = 32) => {
|
| 110 |
+
const clean = readableName(value);
|
| 111 |
+
if (clean.length <= maxChars) return clean;
|
| 112 |
+
return `${clean.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const wrapWords = (value, maxChars, maxLines = 2) => {
|
| 116 |
+
const words = String(value ?? "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
|
| 117 |
+
if (!words.length) return [""];
|
| 118 |
+
const lines = [];
|
| 119 |
+
let current = "";
|
| 120 |
+
words.forEach(word => {
|
| 121 |
+
const candidate = current ? `${current} ${word}` : word;
|
| 122 |
+
if (candidate.length <= maxChars || !current) {
|
| 123 |
+
current = candidate;
|
| 124 |
+
} else {
|
| 125 |
+
lines.push(current);
|
| 126 |
+
current = word;
|
| 127 |
+
}
|
| 128 |
+
});
|
| 129 |
+
if (current) lines.push(current);
|
| 130 |
+
if (lines.length <= maxLines) return lines;
|
| 131 |
+
return lines.slice(0, maxLines - 1).concat(lines.slice(maxLines - 1).join(" "));
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
const parseTimeValue = value => {
|
| 135 |
+
const raw = String(value ?? "").trim();
|
| 136 |
+
if (!raw) return null;
|
| 137 |
+
if (/^-?\d{1,6}$/.test(raw)) return new Date(Number(raw), 0, 1);
|
| 138 |
+
{
|
| 139 |
+
const parsedByHelper = chartUtils.format.parseDate(value);
|
| 140 |
+
if (parsedByHelper instanceof Date && Number.isFinite(parsedByHelper.getTime())) return parsedByHelper;
|
| 141 |
+
}
|
| 142 |
+
const parsed = Date.parse(raw);
|
| 143 |
+
if (Number.isFinite(parsed)) return new Date(parsed);
|
| 144 |
+
return null;
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
const unitRaw = String(yColumn.unit || "").trim();
|
| 148 |
+
const unit = unitRaw.toLowerCase() === "none" ? "" : unitRaw;
|
| 149 |
+
const valueTitle = readableName(yColumn.label || yField || yColumn.description);
|
| 150 |
+
const axisTitle = `${shortText(valueTitle, 34).toUpperCase()}${unit ? ` (${unit})` : ""}`;
|
| 151 |
+
|
| 152 |
+
const formatNumber = value => {
|
| 153 |
+
const numberValue = Number(value);
|
| 154 |
+
if (!Number.isFinite(numberValue)) return String(value ?? "");
|
| 155 |
+
const abs = Math.abs(numberValue);
|
| 156 |
+
const spec = abs >= 100000 ? ",.0f" : abs >= 1000 ? ",.1f" : abs >= 100 ? ",.1f" : abs >= 10 ? ",.1f" : abs >= 1 ? ",.2f" : ",.3f";
|
| 157 |
+
return d3.format(spec)(numberValue)
|
| 158 |
+
.replace(/\.0$/, "")
|
| 159 |
+
.replace(/(\.\d*?)0+$/, "$1")
|
| 160 |
+
.replace(/\.$/, "");
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
const formatLocalValue = value => {
|
| 164 |
+
const formatted = formatNumber(value);
|
| 165 |
+
if (!unit) return formatted;
|
| 166 |
+
if (unit === "%") return `${formatted}%`;
|
| 167 |
+
if (unit === "$" || unit.toLowerCase() === "usd") return `$${formatted}`;
|
| 168 |
+
if (unit.startsWith("$")) return `$${formatted}${unit.slice(1)}`;
|
| 169 |
+
return `${formatted} ${unit}`;
|
| 170 |
+
};
|
| 171 |
+
|
| 172 |
+
const formatCompactValue = value => {
|
| 173 |
+
const numberValue = Number(value);
|
| 174 |
+
if (!Number.isFinite(numberValue)) return String(value ?? "");
|
| 175 |
+
const abs = Math.abs(numberValue);
|
| 176 |
+
const prefix = unit === "$" || unit.toLowerCase() === "usd" || unit.startsWith("$") ? "$" : "";
|
| 177 |
+
const suffix = unit === "%" ? "%" : "";
|
| 178 |
+
if (abs >= 1000000) return `${prefix}${d3.format(".2f")(numberValue / 1000000).replace(/\.00$/, "")}M${suffix}`;
|
| 179 |
+
if (abs >= 1000) return `${prefix}${d3.format(".2f")(numberValue / 1000).replace(/\.00$/, "")}K${suffix}`;
|
| 180 |
+
return `${prefix}${formatNumber(numberValue)}${suffix}`;
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
const formatDateLabel = date => {
|
| 184 |
+
if (!(date instanceof Date) || !Number.isFinite(date.getTime())) return "";
|
| 185 |
+
return String(date.getFullYear());
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
const rows = chartData
|
| 189 |
+
.map((row, sourceIndex) => {
|
| 190 |
+
const date = parseTimeValue(row[xField]);
|
| 191 |
+
const value = Number(row[yField]);
|
| 192 |
+
return {
|
| 193 |
+
rawX: String(row[xField] ?? ""),
|
| 194 |
+
date,
|
| 195 |
+
value,
|
| 196 |
+
sourceIndex
|
| 197 |
+
};
|
| 198 |
+
})
|
| 199 |
+
.filter(row => row.date && Number.isFinite(row.date.getTime()) && Number.isFinite(row.value))
|
| 200 |
+
.sort((a, b) => a.date - b.date || a.sourceIndex - b.sourceIndex);
|
| 201 |
+
|
| 202 |
+
if (rows.length < 2) {
|
| 203 |
+
return addEmptyState("Area chart needs at least two valid points");
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
const minObserved = d3.min(rows, d => d.value) ?? 0;
|
| 207 |
+
const maxObserved = d3.max(rows, d => d.value) ?? 1;
|
| 208 |
+
const rawRange = Math.max(maxObserved - minObserved, Math.abs(maxObserved) * 0.08, 1);
|
| 209 |
+
let yBottom = minObserved - rawRange * 0.22;
|
| 210 |
+
let yTop = maxObserved + rawRange * 0.14;
|
| 211 |
+
if (minObserved >= 0) yBottom = Math.max(0, yBottom);
|
| 212 |
+
if (minObserved === maxObserved) {
|
| 213 |
+
const pad = Math.max(Math.abs(maxObserved) * 0.12, 1);
|
| 214 |
+
yBottom = minObserved >= 0 ? Math.max(0, minObserved - pad) : minObserved - pad;
|
| 215 |
+
yTop = maxObserved + pad;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
const firstPoint = rows[0];
|
| 219 |
+
const lastPoint = rows[rows.length - 1];
|
| 220 |
+
const peakPoint = rows.reduce((max, row) => row.value > max.value ? row : max, rows[0]);
|
| 221 |
+
const lowPoint = rows.reduce((min, row) => row.value < min.value ? row : min, rows[0]);
|
| 222 |
+
const delta = lastPoint.value - firstPoint.value;
|
| 223 |
+
const trendWord = delta < 0 ? "lower" : delta > 0 ? "higher" : "steady";
|
| 224 |
+
const changeNoun = delta < 0 ? "Decrease" : delta > 0 ? "Increase" : "Change";
|
| 225 |
+
const percentDelta = firstPoint.value !== 0 ? Math.abs(delta / firstPoint.value) * 100 : null;
|
| 226 |
+
const changeMetric = percentDelta === null ? formatLocalValue(Math.abs(delta)) : `${d3.format(".1f")(percentDelta)}%`;
|
| 227 |
+
const firstYear = formatDateLabel(firstPoint.date);
|
| 228 |
+
const lastYear = formatDateLabel(lastPoint.date);
|
| 229 |
+
|
| 230 |
+
const referenceBlue = "#0B72D9";
|
| 231 |
+
const referenceBlueDark = "#075CB5";
|
| 232 |
+
const referenceBlueLight = "#CFE8FF";
|
| 233 |
+
const referenceGreen = "#22A843";
|
| 234 |
+
const referenceGreenDark = "#0E7E24";
|
| 235 |
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
| 236 |
+
|
| 237 |
+
const outerX = Math.max(28, width * 0.055);
|
| 238 |
+
const outerY = Math.max(18, height * 0.035);
|
| 239 |
+
const bottomRowH = 0;
|
| 240 |
+
const infoH = 0;
|
| 241 |
+
const infoGap = 0;
|
| 242 |
+
const infoY = height - outerY;
|
| 243 |
+
const chartCardX = outerX;
|
| 244 |
+
const chartCardY = Math.max(126, height * 0.21);
|
| 245 |
+
const chartCardW = width - outerX * 2;
|
| 246 |
+
const chartCardH = Math.max(300, infoY - chartCardY - Math.max(18, height * 0.035));
|
| 247 |
+
const plotX = chartCardX + Math.max(72, chartCardW * 0.115);
|
| 248 |
+
const plotRight = chartCardX + chartCardW - Math.max(36, chartCardW * 0.052);
|
| 249 |
+
const plotTop = chartCardY + Math.max(78, chartCardH * 0.19);
|
| 250 |
+
const plotBottom = chartCardY + chartCardH - Math.max(44, chartCardH * 0.12);
|
| 251 |
+
const plotW = Math.max(180, plotRight - plotX);
|
| 252 |
+
const plotH = Math.max(135, plotBottom - plotTop);
|
| 253 |
+
const baselineY = plotBottom;
|
| 254 |
+
|
| 255 |
+
const xScale = d3.scaleTime()
|
| 256 |
+
.domain(d3.extent(rows, d => d.date))
|
| 257 |
+
.range([plotX, plotRight]);
|
| 258 |
+
const yScale = d3.scaleLinear()
|
| 259 |
+
.domain([yBottom, yTop])
|
| 260 |
+
.nice(5)
|
| 261 |
+
.range([plotBottom, plotTop]);
|
| 262 |
+
|
| 263 |
+
const defs = svg.append("defs");
|
| 264 |
+
const cardShadow = defs.append("filter")
|
| 265 |
+
.attr("id", `area-chart-03-card-shadow-${idSuffix}`)
|
| 266 |
+
.attr("x", "-10%")
|
| 267 |
+
.attr("y", "-10%")
|
| 268 |
+
.attr("width", "120%")
|
| 269 |
+
.attr("height", "120%");
|
| 270 |
+
cardShadow.append("feDropShadow")
|
| 271 |
+
.attr("dx", 0)
|
| 272 |
+
.attr("dy", 7)
|
| 273 |
+
.attr("stdDeviation", 8)
|
| 274 |
+
.attr("flood-color", "#C9D8E6")
|
| 275 |
+
.attr("flood-opacity", 0.28);
|
| 276 |
+
|
| 277 |
+
const areaGradientId = `area-chart-03-fill-${idSuffix}`;
|
| 278 |
+
const areaGradient = defs.append("linearGradient")
|
| 279 |
+
.attr("id", areaGradientId)
|
| 280 |
+
.attr("gradientUnits", "userSpaceOnUse")
|
| 281 |
+
.attr("x1", 0)
|
| 282 |
+
.attr("x2", 0)
|
| 283 |
+
.attr("y1", plotTop)
|
| 284 |
+
.attr("y2", baselineY);
|
| 285 |
+
areaGradient.append("stop").attr("offset", "0%").attr("stop-color", referenceBlue).attr("stop-opacity", 0.42);
|
| 286 |
+
areaGradient.append("stop").attr("offset", "58%").attr("stop-color", referenceBlueLight).attr("stop-opacity", 0.42);
|
| 287 |
+
areaGradient.append("stop").attr("offset", "100%").attr("stop-color", "#F3FAFF").attr("stop-opacity", 0.88);
|
| 288 |
+
|
| 289 |
+
const lineGradientId = `area-chart-03-line-${idSuffix}`;
|
| 290 |
+
const lineGradient = defs.append("linearGradient")
|
| 291 |
+
.attr("id", lineGradientId)
|
| 292 |
+
.attr("gradientUnits", "userSpaceOnUse")
|
| 293 |
+
.attr("x1", plotX)
|
| 294 |
+
.attr("x2", plotRight)
|
| 295 |
+
.attr("y1", 0)
|
| 296 |
+
.attr("y2", 0);
|
| 297 |
+
lineGradient.append("stop").attr("offset", "0%").attr("stop-color", referenceBlueDark);
|
| 298 |
+
lineGradient.append("stop").attr("offset", "100%").attr("stop-color", referenceBlue);
|
| 299 |
+
|
| 300 |
+
const clipId = `area-chart-03-clip-${idSuffix}`;
|
| 301 |
+
defs.append("clipPath")
|
| 302 |
+
.attr("id", clipId)
|
| 303 |
+
.append("rect")
|
| 304 |
+
.attr("x", plotX)
|
| 305 |
+
.attr("y", plotTop)
|
| 306 |
+
.attr("width", plotW)
|
| 307 |
+
.attr("height", plotH);
|
| 308 |
+
|
| 309 |
+
const area = d3.area()
|
| 310 |
+
.x(d => xScale(d.date))
|
| 311 |
+
.y0(baselineY)
|
| 312 |
+
.y1(d => yScale(d.value))
|
| 313 |
+
.curve(d3.curveMonotoneX);
|
| 314 |
+
const line = d3.line()
|
| 315 |
+
.x(d => xScale(d.date))
|
| 316 |
+
.y(d => yScale(d.value))
|
| 317 |
+
.curve(d3.curveMonotoneX);
|
| 318 |
+
|
| 319 |
+
const root = svg.append("g").attr("class", "area-chart-03-reference-panel");
|
| 320 |
+
root.append("rect")
|
| 321 |
+
.attr("class", "chart-style-background")
|
| 322 |
+
.attr("width", width)
|
| 323 |
+
.attr("height", height)
|
| 324 |
+
.attr("fill", "#F7FBFF")
|
| 325 |
+
.attr("fill-opacity", 0);
|
| 326 |
+
root.append("circle")
|
| 327 |
+
.attr("class", "soft-background-cloud decorative-shape")
|
| 328 |
+
.attr("cx", width * 0.60)
|
| 329 |
+
.attr("cy", Math.max(56, height * 0.115))
|
| 330 |
+
.attr("r", Math.max(24, width * 0.045))
|
| 331 |
+
.attr("fill", "#FFFFFF")
|
| 332 |
+
.attr("opacity", 0.86);
|
| 333 |
+
root.append("circle")
|
| 334 |
+
.attr("class", "soft-background-cloud decorative-shape")
|
| 335 |
+
.attr("cx", width * 0.48)
|
| 336 |
+
.attr("cy", Math.max(78, height * 0.145))
|
| 337 |
+
.attr("r", Math.max(18, width * 0.035))
|
| 338 |
+
.attr("fill", "#FFFFFF")
|
| 339 |
+
.attr("opacity", 0.76);
|
| 340 |
+
|
| 341 |
+
const decorW = Math.min(180, Math.max(142, width * 0.24));
|
| 342 |
+
root.append("rect")
|
| 343 |
+
.attr("class", "top-decorative-image-slot decorative-image-slot")
|
| 344 |
+
.attr("data-reserved-slot", "header_illustration_slot")
|
| 345 |
+
.attr("x", width - outerX - decorW)
|
| 346 |
+
.attr("y", Math.max(8, outerY * 0.45))
|
| 347 |
+
.attr("width", decorW)
|
| 348 |
+
.attr("height", decorW * 0.72)
|
| 349 |
+
.attr("fill", "none")
|
| 350 |
+
.attr("stroke", "none");
|
| 351 |
+
|
| 352 |
+
root.append("rect")
|
| 353 |
+
.attr("class", "chart-style-panel chart-card-panel")
|
| 354 |
+
.attr("x", chartCardX)
|
| 355 |
+
.attr("y", chartCardY)
|
| 356 |
+
.attr("width", chartCardW)
|
| 357 |
+
.attr("height", chartCardH)
|
| 358 |
+
.attr("rx", 14)
|
| 359 |
+
.attr("fill", panelFill)
|
| 360 |
+
.attr("stroke", "#E6EEF7")
|
| 361 |
+
.attr("filter", `url(#area-chart-03-card-shadow-${idSuffix})`);
|
| 362 |
+
|
| 363 |
+
root.append("text")
|
| 364 |
+
.attr("class", "chart-axis-caption chart-text")
|
| 365 |
+
.attr("x", chartCardX + 28)
|
| 366 |
+
.attr("y", chartCardY + 34)
|
| 367 |
+
.attr("fill", referenceBlueDark)
|
| 368 |
+
.style("font-family", titleFontFamily)
|
| 369 |
+
.style("font-size", "12px")
|
| 370 |
+
.style("font-weight", "900")
|
| 371 |
+
.style("letter-spacing", "0px")
|
| 372 |
+
.text(axisTitle);
|
| 373 |
+
|
| 374 |
+
const yTicks = yScale.ticks(5);
|
| 375 |
+
const gridLayer = root.append("g").attr("class", "plot-grid-layer");
|
| 376 |
+
yTicks.forEach(tick => {
|
| 377 |
+
const y = yScale(tick);
|
| 378 |
+
gridLayer.append("line")
|
| 379 |
+
.attr("class", "y-grid-line")
|
| 380 |
+
.attr("x1", plotX)
|
| 381 |
+
.attr("x2", plotRight)
|
| 382 |
+
.attr("y1", y)
|
| 383 |
+
.attr("y2", y)
|
| 384 |
+
.attr("stroke", "#D7E1EF")
|
| 385 |
+
.attr("stroke-width", 0.9)
|
| 386 |
+
.attr("stroke-dasharray", "4 5");
|
| 387 |
+
gridLayer.append("text")
|
| 388 |
+
.attr("class", "y-axis-tick chart-text")
|
| 389 |
+
.attr("x", plotX - 16)
|
| 390 |
+
.attr("y", y + 4)
|
| 391 |
+
.attr("text-anchor", "end")
|
| 392 |
+
.attr("fill", "#2F3B52")
|
| 393 |
+
.style("font-family", fontFamily)
|
| 394 |
+
.style("font-size", "12px")
|
| 395 |
+
.text(formatNumber(tick));
|
| 396 |
+
});
|
| 397 |
+
|
| 398 |
+
const plotLayer = root.append("g")
|
| 399 |
+
.attr("class", "area-plot-layer")
|
| 400 |
+
.attr("clip-path", `url(#${clipId})`);
|
| 401 |
+
plotLayer.append("path")
|
| 402 |
+
.datum(rows)
|
| 403 |
+
.attr("class", "area-fill data-area positive-area-fill")
|
| 404 |
+
.attr("d", area)
|
| 405 |
+
.attr("fill", `url(#${areaGradientId})`)
|
| 406 |
+
.attr("data-point-count", rows.length)
|
| 407 |
+
.attr("data-first-x", firstPoint.rawX)
|
| 408 |
+
.attr("data-first-value", firstPoint.value)
|
| 409 |
+
.attr("data-last-x", lastPoint.rawX)
|
| 410 |
+
.attr("data-last-value", lastPoint.value)
|
| 411 |
+
.attr("data-min-x", lowPoint.rawX)
|
| 412 |
+
.attr("data-min-value", lowPoint.value)
|
| 413 |
+
.attr("data-max-x", peakPoint.rawX)
|
| 414 |
+
.attr("data-max-value", peakPoint.value);
|
| 415 |
+
plotLayer.append("path")
|
| 416 |
+
.datum(rows)
|
| 417 |
+
.attr("class", "area-line-underlay")
|
| 418 |
+
.attr("d", line)
|
| 419 |
+
.attr("fill", "none")
|
| 420 |
+
.attr("stroke", "#FFFFFF")
|
| 421 |
+
.attr("stroke-width", 7.5)
|
| 422 |
+
.attr("stroke-linejoin", "round")
|
| 423 |
+
.attr("stroke-linecap", "round")
|
| 424 |
+
.attr("opacity", 0.96);
|
| 425 |
+
plotLayer.append("path")
|
| 426 |
+
.datum(rows)
|
| 427 |
+
.attr("class", "area-line data-line")
|
| 428 |
+
.attr("d", line)
|
| 429 |
+
.attr("fill", "none")
|
| 430 |
+
.attr("stroke", `url(#${lineGradientId})`)
|
| 431 |
+
.attr("stroke-width", 4)
|
| 432 |
+
.attr("stroke-linejoin", "round")
|
| 433 |
+
.attr("stroke-linecap", "round");
|
| 434 |
+
|
| 435 |
+
root.append("line")
|
| 436 |
+
.attr("class", "x-axis-line")
|
| 437 |
+
.attr("x1", plotX)
|
| 438 |
+
.attr("x2", plotRight)
|
| 439 |
+
.attr("y1", baselineY)
|
| 440 |
+
.attr("y2", baselineY)
|
| 441 |
+
.attr("stroke", "#BAC8D8")
|
| 442 |
+
.attr("stroke-width", 1.1);
|
| 443 |
+
root.append("line")
|
| 444 |
+
.attr("class", "y-axis-line")
|
| 445 |
+
.attr("x1", plotX)
|
| 446 |
+
.attr("x2", plotX)
|
| 447 |
+
.attr("y1", plotTop)
|
| 448 |
+
.attr("y2", plotBottom)
|
| 449 |
+
.attr("stroke", "#BAC8D8")
|
| 450 |
+
.attr("stroke-width", 1.1);
|
| 451 |
+
|
| 452 |
+
const tickKey = date => date instanceof Date ? date.getTime() : new Date(date).getTime();
|
| 453 |
+
const xTickDates = rows.length <= 5
|
| 454 |
+
? rows.map(row => row.date)
|
| 455 |
+
: Array.from(
|
| 456 |
+
new Map(
|
| 457 |
+
[firstPoint.date, ...xScale.ticks(4), lastPoint.date]
|
| 458 |
+
.filter(date => date >= firstPoint.date && date <= lastPoint.date)
|
| 459 |
+
.map(date => [tickKey(date), date])
|
| 460 |
+
).values()
|
| 461 |
+
).sort((a, b) => a - b);
|
| 462 |
+
xTickDates.forEach(tick => {
|
| 463 |
+
const x = xScale(tick);
|
| 464 |
+
root.append("text")
|
| 465 |
+
.attr("class", "x-axis-label chart-text")
|
| 466 |
+
.attr("x", x)
|
| 467 |
+
.attr("y", baselineY + 26)
|
| 468 |
+
.attr("text-anchor", "middle")
|
| 469 |
+
.attr("fill", "#0F1E38")
|
| 470 |
+
.style("font-family", fontFamily)
|
| 471 |
+
.style("font-size", "12px")
|
| 472 |
+
.text(formatDateLabel(tick));
|
| 473 |
+
});
|
| 474 |
+
|
| 475 |
+
const pointLayer = root.append("g").attr("class", "area-data-points");
|
| 476 |
+
rows.forEach(row => {
|
| 477 |
+
const x = xScale(row.date);
|
| 478 |
+
const y = yScale(row.value);
|
| 479 |
+
const point = pointLayer.append("g")
|
| 480 |
+
.attr("class", "area-point")
|
| 481 |
+
.attr("data-source-index", row.sourceIndex)
|
| 482 |
+
.attr("data-x", row.rawX)
|
| 483 |
+
.attr("data-value", row.value)
|
| 484 |
+
.attr("transform", `translate(${x}, ${y})`);
|
| 485 |
+
point.append("circle")
|
| 486 |
+
.attr("class", "area-point-outer")
|
| 487 |
+
.attr("r", 6.4)
|
| 488 |
+
.attr("fill", "#FFFFFF")
|
| 489 |
+
.attr("stroke", referenceBlue)
|
| 490 |
+
.attr("stroke-width", 3.1);
|
| 491 |
+
point.append("circle")
|
| 492 |
+
.attr("class", "area-point-inner")
|
| 493 |
+
.attr("r", 2.5)
|
| 494 |
+
.attr("fill", referenceBlue);
|
| 495 |
+
});
|
| 496 |
+
|
| 497 |
+
const labelLayer = root.append("g").attr("class", "key-point-labels");
|
| 498 |
+
[
|
| 499 |
+
{ row: firstPoint, role: "first" },
|
| 500 |
+
{ row: lastPoint, role: "last" }
|
| 501 |
+
].filter((item, index, array) => array.findIndex(other => other.row.sourceIndex === item.row.sourceIndex) === index)
|
| 502 |
+
.forEach(item => {
|
| 503 |
+
const row = item.row;
|
| 504 |
+
const x = xScale(row.date);
|
| 505 |
+
const y = yScale(row.value);
|
| 506 |
+
const valueText = formatCompactValue(row.value);
|
| 507 |
+
const labelW = Math.max(58, valueText.length * 8.3 + 20);
|
| 508 |
+
const labelH = 27;
|
| 509 |
+
const tx = item["role"] === "first"
|
| 510 |
+
? clamp(x - labelW * 0.18, plotX + 4, plotRight - labelW - 4)
|
| 511 |
+
: clamp(x - labelW * 0.72, plotX + 4, plotRight - labelW - 4);
|
| 512 |
+
const ty = clamp(y - labelH - 17, plotTop + 4, plotBottom - labelH - 8);
|
| 513 |
+
const pointerX = clamp(x - tx, 10, labelW - 10);
|
| 514 |
+
const label = labelLayer.append("g")
|
| 515 |
+
.attr("class", `key-point-label ${item.role}-point-label`)
|
| 516 |
+
.attr("data-source-index", row.sourceIndex)
|
| 517 |
+
.attr("data-x", row.rawX)
|
| 518 |
+
.attr("data-value", row.value)
|
| 519 |
+
.attr("transform", `translate(${tx}, ${ty})`);
|
| 520 |
+
label.append("rect")
|
| 521 |
+
.attr("class", "key-point-label-bg")
|
| 522 |
+
.attr("width", labelW)
|
| 523 |
+
.attr("height", labelH)
|
| 524 |
+
.attr("rx", 6)
|
| 525 |
+
.attr("fill", referenceBlue)
|
| 526 |
+
.attr("opacity", 0.98);
|
| 527 |
+
label.append("path")
|
| 528 |
+
.attr("class", "key-point-label-pointer")
|
| 529 |
+
.attr("d", `M ${pointerX - 6} ${labelH - 1} L ${pointerX + 6} ${labelH - 1} L ${pointerX} ${labelH + 8} Z`)
|
| 530 |
+
.attr("fill", referenceBlue)
|
| 531 |
+
.attr("opacity", 0.98);
|
| 532 |
+
label.append("text")
|
| 533 |
+
.attr("class", "key-point-value chart-text")
|
| 534 |
+
.attr("x", labelW / 2)
|
| 535 |
+
.attr("y", 18)
|
| 536 |
+
.attr("text-anchor", "middle")
|
| 537 |
+
.attr("fill", "#FFFFFF")
|
| 538 |
+
.style("font-family", titleFontFamily)
|
| 539 |
+
.style("font-size", "13px")
|
| 540 |
+
.style("font-weight", "900")
|
| 541 |
+
.text(valueText);
|
| 542 |
+
});
|
| 543 |
+
|
| 544 |
+
return svg.node();
|
| 545 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/area_chart_04.js
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Area Chart",
|
| 5 |
+
"chart_name": "area_chart_04",
|
| 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": 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": "no",
|
| 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 colorResolver = chartUtils.color.resolver(jsonData);
|
| 32 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 33 |
+
const chartUtilsStandard = {
|
| 34 |
+
schema: chartUtils.schema,
|
| 35 |
+
format: chartUtils.format,
|
| 36 |
+
text: chartUtils.text,
|
| 37 |
+
color: chartUtils.color,
|
| 38 |
+
legendLayout: chartUtils.legend.layout,
|
| 39 |
+
legendDraw: chartUtils.legend.draw,
|
| 40 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 41 |
+
};
|
| 42 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 43 |
+
x: { fallbackIndex: 0 },
|
| 44 |
+
y: { fallbackIndex: 1 },
|
| 45 |
+
y2: { fallbackIndex: 2 },
|
| 46 |
+
y3: { fallbackIndex: 3 },
|
| 47 |
+
size: { fallbackIndex: 2 },
|
| 48 |
+
group: { fallbackIndex: 2 },
|
| 49 |
+
group2: { fallbackIndex: 3 },
|
| 50 |
+
group3: { fallbackIndex: 4 }
|
| 51 |
+
});
|
| 52 |
+
const variables = jsonData.variables || {};
|
| 53 |
+
const typography = jsonData.typography || {};
|
| 54 |
+
const sourceColors = jsonData.colors_dark || jsonData.colors || {};
|
| 55 |
+
const titles = jsonData.titles || {};
|
| 56 |
+
const metadata = jsonData.metadata || {};
|
| 57 |
+
|
| 58 |
+
d3.select(containerSelector).html("");
|
| 59 |
+
|
| 60 |
+
const xColumn = dataColumns[0] || {};
|
| 61 |
+
const yColumn = dataColumns[1] || {};
|
| 62 |
+
const xField = xColumn.name;
|
| 63 |
+
const yField = yColumn.name;
|
| 64 |
+
|
| 65 |
+
const width = Math.max(1200, Number(variables.width) || 1200);
|
| 66 |
+
const height = Math.max(800, Number(variables.height) || Math.round(width * 2 / 3));
|
| 67 |
+
const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
|
| 68 |
+
const titleFontFamily = typography.title?.font_family || fontFamily;
|
| 69 |
+
const textColor = "#F5FBF8";
|
| 70 |
+
const mutedText = "#B7C4C8";
|
| 71 |
+
const bgTop = "#031522";
|
| 72 |
+
const bgBottom = "#071E26";
|
| 73 |
+
const primary = sourceColors.other?.primary || "#A5DEBE";
|
| 74 |
+
const primarySoft = "#9DE5B6";
|
| 75 |
+
const primaryMid = "#4FC98D";
|
| 76 |
+
const primaryDark = "#103B35";
|
| 77 |
+
const cyan = "#1FA6B0";
|
| 78 |
+
const idSuffix = String(containerSelector || "area-chart-04")
|
| 79 |
+
.replace(/[^a-zA-Z0-9_-]/g, "")
|
| 80 |
+
.slice(0, 24) || "area04";
|
| 81 |
+
|
| 82 |
+
const svg = d3.select(containerSelector)
|
| 83 |
+
.append("svg")
|
| 84 |
+
.attr("width", "100%")
|
| 85 |
+
.attr("height", height)
|
| 86 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 87 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 88 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 89 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 90 |
+
.attr("class", "area-chart-04-reference-layout");
|
| 91 |
+
|
| 92 |
+
const addEmptyState = message => {
|
| 93 |
+
svg.append("rect")
|
| 94 |
+
.attr("width", width)
|
| 95 |
+
.attr("height", height)
|
| 96 |
+
.attr("fill", bgTop);
|
| 97 |
+
svg.append("text")
|
| 98 |
+
.attr("x", width / 2)
|
| 99 |
+
.attr("y", height / 2)
|
| 100 |
+
.attr("text-anchor", "middle")
|
| 101 |
+
.attr("fill", textColor)
|
| 102 |
+
.style("font-family", fontFamily)
|
| 103 |
+
.style("font-size", "16px")
|
| 104 |
+
.text(message);
|
| 105 |
+
return svg.node();
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
if (!xField || !yField) {
|
| 109 |
+
return addEmptyState("Missing area chart fields");
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
const readableName = value => String(value ?? "")
|
| 113 |
+
.replace(/_/g, " ")
|
| 114 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 115 |
+
.replace(/\s+/g, " ")
|
| 116 |
+
.trim();
|
| 117 |
+
|
| 118 |
+
const shortText = (value, maxChars = 36) => {
|
| 119 |
+
const clean = readableName(value);
|
| 120 |
+
if (clean.length <= maxChars) return clean;
|
| 121 |
+
return `${clean.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
const wrapWords = (value, maxChars, maxLines = 2) => {
|
| 125 |
+
const words = String(value ?? "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
|
| 126 |
+
if (!words.length) return [""];
|
| 127 |
+
const lines = [];
|
| 128 |
+
let current = "";
|
| 129 |
+
words.forEach(word => {
|
| 130 |
+
const candidate = current ? `${current} ${word}` : word;
|
| 131 |
+
if (candidate.length <= maxChars || !current) {
|
| 132 |
+
current = candidate;
|
| 133 |
+
} else {
|
| 134 |
+
lines.push(current);
|
| 135 |
+
current = word;
|
| 136 |
+
}
|
| 137 |
+
});
|
| 138 |
+
if (current) lines.push(current);
|
| 139 |
+
if (lines.length <= maxLines) return lines;
|
| 140 |
+
return lines.slice(0, maxLines - 1).concat(lines.slice(maxLines - 1).join(" "));
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
const parseTimeValue = value => {
|
| 144 |
+
const raw = String(value ?? "").trim();
|
| 145 |
+
if (!raw) return null;
|
| 146 |
+
if (/^-?\d{1,6}$/.test(raw)) return new Date(Number(raw), 0, 1);
|
| 147 |
+
{
|
| 148 |
+
const parsedByHelper = chartUtils.format.parseDate(value);
|
| 149 |
+
if (parsedByHelper instanceof Date && Number.isFinite(parsedByHelper.getTime())) return parsedByHelper;
|
| 150 |
+
}
|
| 151 |
+
const parsed = Date.parse(raw);
|
| 152 |
+
if (Number.isFinite(parsed)) return new Date(parsed);
|
| 153 |
+
return null;
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
const unitRaw = String(yColumn.unit || "").trim();
|
| 157 |
+
const unit = unitRaw.toLowerCase() === "none" ? "" : unitRaw;
|
| 158 |
+
const valueTitle = readableName(yColumn.label || yField || yColumn.description);
|
| 159 |
+
const axisTitle = shortText(valueTitle, 38).toUpperCase();
|
| 160 |
+
const rawTitle = String(titles.main_title || metadata.title || `${valueTitle} on the Rise`).trim();
|
| 161 |
+
const titleMatch = rawTitle.match(/\bon\s+the\s+rise\b/i);
|
| 162 |
+
const titleKicker = titleMatch
|
| 163 |
+
? rawTitle.slice(0, titleMatch.index).trim().toUpperCase()
|
| 164 |
+
: rawTitle.toUpperCase();
|
| 165 |
+
const titleEmphasis = titleMatch
|
| 166 |
+
? rawTitle.slice(titleMatch.index).trim()
|
| 167 |
+
: "on the Rise";
|
| 168 |
+
const subtitleText = String(
|
| 169 |
+
titles.sub_title
|
| 170 |
+
|| metadata.description
|
| 171 |
+
|| `${valueTitle} over time.`
|
| 172 |
+
).trim();
|
| 173 |
+
|
| 174 |
+
const formatNumber = value => {
|
| 175 |
+
const numberValue = Number(value);
|
| 176 |
+
if (!Number.isFinite(numberValue)) return String(value ?? "");
|
| 177 |
+
const abs = Math.abs(numberValue);
|
| 178 |
+
const spec = abs >= 100000 ? ",.0f" : abs >= 1000 ? ",.1f" : abs >= 100 ? ",.1f" : abs >= 10 ? ",.1f" : abs >= 1 ? ",.2f" : ",.3f";
|
| 179 |
+
return d3.format(spec)(numberValue)
|
| 180 |
+
.replace(/\.0$/, "")
|
| 181 |
+
.replace(/(\.\d*?)0+$/, "$1")
|
| 182 |
+
.replace(/\.$/, "");
|
| 183 |
+
};
|
| 184 |
+
|
| 185 |
+
const formatLocalValue = value => {
|
| 186 |
+
const formatted = formatNumber(value);
|
| 187 |
+
if (!unit) return formatted;
|
| 188 |
+
if (unit === "%") return `${formatted}%`;
|
| 189 |
+
if (unit === "$" || unit.toLowerCase() === "usd") return `$${formatted}`;
|
| 190 |
+
if (unit === "£") return `£${formatted}`;
|
| 191 |
+
if (unit.toLowerCase() === "£k") return `£${formatted}k`;
|
| 192 |
+
if (unit.startsWith("$")) return `$${formatted}${unit.slice(1)}`;
|
| 193 |
+
return `${formatted} ${unit}`;
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
const formatCompactValue = value => {
|
| 197 |
+
const numberValue = Number(value);
|
| 198 |
+
if (!Number.isFinite(numberValue)) return String(value ?? "");
|
| 199 |
+
if (unit === "%") return `${formatNumber(numberValue)}%`;
|
| 200 |
+
if (unit === "£" || unit.toLowerCase() === "£k") {
|
| 201 |
+
return unit.toLowerCase() === "£k" ? `£${formatNumber(numberValue)}k` : `£${formatNumber(numberValue)}`;
|
| 202 |
+
}
|
| 203 |
+
const abs = Math.abs(numberValue);
|
| 204 |
+
const suffix = unit && unit !== "$" && unit.toLowerCase() !== "usd" ? ` ${unit}` : "";
|
| 205 |
+
const prefix = unit === "$" || unit.toLowerCase() === "usd" || unit.startsWith("$") ? "$" : "";
|
| 206 |
+
if (abs >= 1000000) return `${prefix}${d3.format(".2f")(numberValue / 1000000).replace(/\.00$/, "")}M${suffix}`;
|
| 207 |
+
if (abs >= 1000) return `${prefix}${d3.format(".2f")(numberValue / 1000).replace(/\.00$/, "")}K${suffix}`;
|
| 208 |
+
return `${prefix}${formatNumber(numberValue)}${suffix}`;
|
| 209 |
+
};
|
| 210 |
+
const formatPillValue = value => {
|
| 211 |
+
const numberValue = Number(value);
|
| 212 |
+
if (!Number.isFinite(numberValue)) return String(value ?? "");
|
| 213 |
+
const prefix = unit === "$" || unit.toLowerCase() === "usd" || unit.startsWith("$") ? "$" : "";
|
| 214 |
+
if (Math.abs(numberValue) >= 1000) return `${prefix}${d3.format(".2f")(numberValue / 1000)}K`;
|
| 215 |
+
return `${prefix}${formatNumber(numberValue)}`;
|
| 216 |
+
};
|
| 217 |
+
|
| 218 |
+
const formatDateLabel = date => {
|
| 219 |
+
if (!(date instanceof Date) || !Number.isFinite(date.getTime())) return "";
|
| 220 |
+
return String(date.getFullYear());
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
const rows = chartData
|
| 224 |
+
.map((row, sourceIndex) => {
|
| 225 |
+
const date = parseTimeValue(row[xField]);
|
| 226 |
+
const value = Number(row[yField]);
|
| 227 |
+
return {
|
| 228 |
+
rawX: String(row[xField] ?? ""),
|
| 229 |
+
date,
|
| 230 |
+
value,
|
| 231 |
+
sourceIndex
|
| 232 |
+
};
|
| 233 |
+
})
|
| 234 |
+
.filter(row => row.date && Number.isFinite(row.date.getTime()) && Number.isFinite(row.value))
|
| 235 |
+
.sort((a, b) => a.date - b.date || a.sourceIndex - b.sourceIndex);
|
| 236 |
+
|
| 237 |
+
if (rows.length < 2) {
|
| 238 |
+
return addEmptyState("Area chart needs at least two valid points");
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
const minObserved = d3.min(rows, d => d.value) ?? 0;
|
| 242 |
+
const maxObserved = d3.max(rows, d => d.value) ?? 1;
|
| 243 |
+
const valueRange = Math.max(maxObserved - minObserved, Math.abs(maxObserved) * 0.08, 1);
|
| 244 |
+
const yTop = maxObserved + valueRange * 0.18;
|
| 245 |
+
const yBottom = minObserved >= 0 ? Math.max(0, minObserved - valueRange * 0.34) : minObserved - valueRange * 0.18;
|
| 246 |
+
|
| 247 |
+
const firstPoint = rows[0];
|
| 248 |
+
const lastPoint = rows[rows.length - 1];
|
| 249 |
+
const peakPoint = rows.reduce((max, row) => row.value > max.value ? row : max, rows[0]);
|
| 250 |
+
const lowPoint = rows.reduce((min, row) => row.value < min.value ? row : min, rows[0]);
|
| 251 |
+
const delta = lastPoint.value - firstPoint.value;
|
| 252 |
+
const trendWord = delta < 0 ? "lower" : delta > 0 ? "higher" : "steady";
|
| 253 |
+
const growthRatio = firstPoint.value !== 0 ? lastPoint.value / firstPoint.value : null;
|
| 254 |
+
const growthText = growthRatio && Number.isFinite(growthRatio)
|
| 255 |
+
? `${d3.format(".1f")(Math.abs(growthRatio))}x`
|
| 256 |
+
: formatCompactValue(Math.abs(delta));
|
| 257 |
+
const firstYear = formatDateLabel(firstPoint.date);
|
| 258 |
+
const lastYear = formatDateLabel(lastPoint.date);
|
| 259 |
+
|
| 260 |
+
const padX = Math.max(42, width * 0.035);
|
| 261 |
+
const padY = Math.max(46, height * 0.055);
|
| 262 |
+
const plotLeft = padX;
|
| 263 |
+
const plotRight = width - Math.max(58, width * 0.056);
|
| 264 |
+
const plotTop = Math.max(260, height * 0.33);
|
| 265 |
+
const plotBottom = height - Math.max(96, height * 0.145);
|
| 266 |
+
const plotW = Math.max(260, plotRight - plotLeft);
|
| 267 |
+
const plotH = Math.max(170, plotBottom - plotTop);
|
| 268 |
+
|
| 269 |
+
const xScale = d3.scaleTime()
|
| 270 |
+
.domain(d3.extent(rows, d => d.date))
|
| 271 |
+
.range([plotLeft, plotRight]);
|
| 272 |
+
const yScale = d3.scaleLinear()
|
| 273 |
+
.domain([yBottom, yTop])
|
| 274 |
+
.nice(5)
|
| 275 |
+
.range([plotBottom, plotTop]);
|
| 276 |
+
|
| 277 |
+
const defs = svg.append("defs");
|
| 278 |
+
const bgGradientId = `area-chart-04-bg-${idSuffix}`;
|
| 279 |
+
const bgGradient = defs.append("linearGradient")
|
| 280 |
+
.attr("id", bgGradientId)
|
| 281 |
+
.attr("x1", "0%")
|
| 282 |
+
.attr("y1", "0%")
|
| 283 |
+
.attr("x2", "100%")
|
| 284 |
+
.attr("y2", "100%");
|
| 285 |
+
bgGradient.append("stop").attr("offset", "0%").attr("stop-color", bgTop);
|
| 286 |
+
bgGradient.append("stop").attr("offset", "58%").attr("stop-color", bgBottom);
|
| 287 |
+
bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#010A12");
|
| 288 |
+
|
| 289 |
+
const glowId = `area-chart-04-glow-${idSuffix}`;
|
| 290 |
+
const glow = defs.append("filter")
|
| 291 |
+
.attr("id", glowId)
|
| 292 |
+
.attr("x", "-20%")
|
| 293 |
+
.attr("y", "-20%")
|
| 294 |
+
.attr("width", "140%")
|
| 295 |
+
.attr("height", "140%");
|
| 296 |
+
glow.append("feGaussianBlur")
|
| 297 |
+
.attr("stdDeviation", 4)
|
| 298 |
+
.attr("result", "blur");
|
| 299 |
+
glow.append("feMerge")
|
| 300 |
+
.selectAll("feMergeNode")
|
| 301 |
+
.data(["blur", "SourceGraphic"])
|
| 302 |
+
.enter()
|
| 303 |
+
.append("feMergeNode")
|
| 304 |
+
.attr("in", d => d);
|
| 305 |
+
|
| 306 |
+
const areaGradientId = `area-chart-04-fill-${idSuffix}`;
|
| 307 |
+
const areaGradient = defs.append("linearGradient")
|
| 308 |
+
.attr("id", areaGradientId)
|
| 309 |
+
.attr("gradientUnits", "userSpaceOnUse")
|
| 310 |
+
.attr("x1", 0)
|
| 311 |
+
.attr("x2", 0)
|
| 312 |
+
.attr("y1", plotTop)
|
| 313 |
+
.attr("y2", plotBottom);
|
| 314 |
+
areaGradient.append("stop").attr("offset", "0%").attr("stop-color", primarySoft).attr("stop-opacity", 0.88);
|
| 315 |
+
areaGradient.append("stop").attr("offset", "55%").attr("stop-color", primaryMid).attr("stop-opacity", 0.46);
|
| 316 |
+
areaGradient.append("stop").attr("offset", "100%").attr("stop-color", primaryDark).attr("stop-opacity", 0.10);
|
| 317 |
+
|
| 318 |
+
const clipId = `area-chart-04-clip-${idSuffix}`;
|
| 319 |
+
defs.append("clipPath")
|
| 320 |
+
.attr("id", clipId)
|
| 321 |
+
.append("rect")
|
| 322 |
+
.attr("x", plotLeft)
|
| 323 |
+
.attr("y", plotTop - 24)
|
| 324 |
+
.attr("width", plotW)
|
| 325 |
+
.attr("height", plotH + 24);
|
| 326 |
+
|
| 327 |
+
const area = d3.area()
|
| 328 |
+
.x(d => xScale(d.date))
|
| 329 |
+
.y0(plotBottom)
|
| 330 |
+
.y1(d => yScale(d.value))
|
| 331 |
+
.curve(d3.curveMonotoneX);
|
| 332 |
+
const line = d3.line()
|
| 333 |
+
.x(d => xScale(d.date))
|
| 334 |
+
.y(d => yScale(d.value))
|
| 335 |
+
.curve(d3.curveMonotoneX);
|
| 336 |
+
|
| 337 |
+
const root = svg.append("g").attr("class", "area-chart-04-reference-panel");
|
| 338 |
+
root.append("rect")
|
| 339 |
+
.attr("class", "chart-style-background")
|
| 340 |
+
.attr("width", width)
|
| 341 |
+
.attr("height", height)
|
| 342 |
+
.attr("fill", `url(#${bgGradientId})`);
|
| 343 |
+
|
| 344 |
+
const titleSlot = root.append("g")
|
| 345 |
+
.attr("class", "header-title-art-slot")
|
| 346 |
+
.attr("data-reserved-slot", "header_title_art_slot")
|
| 347 |
+
.attr("transform", `translate(${padX}, ${padY + height * 0.02})`);
|
| 348 |
+
const titleKickerLines = wrapWords(titleKicker, 40, 2);
|
| 349 |
+
const titleKickerFont = Math.max(25, width * 0.026);
|
| 350 |
+
const titleKickerLineH = titleKickerFont * 1.18;
|
| 351 |
+
titleKickerLines.forEach((lineText, index) => {
|
| 352 |
+
titleSlot.append("text")
|
| 353 |
+
.attr("class", "reference-title-kicker chart-text")
|
| 354 |
+
.attr("x", 0)
|
| 355 |
+
.attr("y", index * titleKickerLineH)
|
| 356 |
+
.attr("fill", textColor)
|
| 357 |
+
.style("font-family", "Arial, Helvetica, sans-serif")
|
| 358 |
+
.style("font-size", `${titleKickerFont}px`)
|
| 359 |
+
.style("font-weight", "900")
|
| 360 |
+
.style("letter-spacing", "0px")
|
| 361 |
+
.text(lineText);
|
| 362 |
+
});
|
| 363 |
+
const titleEmphasisY = Math.max(72, height * 0.09) + (titleKickerLines.length - 1) * titleKickerLineH;
|
| 364 |
+
titleSlot.append("text")
|
| 365 |
+
.attr("class", "reference-title-emphasis chart-text")
|
| 366 |
+
.attr("x", 0)
|
| 367 |
+
.attr("y", titleEmphasisY)
|
| 368 |
+
.attr("fill", primarySoft)
|
| 369 |
+
.style("font-family", "Georgia, 'Times New Roman', serif")
|
| 370 |
+
.style("font-size", `${Math.max(58, width * 0.054)}px`)
|
| 371 |
+
.style("font-weight", "900")
|
| 372 |
+
.style("letter-spacing", "0px")
|
| 373 |
+
.text(titleEmphasis);
|
| 374 |
+
const subtitleLines = wrapWords(subtitleText, 44, 2);
|
| 375 |
+
const subtitleY = titleEmphasisY + Math.max(54, height * 0.068);
|
| 376 |
+
subtitleLines.forEach((lineText, index) => {
|
| 377 |
+
titleSlot.append("text")
|
| 378 |
+
.attr("class", "reference-subtitle chart-text")
|
| 379 |
+
.attr("x", 0)
|
| 380 |
+
.attr("y", subtitleY + index * Math.max(25, height * 0.029))
|
| 381 |
+
.attr("fill", textColor)
|
| 382 |
+
.style("font-family", fontFamily)
|
| 383 |
+
.style("font-size", `${Math.max(20, width * 0.018)}px`)
|
| 384 |
+
.style("font-weight", "500")
|
| 385 |
+
.style("letter-spacing", "0px")
|
| 386 |
+
.text(lineText);
|
| 387 |
+
});
|
| 388 |
+
titleSlot.append("line")
|
| 389 |
+
.attr("class", "reference-title-accent")
|
| 390 |
+
.attr("x1", 0)
|
| 391 |
+
.attr("x2", Math.max(50, width * 0.042))
|
| 392 |
+
.attr("y1", subtitleY + subtitleLines.length * Math.max(25, height * 0.029) + 22)
|
| 393 |
+
.attr("y2", subtitleY + subtitleLines.length * Math.max(25, height * 0.029) + 22)
|
| 394 |
+
.attr("stroke", primaryMid)
|
| 395 |
+
.attr("stroke-width", Math.max(4, width * 0.004))
|
| 396 |
+
.attr("stroke-linecap", "round");
|
| 397 |
+
|
| 398 |
+
root.append("path")
|
| 399 |
+
.attr("class", "stage-light decorative-shape")
|
| 400 |
+
.attr("d", `M ${width * 0.70} ${padY} L ${width * 0.84} ${padY} L ${width * 0.78} ${plotBottom + 52} Z`)
|
| 401 |
+
.attr("fill", cyan)
|
| 402 |
+
.attr("opacity", 0.10);
|
| 403 |
+
root.append("path")
|
| 404 |
+
.attr("class", "stage-light decorative-shape")
|
| 405 |
+
.attr("d", `M ${width * 0.86} ${padY} L ${width * 0.98} ${padY} L ${width * 0.82} ${plotBottom + 36} Z`)
|
| 406 |
+
.attr("fill", cyan)
|
| 407 |
+
.attr("opacity", 0.08);
|
| 408 |
+
|
| 409 |
+
const insightX = padX;
|
| 410 |
+
const insightY = Math.max(plotTop + 18, height * 0.39);
|
| 411 |
+
const insightW = Math.min(300, Math.max(248, width * 0.23));
|
| 412 |
+
const insightH = Math.max(142, height * 0.18);
|
| 413 |
+
const insight = root.append("g")
|
| 414 |
+
.attr("class", "insight-callout reference-insight-card")
|
| 415 |
+
.attr("transform", `translate(${insightX}, ${insightY})`);
|
| 416 |
+
insight.append("rect")
|
| 417 |
+
.attr("class", "insight-callout-card")
|
| 418 |
+
.attr("width", insightW)
|
| 419 |
+
.attr("height", insightH)
|
| 420 |
+
.attr("rx", 9)
|
| 421 |
+
.attr("fill", "#071C25")
|
| 422 |
+
.attr("stroke", "#5E9A78")
|
| 423 |
+
.attr("stroke-width", 1)
|
| 424 |
+
.attr("opacity", 0.72);
|
| 425 |
+
const calloutIcon = insight.append("g")
|
| 426 |
+
.attr("class", "callout-icon-slot decorative-icon-slot")
|
| 427 |
+
.attr("data-reserved-slot", "growth_icon_slot")
|
| 428 |
+
.attr("transform", `translate(${Math.max(63, insightW * 0.25)}, ${insightH * 0.47})`);
|
| 429 |
+
calloutIcon.append("circle")
|
| 430 |
+
.attr("r", Math.min(48, insightH * 0.28))
|
| 431 |
+
.attr("fill", "none")
|
| 432 |
+
.attr("stroke", "#8CCB8F")
|
| 433 |
+
.attr("stroke-width", 2.2)
|
| 434 |
+
.attr("opacity", 0.9);
|
| 435 |
+
const growthRounded = growthRatio && Number.isFinite(growthRatio)
|
| 436 |
+
? `${Math.max(1, Math.round(Math.abs(growthRatio)))}x`
|
| 437 |
+
: growthText;
|
| 438 |
+
insight.append("text")
|
| 439 |
+
.attr("class", "callout-nearly chart-text")
|
| 440 |
+
.attr("x", insightW * 0.52)
|
| 441 |
+
.attr("y", insightH * 0.30)
|
| 442 |
+
.attr("fill", textColor)
|
| 443 |
+
.style("font-family", fontFamily)
|
| 444 |
+
.style("font-size", `${Math.max(18, width * 0.017)}px`)
|
| 445 |
+
.style("font-weight", "500")
|
| 446 |
+
.text(delta === 0 ? "Nearly" : "Nearly");
|
| 447 |
+
insight.append("text")
|
| 448 |
+
.attr("class", "callout-growth-value chart-text")
|
| 449 |
+
.attr("x", insightW * 0.52)
|
| 450 |
+
.attr("y", insightH * 0.63)
|
| 451 |
+
.attr("fill", primarySoft)
|
| 452 |
+
.style("font-family", "Georgia, 'Times New Roman', serif")
|
| 453 |
+
.style("font-size", `${Math.max(48, width * 0.049)}px`)
|
| 454 |
+
.style("font-weight", "900")
|
| 455 |
+
.style("letter-spacing", "0px")
|
| 456 |
+
.text(growthRounded);
|
| 457 |
+
["growth in", `${Math.max(1, Number(lastYear) - Number(firstYear))} years`].forEach((lineText, lineIndex) => {
|
| 458 |
+
insight.append("text")
|
| 459 |
+
.attr("class", "callout-subtitle chart-text")
|
| 460 |
+
.attr("x", insightW * 0.52)
|
| 461 |
+
.attr("y", insightH * 0.76 + lineIndex * Math.max(22, height * 0.027))
|
| 462 |
+
.attr("fill", textColor)
|
| 463 |
+
.style("font-family", fontFamily)
|
| 464 |
+
.style("font-size", `${Math.max(18, width * 0.017)}px`)
|
| 465 |
+
.style("font-weight", "500")
|
| 466 |
+
.text(lineText);
|
| 467 |
+
});
|
| 468 |
+
|
| 469 |
+
const decorW = Math.min(width * 0.36, plotW * 0.42);
|
| 470 |
+
const decorH = Math.min(height * 0.30, plotH * 0.52);
|
| 471 |
+
const decor = root.append("g")
|
| 472 |
+
.attr("class", "venue-illustration-slot right-decorative-image-slot decorative-image-slot")
|
| 473 |
+
.attr("data-reserved-slot", "venue_illustration_slot")
|
| 474 |
+
.attr("data-asset-owner", "image2")
|
| 475 |
+
.attr("data-anchor", "plot-lower-right")
|
| 476 |
+
.attr("transform", `translate(${plotRight - decorW - width * 0.02}, ${plotBottom - decorH - height * 0.035})`)
|
| 477 |
+
.attr("opacity", 0.68);
|
| 478 |
+
decor.append("rect")
|
| 479 |
+
.attr("class", "venue-slot-bbox")
|
| 480 |
+
.attr("width", decorW)
|
| 481 |
+
.attr("height", decorH)
|
| 482 |
+
.attr("rx", 12)
|
| 483 |
+
.attr("fill", "#0B2C2A")
|
| 484 |
+
.attr("stroke", primarySoft)
|
| 485 |
+
.attr("stroke-width", 1.2)
|
| 486 |
+
.attr("stroke-opacity", 0.25)
|
| 487 |
+
.attr("fill-opacity", 0.16);
|
| 488 |
+
decor.append("path")
|
| 489 |
+
.attr("class", "venue-slot-arch semantic-placeholder")
|
| 490 |
+
.attr("d", `M ${decorW * 0.08} ${decorH * 0.66} C ${decorW * 0.26} ${decorH * 0.18}, ${decorW * 0.74} ${decorH * 0.18}, ${decorW * 0.92} ${decorH * 0.66}`)
|
| 491 |
+
.attr("fill", "none")
|
| 492 |
+
.attr("stroke", primarySoft)
|
| 493 |
+
.attr("stroke-width", 2.2)
|
| 494 |
+
.attr("stroke-opacity", 0.42)
|
| 495 |
+
.attr("stroke-linecap", "round");
|
| 496 |
+
decor.append("line")
|
| 497 |
+
.attr("class", "venue-slot-stage semantic-placeholder")
|
| 498 |
+
.attr("x1", decorW * 0.12)
|
| 499 |
+
.attr("x2", decorW * 0.88)
|
| 500 |
+
.attr("y1", decorH * 0.72)
|
| 501 |
+
.attr("y2", decorH * 0.72)
|
| 502 |
+
.attr("stroke", primarySoft)
|
| 503 |
+
.attr("stroke-width", 2)
|
| 504 |
+
.attr("stroke-opacity", 0.35)
|
| 505 |
+
.attr("stroke-linecap", "round");
|
| 506 |
+
d3.range(12).forEach(index => {
|
| 507 |
+
decor.append("circle")
|
| 508 |
+
.attr("class", "venue-slot-audience-dot semantic-placeholder")
|
| 509 |
+
.attr("cx", decorW * (0.14 + index * 0.065))
|
| 510 |
+
.attr("cy", decorH * (0.83 + (index % 2) * 0.045))
|
| 511 |
+
.attr("r", Math.max(3, width * 0.004))
|
| 512 |
+
.attr("fill", primarySoft)
|
| 513 |
+
.attr("fill-opacity", 0.18);
|
| 514 |
+
});
|
| 515 |
+
|
| 516 |
+
const plotLayer = root.append("g")
|
| 517 |
+
.attr("class", "area-plot-layer")
|
| 518 |
+
.attr("clip-path", `url(#${clipId})`);
|
| 519 |
+
plotLayer.append("path")
|
| 520 |
+
.datum(rows)
|
| 521 |
+
.attr("class", "area-fill data-area positive-area-fill")
|
| 522 |
+
.attr("d", area)
|
| 523 |
+
.attr("fill", `url(#${areaGradientId})`)
|
| 524 |
+
.attr("data-point-count", rows.length)
|
| 525 |
+
.attr("data-first-x", firstPoint.rawX)
|
| 526 |
+
.attr("data-first-value", firstPoint.value)
|
| 527 |
+
.attr("data-last-x", lastPoint.rawX)
|
| 528 |
+
.attr("data-last-value", lastPoint.value)
|
| 529 |
+
.attr("data-min-x", lowPoint.rawX)
|
| 530 |
+
.attr("data-min-value", lowPoint.value)
|
| 531 |
+
.attr("data-max-x", peakPoint.rawX)
|
| 532 |
+
.attr("data-max-value", peakPoint.value);
|
| 533 |
+
plotLayer.append("path")
|
| 534 |
+
.datum(rows)
|
| 535 |
+
.attr("class", "area-line-underlay")
|
| 536 |
+
.attr("d", line)
|
| 537 |
+
.attr("fill", "none")
|
| 538 |
+
.attr("stroke", "#E8FFF0")
|
| 539 |
+
.attr("stroke-width", 6)
|
| 540 |
+
.attr("stroke-linecap", "round")
|
| 541 |
+
.attr("stroke-linejoin", "round")
|
| 542 |
+
.attr("opacity", 0.88)
|
| 543 |
+
.attr("filter", `url(#${glowId})`);
|
| 544 |
+
plotLayer.append("path")
|
| 545 |
+
.datum(rows)
|
| 546 |
+
.attr("class", "area-line data-line")
|
| 547 |
+
.attr("d", line)
|
| 548 |
+
.attr("fill", "none")
|
| 549 |
+
.attr("stroke", primarySoft)
|
| 550 |
+
.attr("stroke-width", 3.3)
|
| 551 |
+
.attr("stroke-linecap", "round")
|
| 552 |
+
.attr("stroke-linejoin", "round");
|
| 553 |
+
|
| 554 |
+
root.append("line")
|
| 555 |
+
.attr("class", "x-axis-line")
|
| 556 |
+
.attr("x1", plotLeft)
|
| 557 |
+
.attr("x2", plotRight)
|
| 558 |
+
.attr("y1", plotBottom)
|
| 559 |
+
.attr("y2", plotBottom)
|
| 560 |
+
.attr("stroke", "#9AA8AD")
|
| 561 |
+
.attr("stroke-width", 1.2)
|
| 562 |
+
.attr("opacity", 0.78);
|
| 563 |
+
|
| 564 |
+
const chooseRowsByPosition = () => {
|
| 565 |
+
if (rows.length <= 5) return rows;
|
| 566 |
+
const indexes = [0, Math.round((rows.length - 1) * 0.25), Math.round((rows.length - 1) * 0.5), Math.round((rows.length - 1) * 0.75), rows.length - 1];
|
| 567 |
+
return indexes
|
| 568 |
+
.map(index => rows[index])
|
| 569 |
+
.filter((row, index, array) => array.findIndex(item => item.sourceIndex === row.sourceIndex) === index)
|
| 570 |
+
.sort((a, b) => a.date - b.date);
|
| 571 |
+
};
|
| 572 |
+
const labeledRows = chooseRowsByPosition();
|
| 573 |
+
const labeledIndexes = new Set(labeledRows.map(row => row.sourceIndex));
|
| 574 |
+
|
| 575 |
+
const guideLayer = root.append("g").attr("class", "point-guide-layer");
|
| 576 |
+
labeledRows.forEach(row => {
|
| 577 |
+
const x = xScale(row.date);
|
| 578 |
+
const y = yScale(row.value);
|
| 579 |
+
guideLayer.append("line")
|
| 580 |
+
.attr("class", "point-guide")
|
| 581 |
+
.attr("x1", x)
|
| 582 |
+
.attr("x2", x)
|
| 583 |
+
.attr("y1", y + 8)
|
| 584 |
+
.attr("y2", plotBottom)
|
| 585 |
+
.attr("stroke", "#A9DDBB")
|
| 586 |
+
.attr("stroke-width", 1.1)
|
| 587 |
+
.attr("stroke-dasharray", "4 5")
|
| 588 |
+
.attr("opacity", 0.54)
|
| 589 |
+
.attr("data-source-index", row.sourceIndex)
|
| 590 |
+
.attr("data-x", row.rawX)
|
| 591 |
+
.attr("data-value", row.value);
|
| 592 |
+
guideLayer.append("circle")
|
| 593 |
+
.attr("class", "x-axis-dot")
|
| 594 |
+
.attr("cx", x)
|
| 595 |
+
.attr("cy", plotBottom)
|
| 596 |
+
.attr("r", 2.8)
|
| 597 |
+
.attr("fill", mutedText)
|
| 598 |
+
.attr("opacity", 0.82);
|
| 599 |
+
});
|
| 600 |
+
|
| 601 |
+
const pointLayer = root.append("g").attr("class", "area-data-points");
|
| 602 |
+
rows.forEach(row => {
|
| 603 |
+
const x = xScale(row.date);
|
| 604 |
+
const y = yScale(row.value);
|
| 605 |
+
const point = pointLayer.append("g")
|
| 606 |
+
.attr("class", labeledIndexes.has(row.sourceIndex) ? "area-point labeled-area-point" : "area-point")
|
| 607 |
+
.attr("data-source-index", row.sourceIndex)
|
| 608 |
+
.attr("data-x", row.rawX)
|
| 609 |
+
.attr("data-value", row.value)
|
| 610 |
+
.attr("transform", `translate(${x}, ${y})`);
|
| 611 |
+
point.append("circle")
|
| 612 |
+
.attr("class", "area-point-outer")
|
| 613 |
+
.attr("r", labeledIndexes.has(row.sourceIndex) ? 6.3 : 3.1)
|
| 614 |
+
.attr("fill", bgTop)
|
| 615 |
+
.attr("stroke", "#F3FFF7")
|
| 616 |
+
.attr("stroke-width", labeledIndexes.has(row.sourceIndex) ? 3 : 1.5);
|
| 617 |
+
point.append("circle")
|
| 618 |
+
.attr("class", "area-point-inner")
|
| 619 |
+
.attr("r", labeledIndexes.has(row.sourceIndex) ? 2.5 : 1.4)
|
| 620 |
+
.attr("fill", primarySoft);
|
| 621 |
+
});
|
| 622 |
+
|
| 623 |
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
| 624 |
+
const boxFrom = (x, y, w, h) => ({ left: x, right: x + w, top: y, bottom: y + h, width: w, height: h });
|
| 625 |
+
const boxesOverlap = (a, b, pad = 0) => !(a.right + pad <= b.left || b.right + pad <= a.left || a.bottom + pad <= b.top || b.bottom + pad <= a.top);
|
| 626 |
+
const placedLabelBoxes = [];
|
| 627 |
+
const labelLayer = root.append("g").attr("class", "key-point-labels");
|
| 628 |
+
labeledRows.forEach(row => {
|
| 629 |
+
const x = xScale(row.date);
|
| 630 |
+
const y = yScale(row.value);
|
| 631 |
+
const valueText = formatPillValue(row.value);
|
| 632 |
+
const labelW = Math.max(86, valueText.length * Math.max(8.2, width * 0.0068) + 28);
|
| 633 |
+
const labelH = Math.max(38, height * 0.046);
|
| 634 |
+
const xOptions = [
|
| 635 |
+
x - labelW / 2,
|
| 636 |
+
x - labelW * 0.18,
|
| 637 |
+
x - labelW * 0.82,
|
| 638 |
+
x + 8,
|
| 639 |
+
x - labelW - 8,
|
| 640 |
+
x + 26,
|
| 641 |
+
x - labelW - 26
|
| 642 |
+
].map(value => clamp(value, padX + 6, width - padX - labelW - 6));
|
| 643 |
+
const yOptions = [
|
| 644 |
+
y - labelH - Math.max(28, height * 0.038),
|
| 645 |
+
y - labelH - Math.max(42, height * 0.056),
|
| 646 |
+
y - labelH - Math.max(58, height * 0.074),
|
| 647 |
+
plotTop + 4
|
| 648 |
+
].map(value => clamp(value, padY + 8, Math.max(padY + 8, y - labelH - 8)));
|
| 649 |
+
let best = { tx: xOptions[0], ty: yOptions[0], score: Number.POSITIVE_INFINITY };
|
| 650 |
+
xOptions.forEach(candidateX => {
|
| 651 |
+
yOptions.forEach(candidateY => {
|
| 652 |
+
const box = boxFrom(candidateX, candidateY, labelW, labelH);
|
| 653 |
+
const overlapPenalty = placedLabelBoxes.reduce((sum, placed) => sum + (boxesOverlap(box, placed, 5) ? 50000 : 0), 0);
|
| 654 |
+
const pointDx = Math.abs(candidateX + labelW / 2 - x);
|
| 655 |
+
const pointDy = Math.abs(candidateY + labelH / 2 - y);
|
| 656 |
+
const abovePenalty = candidateY + labelH > y - 6 ? 10000 : 0;
|
| 657 |
+
const topPenalty = candidateY < plotTop - 90 ? 14 : 0;
|
| 658 |
+
const score = overlapPenalty + abovePenalty + pointDx * 2.4 + pointDy * 0.18 + topPenalty;
|
| 659 |
+
if (score < best.score) best = { tx: candidateX, ty: candidateY, score };
|
| 660 |
+
});
|
| 661 |
+
});
|
| 662 |
+
const tx = best.tx;
|
| 663 |
+
const ty = best.ty;
|
| 664 |
+
placedLabelBoxes.push(boxFrom(tx, ty, labelW, labelH));
|
| 665 |
+
const pointerX = clamp(x - tx, 10, labelW - 10);
|
| 666 |
+
const label = labelLayer.append("g")
|
| 667 |
+
.attr("class", "key-point-label")
|
| 668 |
+
.attr("data-source-index", row.sourceIndex)
|
| 669 |
+
.attr("data-x", row.rawX)
|
| 670 |
+
.attr("data-value", row.value)
|
| 671 |
+
.attr("transform", `translate(${tx}, ${ty})`);
|
| 672 |
+
label.append("rect")
|
| 673 |
+
.attr("class", "key-point-label-bg")
|
| 674 |
+
.attr("width", labelW)
|
| 675 |
+
.attr("height", labelH)
|
| 676 |
+
.attr("rx", 7)
|
| 677 |
+
.attr("fill", primarySoft)
|
| 678 |
+
.attr("opacity", 0.98);
|
| 679 |
+
label.append("path")
|
| 680 |
+
.attr("class", "key-point-label-pointer")
|
| 681 |
+
.attr("d", `M ${pointerX - 8} ${labelH - 1} L ${pointerX + 8} ${labelH - 1} L ${pointerX} ${labelH + 14} Z`)
|
| 682 |
+
.attr("fill", primarySoft)
|
| 683 |
+
.attr("opacity", 0.98);
|
| 684 |
+
label.append("text")
|
| 685 |
+
.attr("class", "key-point-value chart-text")
|
| 686 |
+
.attr("x", labelW / 2)
|
| 687 |
+
.attr("y", labelH * 0.64)
|
| 688 |
+
.attr("text-anchor", "middle")
|
| 689 |
+
.attr("fill", "#06131A")
|
| 690 |
+
.style("font-family", titleFontFamily)
|
| 691 |
+
.style("font-size", `${Math.max(17, width * 0.015)}px`)
|
| 692 |
+
.style("font-weight", "900")
|
| 693 |
+
.text(valueText);
|
| 694 |
+
});
|
| 695 |
+
|
| 696 |
+
const tickKey = date => date instanceof Date ? date.getTime() : new Date(date).getTime();
|
| 697 |
+
const xTickDates = Array.from(
|
| 698 |
+
new Map(
|
| 699 |
+
[firstPoint.date, ...xScale.ticks(4), lastPoint.date]
|
| 700 |
+
.filter(date => date >= firstPoint.date && date <= lastPoint.date)
|
| 701 |
+
.map(date => [tickKey(date), date])
|
| 702 |
+
).values()
|
| 703 |
+
).sort((a, b) => a - b);
|
| 704 |
+
xTickDates.forEach(tick => {
|
| 705 |
+
const x = xScale(tick);
|
| 706 |
+
root.append("text")
|
| 707 |
+
.attr("class", "x-axis-label chart-text")
|
| 708 |
+
.attr("x", x)
|
| 709 |
+
.attr("y", plotBottom + 24)
|
| 710 |
+
.attr("text-anchor", "middle")
|
| 711 |
+
.attr("fill", "#D6DEE0")
|
| 712 |
+
.style("font-family", fontFamily)
|
| 713 |
+
.style("font-size", "13px")
|
| 714 |
+
.text(formatDateLabel(tick));
|
| 715 |
+
});
|
| 716 |
+
|
| 717 |
+
return svg.node();
|
| 718 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/layered_area_chart_03.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Layered Area Chart",
|
| 5 |
+
"chart_name": "layered_area_chart_03",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 50], [0, "inf"], [2, 6]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": ["gradient", "opacity"],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "dark",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
// 提取数据
|
| 27 |
+
const jsonData = data;
|
| 28 |
+
let chartData = jsonData.data.data;
|
| 29 |
+
const variables = jsonData.variables;
|
| 30 |
+
const typography = jsonData.typography;
|
| 31 |
+
const colors = jsonData.colors_dark || {};
|
| 32 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 33 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 34 |
+
const images = jsonData.images || {};
|
| 35 |
+
|
| 36 |
+
// 清空容器
|
| 37 |
+
d3.select(containerSelector).html("");
|
| 38 |
+
|
| 39 |
+
// 获取字段名
|
| 40 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 41 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 42 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 43 |
+
|
| 44 |
+
chartData = temporalFilter(chartData, xField);
|
| 45 |
+
if (chartData.length === 0) {
|
| 46 |
+
return;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// 获取唯一的组值并按最后y值排序
|
| 50 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))]
|
| 51 |
+
.sort((a, b) => {
|
| 52 |
+
const aLast = chartData.filter(d => d[groupField] === a).slice(-1)[0][yField];
|
| 53 |
+
const bLast = chartData.filter(d => d[groupField] === b).slice(-1)[0][yField];
|
| 54 |
+
return aLast - bLast;
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
// 设置尺寸和边距
|
| 58 |
+
const width = variables.width;
|
| 59 |
+
const height = variables.height;
|
| 60 |
+
const margin = { top: 20, right: 60, bottom: 40, left: 60 };
|
| 61 |
+
|
| 62 |
+
// 创建SVG
|
| 63 |
+
const svg = d3.select(containerSelector)
|
| 64 |
+
.append("svg")
|
| 65 |
+
.attr("width", "100%")
|
| 66 |
+
.attr("height", height)
|
| 67 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 68 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 69 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 70 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 71 |
+
|
| 72 |
+
// 创建图表区域
|
| 73 |
+
const chartWidth = width - margin.left - margin.right;
|
| 74 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 75 |
+
|
| 76 |
+
const g = svg.append("g")
|
| 77 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 78 |
+
|
| 79 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
|
| 80 |
+
|
| 81 |
+
// 创建y轴比例尺 (百分比)
|
| 82 |
+
const yScale = d3.scaleLinear()
|
| 83 |
+
.domain([0, d3.max(chartData, d => +d[yField]) * 1.5])
|
| 84 |
+
.range([chartHeight, 0]);
|
| 85 |
+
|
| 86 |
+
// 添加X轴线
|
| 87 |
+
g.append("line")
|
| 88 |
+
.attr("x1", -40)
|
| 89 |
+
.attr("y1", chartHeight)
|
| 90 |
+
.attr("x2", chartWidth)
|
| 91 |
+
.attr("y2", chartHeight)
|
| 92 |
+
.attr("stroke", "#9badd3")
|
| 93 |
+
.attr("opacity", 0.6)
|
| 94 |
+
.attr("stroke-width", 1)
|
| 95 |
+
|
| 96 |
+
// 添加水平网格线
|
| 97 |
+
const yTicks = d3.ticks(0, d3.max(chartData, d => +d[yField]) * 1.3, 5); // 根据实际数据生成刻度
|
| 98 |
+
|
| 99 |
+
// 添加y轴刻度和标签
|
| 100 |
+
yTicks.forEach(tick => {
|
| 101 |
+
if (tick !== 0) { // 跳过0刻度
|
| 102 |
+
g.append("text")
|
| 103 |
+
.attr("x", -10)
|
| 104 |
+
.attr("y", yScale(tick) + 15)
|
| 105 |
+
.attr("text-anchor", "end")
|
| 106 |
+
.attr("dominant-baseline", "middle")
|
| 107 |
+
.style("font-family", "Arial")
|
| 108 |
+
.style("font-size", "16px")
|
| 109 |
+
.style("fill", "#87aac0")
|
| 110 |
+
.text(tick);
|
| 111 |
+
}
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
// 添加x轴刻度和标签
|
| 115 |
+
xTicks.forEach((tick, i) => {
|
| 116 |
+
if (i === xTicks.length - 1) {
|
| 117 |
+
return;
|
| 118 |
+
}
|
| 119 |
+
// 添加刻度标签
|
| 120 |
+
g.append("text")
|
| 121 |
+
.attr("x", xScale(tick))
|
| 122 |
+
.attr("y", chartHeight + 20)
|
| 123 |
+
.attr("text-anchor", "middle")
|
| 124 |
+
.style("font-family", "Arial")
|
| 125 |
+
.style("font-size", "16px")
|
| 126 |
+
.style("font-weight", "bold")
|
| 127 |
+
.style("fill", "#87aac0")
|
| 128 |
+
.text(xFormat(tick));
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
// 创建面积生成器
|
| 132 |
+
const area = d3.area()
|
| 133 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 134 |
+
.y0(chartHeight)
|
| 135 |
+
.y1(d => yScale(+d[yField]))
|
| 136 |
+
.curve(d3.curveLinear);
|
| 137 |
+
|
| 138 |
+
// 创建渐变定义
|
| 139 |
+
const defs = svg.append("defs");
|
| 140 |
+
|
| 141 |
+
// 为每个组创建渐变
|
| 142 |
+
groups.forEach(group => {
|
| 143 |
+
const color = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 144 |
+
const gradientId = `gradient-${group.replace(/\s+/g, '-')}`;
|
| 145 |
+
|
| 146 |
+
const gradient = defs.append("linearGradient")
|
| 147 |
+
.attr("id", gradientId)
|
| 148 |
+
.attr("x1", "0%")
|
| 149 |
+
.attr("y1", "0%")
|
| 150 |
+
.attr("x2", "0%")
|
| 151 |
+
.attr("y2", "100%");
|
| 152 |
+
|
| 153 |
+
// 渐变起始颜色(原始颜色)
|
| 154 |
+
gradient.append("stop")
|
| 155 |
+
.attr("offset", "0%")
|
| 156 |
+
.attr("stop-color", d3.color(color))
|
| 157 |
+
.attr("stop-opacity", 1);
|
| 158 |
+
|
| 159 |
+
// 渐变结束颜色(偏白色)
|
| 160 |
+
gradient.append("stop")
|
| 161 |
+
.attr("offset", "100%")
|
| 162 |
+
.attr("stop-color", chartUtils.color.variant(color, { mode: "brighter", amount: 1.0 })) // 使颜色更亮(偏白)
|
| 163 |
+
.attr("stop-opacity", 1);
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
// 创建阴影滤镜 - 修复截断问题
|
| 167 |
+
const filters = defs.append("filter")
|
| 168 |
+
.attr("id", "drop-shadow")
|
| 169 |
+
.attr("x", "-50%") // 扩展滤镜区域左侧
|
| 170 |
+
.attr("y", "-100%") // 扩展滤镜区域上方
|
| 171 |
+
.attr("width", "200%") // 扩展滤镜区域宽度
|
| 172 |
+
.attr("height", "200%"); // 扩展滤镜区域高度
|
| 173 |
+
|
| 174 |
+
// 调整阴影颜色和透明度
|
| 175 |
+
filters.append("feComponentTransfer")
|
| 176 |
+
.append("feFuncA")
|
| 177 |
+
.attr("type", "linear")
|
| 178 |
+
.attr("slope", 0.2); // 降低阴影不透明度,使其更加柔和
|
| 179 |
+
|
| 180 |
+
// 添加高斯模糊
|
| 181 |
+
filters.append("feGaussianBlur")
|
| 182 |
+
.attr("in", "SourceAlpha")
|
| 183 |
+
.attr("stdDeviation", 25) // 调整模糊程度,使阴影更加柔和
|
| 184 |
+
.attr("result", "blur");
|
| 185 |
+
|
| 186 |
+
// 添加偏移
|
| 187 |
+
filters.append("feOffset")
|
| 188 |
+
.attr("in", "blur")
|
| 189 |
+
.attr("dx", 0)
|
| 190 |
+
.attr("dy", 15)
|
| 191 |
+
.attr("result", "offsetBlur");
|
| 192 |
+
|
| 193 |
+
// 合并原始图形和阴影
|
| 194 |
+
const feMerge = filters.append("feMerge");
|
| 195 |
+
feMerge.append("feMergeNode")
|
| 196 |
+
.attr("in", "offsetBlur");
|
| 197 |
+
feMerge.append("feMergeNode")
|
| 198 |
+
.attr("in", "SourceGraphic");
|
| 199 |
+
|
| 200 |
+
// 绘制面积图
|
| 201 |
+
// 按照组的顺序反向绘制,确保第一个组在最上面
|
| 202 |
+
[...groups].reverse().forEach(group => {
|
| 203 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 204 |
+
const gradientId = `gradient-${group.replace(/\s+/g, '-')}`;
|
| 205 |
+
|
| 206 |
+
// 绘制面积
|
| 207 |
+
g.append("path")
|
| 208 |
+
.datum(groupData)
|
| 209 |
+
.attr("fill", `url(#${gradientId})`) // 使用渐变填充
|
| 210 |
+
.attr("opacity", 0.8)
|
| 211 |
+
.attr("d", area)
|
| 212 |
+
.style("filter", "url(#drop-shadow)"); // 添加阴影效果
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
// 添加网格背景
|
| 216 |
+
g.append("rect")
|
| 217 |
+
.attr("class", "background")
|
| 218 |
+
.attr("x", 0)
|
| 219 |
+
.attr("y", yScale(d3.max(yTicks)))
|
| 220 |
+
.attr("width", chartWidth)
|
| 221 |
+
.attr("height", chartHeight - yScale(d3.max(yTicks)))
|
| 222 |
+
.attr("fill", "#87aac0")
|
| 223 |
+
.attr("opacity", 0.1);
|
| 224 |
+
|
| 225 |
+
// 添加垂直网格线
|
| 226 |
+
xTicks.forEach(tick => {
|
| 227 |
+
g.append("line")
|
| 228 |
+
.attr("x1", xScale(tick))
|
| 229 |
+
.attr("y1", yScale(d3.max(yTicks))) // 从y轴最大刻度开始
|
| 230 |
+
.attr("x2", xScale(tick))
|
| 231 |
+
.attr("y2", chartHeight)
|
| 232 |
+
.attr("stroke", "#87aac0")
|
| 233 |
+
.attr("opacity", 0.2)
|
| 234 |
+
.attr("class", "background")
|
| 235 |
+
.attr("stroke-width", 1)
|
| 236 |
+
});
|
| 237 |
+
// 添加水平网格线
|
| 238 |
+
yTicks.forEach(tick => {
|
| 239 |
+
g.append("line")
|
| 240 |
+
.attr("x1", -40)
|
| 241 |
+
.attr("y1", yScale(tick))
|
| 242 |
+
.attr("x2", chartWidth)
|
| 243 |
+
.attr("y2", yScale(tick))
|
| 244 |
+
.attr("stroke", "#87aac0")
|
| 245 |
+
.attr("opacity", 0.2)
|
| 246 |
+
.attr("stroke-width", 1)
|
| 247 |
+
.attr("class", "background")
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
// 获取最后一个刻度的x值
|
| 251 |
+
const lastTickX = xScale(xTicks[xTicks.length - 1]);
|
| 252 |
+
|
| 253 |
+
// 添加最后一年标注
|
| 254 |
+
g.append("text")
|
| 255 |
+
.attr("x", lastTickX)
|
| 256 |
+
.attr("y", yScale(d3.max(yTicks)) - 10) // 调整文本位置到y轴最大刻度上方
|
| 257 |
+
.attr("text-anchor", "middle")
|
| 258 |
+
.style("font-family", "Arial")
|
| 259 |
+
.style("font-size", "14px")
|
| 260 |
+
.style("fill", "#87aac0")
|
| 261 |
+
.text(xFormat(xTicks[xTicks.length - 1]));
|
| 262 |
+
|
| 263 |
+
// 添加倒置的蓝色小三角
|
| 264 |
+
g.append("path")
|
| 265 |
+
.attr("d", "M" + (lastTickX-5) + "," + yScale(d3.max(yTicks)) + " L" + (lastTickX+5) + "," + yScale(d3.max(yTicks)) + " L" + lastTickX + "," + (yScale(d3.max(yTicks))+5) + "Z")
|
| 266 |
+
.attr("fill", "#87aac0");
|
| 267 |
+
|
| 268 |
+
// 添加垂直虚线指向最后一年
|
| 269 |
+
g.append("line")
|
| 270 |
+
.attr("x1", lastTickX)
|
| 271 |
+
.attr("y1", yScale(d3.max(yTicks))+5)
|
| 272 |
+
.attr("x2", lastTickX)
|
| 273 |
+
.attr("y2", chartHeight)
|
| 274 |
+
.attr("stroke", "#87aac0")
|
| 275 |
+
.attr("stroke-width", 1)
|
| 276 |
+
.attr("stroke-dasharray", "3,3");
|
| 277 |
+
|
| 278 |
+
// 添加最终值标签
|
| 279 |
+
let prevLabelYBase = null; // 跟踪上一个标签的Y位置
|
| 280 |
+
const minLabelDistance = 50; // 最小标签间距
|
| 281 |
+
|
| 282 |
+
[...groups].forEach(group => {
|
| 283 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 284 |
+
const lastPoint = groupData[groupData.length - 1];
|
| 285 |
+
const color = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 286 |
+
|
| 287 |
+
// 计算标签基础位置
|
| 288 |
+
let labelYBase = yScale(+lastPoint[yField]);
|
| 289 |
+
let labelXBase = xScale(chartUtils.format.parseDate(lastPoint[xField]));
|
| 290 |
+
|
| 291 |
+
// 检查与上一个标签的距离,如果太近则调整位置
|
| 292 |
+
if (prevLabelYBase !== null && Math.abs(prevLabelYBase - labelYBase) < minLabelDistance) {
|
| 293 |
+
labelYBase = labelYBase - minLabelDistance;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
// 更新上一个标签位置
|
| 297 |
+
prevLabelYBase = labelYBase;
|
| 298 |
+
let textWidth = chartUtils.text.measure(null, group, { fontSize: 10 }).width * 1.3;
|
| 299 |
+
// 创建标签背景
|
| 300 |
+
const labelBg = g.append("rect")
|
| 301 |
+
.attr("x", labelXBase - textWidth / 2)
|
| 302 |
+
.attr("y", labelYBase - 50)
|
| 303 |
+
.attr("rx", 10)
|
| 304 |
+
.attr("ry", 10)
|
| 305 |
+
.attr("width", textWidth)
|
| 306 |
+
.attr("height", 40)
|
| 307 |
+
.attr("fill", color);
|
| 308 |
+
|
| 309 |
+
// 添加倒三角
|
| 310 |
+
g.append("path")
|
| 311 |
+
.attr("d", `M${labelXBase}, ${labelYBase}
|
| 312 |
+
L${labelXBase - 10}, ${labelYBase - 15}
|
| 313 |
+
L${labelXBase + 10}, ${labelYBase - 15} Z`)
|
| 314 |
+
.attr("fill", color);
|
| 315 |
+
|
| 316 |
+
// 添加组名(上部分文本)
|
| 317 |
+
g.append("text")
|
| 318 |
+
.attr("x", labelXBase)
|
| 319 |
+
.attr("y", labelYBase - 40)
|
| 320 |
+
.attr("text-anchor", "middle")
|
| 321 |
+
.attr("dominant-baseline", "middle")
|
| 322 |
+
.style("font-family", "Arial")
|
| 323 |
+
.style("font-size", "10px")
|
| 324 |
+
.style("fill", "#ffffff")
|
| 325 |
+
.style("opacity", 0.5)
|
| 326 |
+
.text(group);
|
| 327 |
+
|
| 328 |
+
// 添加最终值(下部分文本)
|
| 329 |
+
g.append("text")
|
| 330 |
+
.attr("x", labelXBase)
|
| 331 |
+
.attr("y", labelYBase - 22)
|
| 332 |
+
.attr("text-anchor", "middle")
|
| 333 |
+
.attr("dominant-baseline", "middle")
|
| 334 |
+
.style("font-family", "Arial")
|
| 335 |
+
.style("font-size", "22px")
|
| 336 |
+
.style("font-weight", "bold")
|
| 337 |
+
.style("fill", "#ffffff")
|
| 338 |
+
.text(chartUtils.format.integer(lastPoint[yField]).text);
|
| 339 |
+
|
| 340 |
+
});
|
| 341 |
+
|
| 342 |
+
return svg.node();
|
| 343 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/multiple_area_chart_01.js
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Area Chart",
|
| 5 |
+
"chart_name": "multiple_area_chart_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 50], [0, "inf"], [2, 2]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
const jsonData = data;
|
| 27 |
+
const chartData = jsonData.data.data;
|
| 28 |
+
const variables = jsonData.variables;
|
| 29 |
+
const typography = jsonData.typography || {};
|
| 30 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 31 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 32 |
+
|
| 33 |
+
d3.select(containerSelector).html("");
|
| 34 |
+
|
| 35 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 36 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 37 |
+
const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
|
| 38 |
+
const yLabel = yColumn.rawLabel || yField;
|
| 39 |
+
const yUnit = yColumn.unit || "";
|
| 40 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 41 |
+
let groups = [...new Set(chartData.map(d => d[groupField]))].slice(0, 2);
|
| 42 |
+
|
| 43 |
+
const width = +variables.width || 600;
|
| 44 |
+
const height = +variables.height || 600;
|
| 45 |
+
const svg = d3.select(containerSelector)
|
| 46 |
+
.append("svg")
|
| 47 |
+
.attr("width", "100%")
|
| 48 |
+
.attr("height", height)
|
| 49 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 50 |
+
.attr("style", "max-width: 100%; height: auto; background: #f8f0df;")
|
| 51 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 52 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 53 |
+
|
| 54 |
+
const defs = svg.append("defs");
|
| 55 |
+
defs.append("linearGradient")
|
| 56 |
+
.attr("id", "multiple-area-paper")
|
| 57 |
+
.attr("x1", "0%").attr("y1", "0%")
|
| 58 |
+
.attr("x2", "100%").attr("y2", "100%")
|
| 59 |
+
.call(grad => {
|
| 60 |
+
grad.append("stop").attr("offset", "0%").attr("stop-color", "#fff8e9");
|
| 61 |
+
grad.append("stop").attr("offset", "54%").attr("stop-color", "#f4ead6");
|
| 62 |
+
grad.append("stop").attr("offset", "100%").attr("stop-color", "#efe0c3");
|
| 63 |
+
});
|
| 64 |
+
defs.append("linearGradient")
|
| 65 |
+
.attr("id", "multiple-area-left-fill")
|
| 66 |
+
.attr("x1", "0%").attr("y1", "0%")
|
| 67 |
+
.attr("x2", "100%").attr("y2", "0%")
|
| 68 |
+
.call(grad => {
|
| 69 |
+
grad.append("stop").attr("offset", "0%").attr("stop-color", "#d84a42").attr("stop-opacity", 0.9);
|
| 70 |
+
grad.append("stop").attr("offset", "100%").attr("stop-color", "#b9151f").attr("stop-opacity", 0.96);
|
| 71 |
+
});
|
| 72 |
+
defs.append("linearGradient")
|
| 73 |
+
.attr("id", "multiple-area-right-fill")
|
| 74 |
+
.attr("x1", "0%").attr("y1", "0%")
|
| 75 |
+
.attr("x2", "100%").attr("y2", "0%")
|
| 76 |
+
.call(grad => {
|
| 77 |
+
grad.append("stop").attr("offset", "0%").attr("stop-color", "#6c934e").attr("stop-opacity", 0.96);
|
| 78 |
+
grad.append("stop").attr("offset", "100%").attr("stop-color", "#9eb776").attr("stop-opacity", 0.9);
|
| 79 |
+
});
|
| 80 |
+
defs.append("filter")
|
| 81 |
+
.attr("id", "multiple-area-soft-shadow")
|
| 82 |
+
.attr("x", "-20%").attr("y", "-20%")
|
| 83 |
+
.attr("width", "140%").attr("height", "140%")
|
| 84 |
+
.call(filter => {
|
| 85 |
+
filter.append("feDropShadow")
|
| 86 |
+
.attr("dx", 0)
|
| 87 |
+
.attr("dy", 2)
|
| 88 |
+
.attr("stdDeviation", 2)
|
| 89 |
+
.attr("flood-color", "#162233")
|
| 90 |
+
.attr("flood-opacity", 0.18);
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
svg.append("rect")
|
| 94 |
+
.attr("x", 8)
|
| 95 |
+
.attr("y", 8)
|
| 96 |
+
.attr("width", width - 16)
|
| 97 |
+
.attr("height", height - 16)
|
| 98 |
+
.attr("fill", "url(#multiple-area-paper)")
|
| 99 |
+
.attr("stroke", "#0e2134")
|
| 100 |
+
.attr("stroke-width", 1.25);
|
| 101 |
+
|
| 102 |
+
const fontFamily = "Georgia, 'Times New Roman', serif";
|
| 103 |
+
const sansFamily = (typography.label && typography.label.font_family) || "Arial, sans-serif";
|
| 104 |
+
const navy = "#0d2238";
|
| 105 |
+
const muted = "#4d463c";
|
| 106 |
+
const red = "#a51519";
|
| 107 |
+
const green = "#2d6830";
|
| 108 |
+
const portrait = height > width * 1.15;
|
| 109 |
+
|
| 110 |
+
function fitFont(text, maxWidth, start, min, weight, family) {
|
| 111 |
+
let size = start;
|
| 112 |
+
while (size > min && chartUtils.text.measure(null, String(text), {
|
| 113 |
+
fontFamily: family,
|
| 114 |
+
fontSize: size,
|
| 115 |
+
fontWeight: weight
|
| 116 |
+
}).width > maxWidth) {
|
| 117 |
+
size -= 1;
|
| 118 |
+
}
|
| 119 |
+
return size;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function wrapLines(text, maxWidth, fontSize, fontWeight, family, maxLines) {
|
| 123 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 124 |
+
const lines = [];
|
| 125 |
+
let current = "";
|
| 126 |
+
words.forEach(word => {
|
| 127 |
+
const test = current ? `${current} ${word}` : word;
|
| 128 |
+
const widthTest = chartUtils.text.measure(null, test, {
|
| 129 |
+
fontFamily: family,
|
| 130 |
+
fontSize,
|
| 131 |
+
fontWeight
|
| 132 |
+
}).width;
|
| 133 |
+
if (widthTest <= maxWidth || !current) {
|
| 134 |
+
current = test;
|
| 135 |
+
} else {
|
| 136 |
+
lines.push(current);
|
| 137 |
+
current = word;
|
| 138 |
+
}
|
| 139 |
+
});
|
| 140 |
+
if (current) lines.push(current);
|
| 141 |
+
return lines.slice(0, maxLines);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
function drawTextBlock(parent, lines, x, y, options) {
|
| 145 |
+
const opts = Object.assign({
|
| 146 |
+
fill: navy,
|
| 147 |
+
fontSize: 12,
|
| 148 |
+
fontWeight: 400,
|
| 149 |
+
family: fontFamily,
|
| 150 |
+
anchor: "middle",
|
| 151 |
+
lineHeight: 1.18,
|
| 152 |
+
style: null
|
| 153 |
+
}, options || {});
|
| 154 |
+
const text = parent.append("text")
|
| 155 |
+
.attr("x", x)
|
| 156 |
+
.attr("y", y)
|
| 157 |
+
.attr("text-anchor", opts.anchor)
|
| 158 |
+
.attr("fill", opts.fill)
|
| 159 |
+
.style("font-family", opts.family)
|
| 160 |
+
.style("font-size", `${opts.fontSize}px`)
|
| 161 |
+
.style("font-weight", opts.fontWeight);
|
| 162 |
+
if (opts.style) text.style("font-style", opts.style);
|
| 163 |
+
lines.forEach((line, i) => {
|
| 164 |
+
text.append("tspan")
|
| 165 |
+
.attr("x", x)
|
| 166 |
+
.attr("dy", i === 0 ? 0 : `${opts.fontSize * opts.lineHeight}px`)
|
| 167 |
+
.text(line);
|
| 168 |
+
});
|
| 169 |
+
return text;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
function temporalNumber(value) {
|
| 173 |
+
if (value instanceof Date) return value.getTime();
|
| 174 |
+
const numeric = +value;
|
| 175 |
+
if (Number.isFinite(numeric)) return numeric;
|
| 176 |
+
const parsed = chartUtils.format.parseDate(value);
|
| 177 |
+
if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed.getTime();
|
| 178 |
+
const fallback = new Date(value);
|
| 179 |
+
return fallback instanceof Date && !Number.isNaN(fallback.getTime()) ? fallback.getTime() : 0;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
function temporalLabel(value) {
|
| 183 |
+
const numeric = +value;
|
| 184 |
+
if (Number.isFinite(numeric) && numeric >= 1000 && numeric <= 3000) return String(Math.round(numeric));
|
| 185 |
+
const parsed = chartUtils.format.parseDate(value);
|
| 186 |
+
if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return String(parsed.getFullYear());
|
| 187 |
+
return String(value);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
function niceValueMax(value) {
|
| 191 |
+
if (!Number.isFinite(value) || value <= 0) return 1;
|
| 192 |
+
const power = Math.pow(10, Math.floor(Math.log10(value)));
|
| 193 |
+
const scaled = value / power;
|
| 194 |
+
const step = scaled <= 1 ? 1 : scaled <= 2 ? 2 : scaled <= 5 ? 5 : 10;
|
| 195 |
+
return step * power;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
function drawReservedSlot(parent, x, y, w, h, slotName, shape) {
|
| 199 |
+
const slot = parent.append("g")
|
| 200 |
+
.attr("class", "asset-slot")
|
| 201 |
+
.attr("data-asset-slot", slotName)
|
| 202 |
+
.attr("data-asset-source-policy", "image2_asset")
|
| 203 |
+
.attr("data-anchor", "header-safe-area")
|
| 204 |
+
.attr("data-collision-rule", "avoid-title-subtitle-category-labels-and-plot");
|
| 205 |
+
if (shape === "circle") {
|
| 206 |
+
slot.append("circle")
|
| 207 |
+
.attr("cx", x + w / 2)
|
| 208 |
+
.attr("cy", y + h / 2)
|
| 209 |
+
.attr("r", Math.min(w, h) / 2)
|
| 210 |
+
.attr("fill", "#eef0dc")
|
| 211 |
+
.attr("stroke", "#b9ad8e")
|
| 212 |
+
.attr("stroke-width", 1.2);
|
| 213 |
+
slot.append("circle")
|
| 214 |
+
.attr("cx", x + w / 2)
|
| 215 |
+
.attr("cy", y + h / 2)
|
| 216 |
+
.attr("r", Math.min(w, h) / 2 - 7)
|
| 217 |
+
.attr("fill", "none")
|
| 218 |
+
.attr("stroke", "#d2c6a4")
|
| 219 |
+
.attr("stroke-dasharray", "3 4");
|
| 220 |
+
} else {
|
| 221 |
+
slot.append("rect")
|
| 222 |
+
.attr("x", x)
|
| 223 |
+
.attr("y", y)
|
| 224 |
+
.attr("width", w)
|
| 225 |
+
.attr("height", h)
|
| 226 |
+
.attr("rx", 18)
|
| 227 |
+
.attr("fill", "#f1ead7")
|
| 228 |
+
.attr("stroke", "#bead85")
|
| 229 |
+
.attr("stroke-width", 1.1)
|
| 230 |
+
.attr("stroke-dasharray", "5 5")
|
| 231 |
+
.attr("opacity", 0.75);
|
| 232 |
+
slot.append("path")
|
| 233 |
+
.attr("d", `M${x + 14},${y + h - 14} C${x + w * 0.35},${y + h * 0.25} ${x + w * 0.62},${y + h * 0.76} ${x + w - 14},${y + 14}`)
|
| 234 |
+
.attr("fill", "none")
|
| 235 |
+
.attr("stroke", "#c7b891")
|
| 236 |
+
.attr("stroke-width", 1)
|
| 237 |
+
.attr("opacity", 0.8);
|
| 238 |
+
}
|
| 239 |
+
return slot;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
const titles = jsonData.titles || {};
|
| 243 |
+
const metadata = jsonData.metadata || {};
|
| 244 |
+
const titleText = titles.main_title || metadata.title || "Multiple Area Comparison";
|
| 245 |
+
const subtitleText = titles.sub_title || metadata.description || jsonData.description || "";
|
| 246 |
+
const titleSize = fitFont(titleText, width - 170, 35, 24, 700, fontFamily);
|
| 247 |
+
const titleLines = wrapLines(titleText, width - 170, titleSize, 700, fontFamily, 2);
|
| 248 |
+
drawTextBlock(svg, titleLines, width / 2, 48, {
|
| 249 |
+
fill: navy,
|
| 250 |
+
fontSize: titleSize,
|
| 251 |
+
fontWeight: 700,
|
| 252 |
+
family: fontFamily,
|
| 253 |
+
anchor: "middle",
|
| 254 |
+
lineHeight: 1.05
|
| 255 |
+
});
|
| 256 |
+
const titleBlockHeight = titleLines.length * titleSize * 1.05;
|
| 257 |
+
const subtitleSize = fitFont(subtitleText, width - 160, 18, 13, 500, fontFamily);
|
| 258 |
+
const subtitleLines = wrapLines(subtitleText, width - 168, subtitleSize, 500, fontFamily, 2);
|
| 259 |
+
drawTextBlock(svg, subtitleLines, width / 2, 52 + titleBlockHeight, {
|
| 260 |
+
fill: navy,
|
| 261 |
+
fontSize: subtitleSize,
|
| 262 |
+
fontWeight: 500,
|
| 263 |
+
family: fontFamily,
|
| 264 |
+
style: "italic",
|
| 265 |
+
anchor: "middle",
|
| 266 |
+
lineHeight: 1.15
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
const headerSlotY = portrait ? 124 : 55;
|
| 270 |
+
const headerSlotW = portrait ? 112 : 88;
|
| 271 |
+
const headerSlotH = portrait ? 72 : 56;
|
| 272 |
+
drawReservedSlot(svg, 25, headerSlotY, headerSlotW, headerSlotH, "header-left-illustration", "rect");
|
| 273 |
+
drawReservedSlot(svg, width - 25 - headerSlotW, headerSlotY, headerSlotW, headerSlotH, "header-right-illustration", "rect");
|
| 274 |
+
|
| 275 |
+
const deckY = portrait ? 224 : 122;
|
| 276 |
+
const deckText = metadata.main_insight || jsonData.description || "";
|
| 277 |
+
const deckX = portrait ? 166 : 150;
|
| 278 |
+
const deckW = portrait ? width - 332 : width - 300;
|
| 279 |
+
const deckFont = portrait ? 13 : 12;
|
| 280 |
+
const deckLines = wrapLines(deckText, deckW - 28, deckFont, 400, fontFamily, 2);
|
| 281 |
+
svg.append("rect")
|
| 282 |
+
.attr("x", deckX)
|
| 283 |
+
.attr("y", deckY - (portrait ? 24 : 19))
|
| 284 |
+
.attr("width", deckW)
|
| 285 |
+
.attr("height", Math.max(portrait ? 48 : 34, deckLines.length * (portrait ? 18 : 15) + 13))
|
| 286 |
+
.attr("rx", 8)
|
| 287 |
+
.attr("fill", "#f7eedb")
|
| 288 |
+
.attr("stroke", "#cfbd94")
|
| 289 |
+
.attr("opacity", 0.92);
|
| 290 |
+
drawTextBlock(svg, deckLines, width / 2, deckY, {
|
| 291 |
+
fill: muted,
|
| 292 |
+
fontSize: deckFont,
|
| 293 |
+
family: fontFamily,
|
| 294 |
+
anchor: "middle",
|
| 295 |
+
lineHeight: 1.2
|
| 296 |
+
});
|
| 297 |
+
|
| 298 |
+
const plot = {
|
| 299 |
+
x: portrait ? 52 : 45,
|
| 300 |
+
y: portrait ? 370 : 185,
|
| 301 |
+
width: width - (portrait ? 104 : 90),
|
| 302 |
+
height: portrait ? Math.min(420, height * 0.43) : 300
|
| 303 |
+
};
|
| 304 |
+
const centerX = width / 2;
|
| 305 |
+
const axisWidth = Math.max(48, width * 0.08);
|
| 306 |
+
const axisLeft = centerX - axisWidth / 2;
|
| 307 |
+
const axisRight = centerX + axisWidth / 2;
|
| 308 |
+
const leftAreaMinX = plot.x + 18;
|
| 309 |
+
const rightAreaMaxX = plot.x + plot.width - 18;
|
| 310 |
+
|
| 311 |
+
const grouped = groups.map((group, index) => {
|
| 312 |
+
const rows = chartData
|
| 313 |
+
.filter(d => d[groupField] === group)
|
| 314 |
+
.map(d => Object.assign({}, d, {
|
| 315 |
+
__time: temporalNumber(d[xField]),
|
| 316 |
+
__label: temporalLabel(d[xField]),
|
| 317 |
+
__value: Math.max(0, +d[yField] || 0)
|
| 318 |
+
}))
|
| 319 |
+
.sort((a, b) => a.__time - b.__time);
|
| 320 |
+
return { group, index, rows, maxValue: d3.max(rows, d => d.__value) || 1 };
|
| 321 |
+
});
|
| 322 |
+
|
| 323 |
+
const allTimes = [...new Set(grouped.flatMap(series => series.rows.map(d => d.__time)))].sort((a, b) => a - b);
|
| 324 |
+
const minTime = d3.min(allTimes);
|
| 325 |
+
const maxTime = d3.max(allTimes);
|
| 326 |
+
const yScale = d3.scaleLinear()
|
| 327 |
+
.domain([minTime, maxTime])
|
| 328 |
+
.range([plot.y + 18, plot.y + plot.height - 18]);
|
| 329 |
+
|
| 330 |
+
const leftScale = d3.scaleLinear()
|
| 331 |
+
.domain([0, grouped[0] ? grouped[0].maxValue : 1])
|
| 332 |
+
.range([axisLeft, leftAreaMinX]);
|
| 333 |
+
const rightScale = d3.scaleLinear()
|
| 334 |
+
.domain([0, grouped[1] ? grouped[1].maxValue : 1])
|
| 335 |
+
.range([axisRight, rightAreaMaxX]);
|
| 336 |
+
|
| 337 |
+
const tickValues = allTimes;
|
| 338 |
+
tickValues.forEach(time => {
|
| 339 |
+
const y = yScale(time);
|
| 340 |
+
svg.append("line")
|
| 341 |
+
.attr("x1", plot.x)
|
| 342 |
+
.attr("x2", plot.x + plot.width)
|
| 343 |
+
.attr("y1", y)
|
| 344 |
+
.attr("y2", y)
|
| 345 |
+
.attr("stroke", "#b8afa0")
|
| 346 |
+
.attr("stroke-width", 0.8)
|
| 347 |
+
.attr("stroke-dasharray", "3 4")
|
| 348 |
+
.attr("opacity", 0.75);
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
const areaLeft = d3.area()
|
| 352 |
+
.x0(axisLeft)
|
| 353 |
+
.x1(d => leftScale(d.__value))
|
| 354 |
+
.y(d => yScale(d.__time))
|
| 355 |
+
.curve(d3.curveLinear);
|
| 356 |
+
const areaRight = d3.area()
|
| 357 |
+
.x0(axisRight)
|
| 358 |
+
.x1(d => rightScale(d.__value))
|
| 359 |
+
.y(d => yScale(d.__time))
|
| 360 |
+
.curve(d3.curveLinear);
|
| 361 |
+
|
| 362 |
+
if (grouped[0]) {
|
| 363 |
+
svg.append("path")
|
| 364 |
+
.datum(grouped[0].rows)
|
| 365 |
+
.attr("class", "series-area series-area-left")
|
| 366 |
+
.attr("data-series", grouped[0].group)
|
| 367 |
+
.attr("fill", "url(#multiple-area-left-fill)")
|
| 368 |
+
.attr("stroke", "#95121b")
|
| 369 |
+
.attr("stroke-width", 2)
|
| 370 |
+
.attr("d", areaLeft);
|
| 371 |
+
}
|
| 372 |
+
if (grouped[1]) {
|
| 373 |
+
svg.append("path")
|
| 374 |
+
.datum(grouped[1].rows)
|
| 375 |
+
.attr("class", "series-area series-area-right")
|
| 376 |
+
.attr("data-series", grouped[1].group)
|
| 377 |
+
.attr("fill", "url(#multiple-area-right-fill)")
|
| 378 |
+
.attr("stroke", "#315f2d")
|
| 379 |
+
.attr("stroke-width", 2)
|
| 380 |
+
.attr("d", areaRight);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
const axisTop = plot.y - 26;
|
| 384 |
+
const axisHeight = plot.height + 45;
|
| 385 |
+
svg.append("rect")
|
| 386 |
+
.attr("class", "central-year-axis")
|
| 387 |
+
.attr("data-axis-role", "chronological-year-spine")
|
| 388 |
+
.attr("x", axisLeft)
|
| 389 |
+
.attr("y", axisTop)
|
| 390 |
+
.attr("width", axisWidth)
|
| 391 |
+
.attr("height", axisHeight)
|
| 392 |
+
.attr("rx", 11)
|
| 393 |
+
.attr("fill", "#0c3345")
|
| 394 |
+
.attr("filter", "url(#multiple-area-soft-shadow)");
|
| 395 |
+
svg.append("text")
|
| 396 |
+
.attr("x", centerX)
|
| 397 |
+
.attr("y", axisTop + 23)
|
| 398 |
+
.attr("text-anchor", "middle")
|
| 399 |
+
.attr("fill", "#ffffff")
|
| 400 |
+
.style("font-family", fontFamily)
|
| 401 |
+
.style("font-size", "15px")
|
| 402 |
+
.style("font-weight", 700)
|
| 403 |
+
.text("Year");
|
| 404 |
+
|
| 405 |
+
tickValues.forEach(time => {
|
| 406 |
+
const y = yScale(time);
|
| 407 |
+
const source = grouped[0] && grouped[0].rows.find(d => d.__time === time);
|
| 408 |
+
const label = source ? source.__label : String(time);
|
| 409 |
+
svg.append("text")
|
| 410 |
+
.attr("x", centerX)
|
| 411 |
+
.attr("y", y + 4)
|
| 412 |
+
.attr("text-anchor", "middle")
|
| 413 |
+
.attr("fill", "#ffffff")
|
| 414 |
+
.style("font-family", sansFamily)
|
| 415 |
+
.style("font-size", "14px")
|
| 416 |
+
.text(label);
|
| 417 |
+
[axisLeft, axisRight].forEach(x => {
|
| 418 |
+
svg.append("circle")
|
| 419 |
+
.attr("class", "axis-edge-dot")
|
| 420 |
+
.attr("data-axis-row", label)
|
| 421 |
+
.attr("cx", x)
|
| 422 |
+
.attr("cy", y)
|
| 423 |
+
.attr("r", 3.5)
|
| 424 |
+
.attr("fill", "#f8f0df")
|
| 425 |
+
.attr("stroke", "#ffffff")
|
| 426 |
+
.attr("stroke-width", 0.8);
|
| 427 |
+
});
|
| 428 |
+
});
|
| 429 |
+
|
| 430 |
+
function groupHeadingText(group) {
|
| 431 |
+
const cleaned = String(group || "").replace(/\s*\([^)]*\)\s*/g, "").trim();
|
| 432 |
+
return cleaned || String(group || "");
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
function drawSeriesHeading(series, side) {
|
| 436 |
+
if (!series) return;
|
| 437 |
+
const isLeft = side === "left";
|
| 438 |
+
const medallionSize = portrait ? 58 : 48;
|
| 439 |
+
const medallionY = plot.y - (portrait ? 93 : 42);
|
| 440 |
+
const medallionX = isLeft ? plot.x + 10 : plot.x + plot.width - medallionSize - 10;
|
| 441 |
+
const labelX = isLeft ? medallionX + medallionSize + 20 : medallionX - 20;
|
| 442 |
+
const headingY = medallionY + (portrait ? 21 : 14);
|
| 443 |
+
const anchor = isLeft ? "start" : "end";
|
| 444 |
+
const accent = isLeft ? red : green;
|
| 445 |
+
drawReservedSlot(svg, medallionX, medallionY, medallionSize, medallionSize, isLeft ? "series-left-medallion" : "series-right-medallion", "circle");
|
| 446 |
+
const heading = groupHeadingText(series.group);
|
| 447 |
+
const headingMaxWidth = portrait ? 220 : 190;
|
| 448 |
+
const headingSize = fitFont(heading, headingMaxWidth, portrait ? 22 : 18, 13, 700, fontFamily);
|
| 449 |
+
svg.append("text")
|
| 450 |
+
.attr("x", labelX)
|
| 451 |
+
.attr("y", headingY)
|
| 452 |
+
.attr("text-anchor", anchor)
|
| 453 |
+
.attr("fill", accent)
|
| 454 |
+
.style("font-family", fontFamily)
|
| 455 |
+
.style("font-size", `${headingSize}px`)
|
| 456 |
+
.style("font-weight", 700)
|
| 457 |
+
.text(heading);
|
| 458 |
+
const descriptor = `${yLabel}${yUnit ? ` (${yUnit})` : ""}`;
|
| 459 |
+
wrapLines(descriptor, headingMaxWidth, portrait ? 14 : 11, 400, fontFamily, 2).forEach((line, i) => {
|
| 460 |
+
svg.append("text")
|
| 461 |
+
.attr("x", labelX)
|
| 462 |
+
.attr("y", headingY + (portrait ? 24 : 17) + i * (portrait ? 17 : 14))
|
| 463 |
+
.attr("text-anchor", anchor)
|
| 464 |
+
.attr("fill", "#2f2c29")
|
| 465 |
+
.style("font-family", fontFamily)
|
| 466 |
+
.style("font-size", `${portrait ? 14 : 11}px`)
|
| 467 |
+
.text(line);
|
| 468 |
+
});
|
| 469 |
+
}
|
| 470 |
+
drawSeriesHeading(grouped[0], "left");
|
| 471 |
+
drawSeriesHeading(grouped[1], "right");
|
| 472 |
+
|
| 473 |
+
const sideDomain = niceValueMax(d3.max(grouped, s => s.maxValue) || 1);
|
| 474 |
+
const labelTicks = d3.ticks(0, sideDomain, 6).filter((_, i, arr) => i === 0 || i === arr.length - 1 || i % 1 === 0);
|
| 475 |
+
const leftLabelScale = d3.scaleLinear().domain([0, sideDomain]).range([axisLeft, leftAreaMinX]);
|
| 476 |
+
const rightLabelScale = d3.scaleLinear().domain([0, sideDomain]).range([axisRight, rightAreaMaxX]);
|
| 477 |
+
svg.append("line")
|
| 478 |
+
.attr("x1", leftAreaMinX)
|
| 479 |
+
.attr("x2", axisLeft)
|
| 480 |
+
.attr("y1", plot.y + plot.height + 8)
|
| 481 |
+
.attr("y2", plot.y + plot.height + 8)
|
| 482 |
+
.attr("stroke", "#9f8a60")
|
| 483 |
+
.attr("stroke-width", 1);
|
| 484 |
+
svg.append("line")
|
| 485 |
+
.attr("x1", axisRight)
|
| 486 |
+
.attr("x2", rightAreaMaxX)
|
| 487 |
+
.attr("y1", plot.y + plot.height + 8)
|
| 488 |
+
.attr("y2", plot.y + plot.height + 8)
|
| 489 |
+
.attr("stroke", "#9f8a60")
|
| 490 |
+
.attr("stroke-width", 1);
|
| 491 |
+
labelTicks.forEach(tick => {
|
| 492 |
+
const label = chartUtils.format.autoText(tick);
|
| 493 |
+
svg.append("text")
|
| 494 |
+
.attr("x", leftLabelScale(tick))
|
| 495 |
+
.attr("y", plot.y + plot.height + 26)
|
| 496 |
+
.attr("text-anchor", "middle")
|
| 497 |
+
.attr("fill", red)
|
| 498 |
+
.style("font-family", sansFamily)
|
| 499 |
+
.style("font-size", "12px")
|
| 500 |
+
.style("font-weight", 700)
|
| 501 |
+
.text(label);
|
| 502 |
+
svg.append("text")
|
| 503 |
+
.attr("x", rightLabelScale(tick))
|
| 504 |
+
.attr("y", plot.y + plot.height + 26)
|
| 505 |
+
.attr("text-anchor", "middle")
|
| 506 |
+
.attr("fill", green)
|
| 507 |
+
.style("font-family", sansFamily)
|
| 508 |
+
.style("font-size", "12px")
|
| 509 |
+
.style("font-weight", 700)
|
| 510 |
+
.text(label);
|
| 511 |
+
});
|
| 512 |
+
|
| 513 |
+
const leftCaption = `${yLabel}${yUnit ? ` (${yUnit})` : ""}`;
|
| 514 |
+
const rightCaption = leftCaption;
|
| 515 |
+
drawTextBlock(svg, wrapLines(leftCaption, 170, 12, 700, fontFamily, 1), centerX - 145, plot.y + plot.height + 52, {
|
| 516 |
+
fill: "#8e151a",
|
| 517 |
+
fontSize: 12,
|
| 518 |
+
fontWeight: 700,
|
| 519 |
+
family: fontFamily
|
| 520 |
+
});
|
| 521 |
+
drawTextBlock(svg, wrapLines(rightCaption, 170, 12, 700, fontFamily, 1), centerX + 145, plot.y + plot.height + 52, {
|
| 522 |
+
fill: "#27602e",
|
| 523 |
+
fontSize: 12,
|
| 524 |
+
fontWeight: 700,
|
| 525 |
+
family: fontFamily
|
| 526 |
+
});
|
| 527 |
+
|
| 528 |
+
svg.append("line")
|
| 529 |
+
.attr("x1", centerX - 236)
|
| 530 |
+
.attr("x2", centerX - 178)
|
| 531 |
+
.attr("y1", plot.y + plot.height + 48)
|
| 532 |
+
.attr("y2", plot.y + plot.height + 48)
|
| 533 |
+
.attr("stroke", "#a58d5e");
|
| 534 |
+
svg.append("line")
|
| 535 |
+
.attr("x1", centerX + 178)
|
| 536 |
+
.attr("x2", centerX + 236)
|
| 537 |
+
.attr("y1", plot.y + plot.height + 48)
|
| 538 |
+
.attr("y2", plot.y + plot.height + 48)
|
| 539 |
+
.attr("stroke", "#a58d5e");
|
| 540 |
+
|
| 541 |
+
svg.append("g")
|
| 542 |
+
.attr("class", "ignored-wrapper-region")
|
| 543 |
+
.attr("data-ignored-reference-element", "bottom-takeaway-source-footer")
|
| 544 |
+
.attr("data-policy", "no-bottom-narrative-wrapper");
|
| 545 |
+
|
| 546 |
+
return svg.node();
|
| 547 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/range_area_chart_01.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Range Area Chart",
|
| 5 |
+
"chart_name": "range_area_chart_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 50], [0, "inf"], [2, 2]],
|
| 9 |
+
"required_fields_icons": ["group"],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "overlay",
|
| 18 |
+
"icon_label": "side",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes",
|
| 21 |
+
"chart_for": "comparison"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data || {};
|
| 28 |
+
const rows = jsonData.data?.data || [];
|
| 29 |
+
const variables = jsonData.variables || {};
|
| 30 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 31 |
+
const fontSize = chartUtils.text.fontSize;
|
| 32 |
+
|
| 33 |
+
d3.select(containerSelector).html("");
|
| 34 |
+
|
| 35 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 36 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 37 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 38 |
+
const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
|
| 39 |
+
const yDescription = yColumn.description || yField || "Value";
|
| 40 |
+
const yUnit = yColumn.unit || "";
|
| 41 |
+
|
| 42 |
+
const width = Number(variables.width) || 600;
|
| 43 |
+
const height = Number(variables.height) || 600;
|
| 44 |
+
const navy = "#0f2742";
|
| 45 |
+
const muted = "#6f766f";
|
| 46 |
+
const panelStroke = "#e8ded0";
|
| 47 |
+
const gridStroke = "#eadfce";
|
| 48 |
+
const orange = "#f28a13";
|
| 49 |
+
const green = "#5d8f3b";
|
| 50 |
+
const cream = "#fbf8f1";
|
| 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", "range-area-chart-01-reference-layout")
|
| 61 |
+
.attr("data-no-embedded-raster", "true")
|
| 62 |
+
.attr("data-no-copied-reference-art", "true");
|
| 63 |
+
|
| 64 |
+
const empty = message => {
|
| 65 |
+
svg.append("rect").attr("width", width).attr("height", height).attr("fill", cream);
|
| 66 |
+
svg.append("text")
|
| 67 |
+
.attr("x", width / 2)
|
| 68 |
+
.attr("y", height / 2)
|
| 69 |
+
.attr("text-anchor", "middle")
|
| 70 |
+
.style("font-family", "Arial, sans-serif")
|
| 71 |
+
.style("font-size", `${fontSize(14)}px`)
|
| 72 |
+
.style("fill", navy)
|
| 73 |
+
.text(message);
|
| 74 |
+
return svg.node();
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
if (!xField || !yField || !groupField || rows.length < 4) {
|
| 78 |
+
return empty("Missing range area fields");
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
const parseX = value => chartUtils.format.parseDate(value);
|
| 82 |
+
const validRows = rows
|
| 83 |
+
.map(d => ({ raw: d, x: parseX(d[xField]), y: +d[yField], group: d[groupField] }))
|
| 84 |
+
.filter(d => d.x instanceof Date && !Number.isNaN(d.x.getTime()) && Number.isFinite(d.y) && d.group != null);
|
| 85 |
+
const groups = Array.from(new Set(validRows.map(d => d.group)));
|
| 86 |
+
if (groups.length < 2) return empty("Need two groups");
|
| 87 |
+
|
| 88 |
+
const averages = groups
|
| 89 |
+
.map(group => {
|
| 90 |
+
const values = validRows.filter(d => d.group === group).map(d => d.y);
|
| 91 |
+
return { group, avg: d3.mean(values) };
|
| 92 |
+
})
|
| 93 |
+
.sort((a, b) => d3.descending(a.avg, b.avg));
|
| 94 |
+
const upperGroup = averages[0].group;
|
| 95 |
+
const lowerGroup = averages[averages.length - 1].group;
|
| 96 |
+
|
| 97 |
+
const byGroup = group => validRows
|
| 98 |
+
.filter(d => d.group === group)
|
| 99 |
+
.sort((a, b) => d3.ascending(a.x, b.x));
|
| 100 |
+
const upperData = byGroup(upperGroup);
|
| 101 |
+
const lowerData = byGroup(lowerGroup);
|
| 102 |
+
const lowerByTime = new Map(lowerData.map(d => [d.x.getTime(), d]));
|
| 103 |
+
const areaData = upperData
|
| 104 |
+
.map(top => ({ top, bottom: lowerByTime.get(top.x.getTime()) }))
|
| 105 |
+
.filter(d => d.bottom);
|
| 106 |
+
if (!areaData.length) return empty("No paired range values");
|
| 107 |
+
|
| 108 |
+
const allDates = validRows.map(d => d.x);
|
| 109 |
+
const xExtent = d3.extent(allDates);
|
| 110 |
+
const yExtent = d3.extent(validRows, d => d.y);
|
| 111 |
+
const yPad = Math.max(1, (yExtent[1] - yExtent[0]) * 0.18);
|
| 112 |
+
const yDomain = [Math.max(0, yExtent[0] - yPad), yExtent[1] + yPad];
|
| 113 |
+
|
| 114 |
+
const legendY = 22;
|
| 115 |
+
const panel = {
|
| 116 |
+
x: 56,
|
| 117 |
+
y: 76,
|
| 118 |
+
width: Math.max(420, width - 96),
|
| 119 |
+
height: Math.max(390, height - 138),
|
| 120 |
+
rx: 10
|
| 121 |
+
};
|
| 122 |
+
const margin = { top: 38, right: 42, bottom: 48, left: 56 };
|
| 123 |
+
const plot = {
|
| 124 |
+
x: panel.x + margin.left,
|
| 125 |
+
y: panel.y + margin.top,
|
| 126 |
+
width: panel.width - margin.left - margin.right,
|
| 127 |
+
height: panel.height - margin.top - margin.bottom
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
const xScale = d3.scaleTime().domain(xExtent).range([plot.x, plot.x + plot.width]);
|
| 131 |
+
const yScale = d3.scaleLinear().domain(yDomain).nice(4).range([plot.y + plot.height, plot.y]);
|
| 132 |
+
|
| 133 |
+
const uniqueDates = Array.from(new Map(validRows.map(d => [d.x.getTime(), d.x])).values())
|
| 134 |
+
.sort((a, b) => d3.ascending(a, b));
|
| 135 |
+
const targetTicks = Math.min(5, uniqueDates.length);
|
| 136 |
+
const xTicks = uniqueDates.filter((_, i) => {
|
| 137 |
+
if (i === 0 || i === uniqueDates.length - 1) return true;
|
| 138 |
+
const step = Math.max(1, Math.round((uniqueDates.length - 1) / (targetTicks - 1)));
|
| 139 |
+
return i % step === 0;
|
| 140 |
+
}).slice(0, 6);
|
| 141 |
+
if (xTicks[xTicks.length - 1]?.getTime() !== uniqueDates[uniqueDates.length - 1].getTime()) {
|
| 142 |
+
xTicks.push(uniqueDates[uniqueDates.length - 1]);
|
| 143 |
+
}
|
| 144 |
+
const yTicks = yScale.ticks(5).filter(t => t >= 0);
|
| 145 |
+
const yearFormat = d => chartUtils.format.date(d, { output: "%Y" }).text;
|
| 146 |
+
const valueText = value => chartUtils.format.autoText(+value).text || chartUtils.format.autoText(+value);
|
| 147 |
+
const valueWithUnit = value => `${valueText(value)}${yUnit && !String(valueText(value)).includes(yUnit) ? yUnit : ""}`;
|
| 148 |
+
|
| 149 |
+
const defs = svg.append("defs");
|
| 150 |
+
const fillGradientId = "rangeArea01Fill";
|
| 151 |
+
defs.append("linearGradient")
|
| 152 |
+
.attr("id", fillGradientId)
|
| 153 |
+
.attr("x1", "0%")
|
| 154 |
+
.attr("x2", "0%")
|
| 155 |
+
.attr("y1", "0%")
|
| 156 |
+
.attr("y2", "100%")
|
| 157 |
+
.selectAll("stop")
|
| 158 |
+
.data([
|
| 159 |
+
{ offset: "0%", color: orange, opacity: 0.30 },
|
| 160 |
+
{ offset: "58%", color: "#f5c57c", opacity: 0.18 },
|
| 161 |
+
{ offset: "100%", color: green, opacity: 0.16 }
|
| 162 |
+
])
|
| 163 |
+
.enter()
|
| 164 |
+
.append("stop")
|
| 165 |
+
.attr("offset", d => d.offset)
|
| 166 |
+
.attr("stop-color", d => d.color)
|
| 167 |
+
.attr("stop-opacity", d => d.opacity);
|
| 168 |
+
|
| 169 |
+
svg.append("rect")
|
| 170 |
+
.attr("x", panel.x)
|
| 171 |
+
.attr("y", panel.y)
|
| 172 |
+
.attr("width", panel.width)
|
| 173 |
+
.attr("height", panel.height)
|
| 174 |
+
.attr("rx", panel.rx)
|
| 175 |
+
.attr("fill", "#fffdf8")
|
| 176 |
+
.attr("stroke", panelStroke)
|
| 177 |
+
.attr("stroke-width", 1.2)
|
| 178 |
+
.attr("filter", "drop-shadow(0px 1px 4px rgba(68, 42, 17, 0.12))");
|
| 179 |
+
|
| 180 |
+
const legendItems = [
|
| 181 |
+
{ group: upperGroup, color: orange },
|
| 182 |
+
{ group: lowerGroup, color: green }
|
| 183 |
+
];
|
| 184 |
+
const legend = svg.append("g")
|
| 185 |
+
.attr("class", "legend-pills")
|
| 186 |
+
.attr("data-relationship", "independent-centered-row")
|
| 187 |
+
.attr("transform", `translate(${width / 2}, ${legendY})`);
|
| 188 |
+
const pillWidth = Math.min(188, Math.max(130, width * 0.28));
|
| 189 |
+
const pillGap = 14;
|
| 190 |
+
legendItems.forEach((item, i) => {
|
| 191 |
+
const x = (i - 0.5) * (pillWidth + pillGap) - pillWidth / 2;
|
| 192 |
+
const pill = legend.append("g").attr("transform", `translate(${x}, 0)`);
|
| 193 |
+
pill.append("rect")
|
| 194 |
+
.attr("x", 0)
|
| 195 |
+
.attr("y", 0)
|
| 196 |
+
.attr("width", pillWidth)
|
| 197 |
+
.attr("height", 30)
|
| 198 |
+
.attr("rx", 15)
|
| 199 |
+
.attr("fill", "#fffefa")
|
| 200 |
+
.attr("stroke", panelStroke)
|
| 201 |
+
.attr("stroke-width", 1);
|
| 202 |
+
pill.append("circle")
|
| 203 |
+
.attr("cx", 16)
|
| 204 |
+
.attr("cy", 15)
|
| 205 |
+
.attr("r", 7)
|
| 206 |
+
.attr("fill", item.color);
|
| 207 |
+
pill.append("line")
|
| 208 |
+
.attr("x1", 32)
|
| 209 |
+
.attr("x2", 58)
|
| 210 |
+
.attr("y1", 15)
|
| 211 |
+
.attr("y2", 15)
|
| 212 |
+
.attr("stroke", item.color)
|
| 213 |
+
.attr("stroke-width", 3)
|
| 214 |
+
.attr("stroke-linecap", "round");
|
| 215 |
+
pill.append("text")
|
| 216 |
+
.attr("x", 66)
|
| 217 |
+
.attr("y", 16)
|
| 218 |
+
.attr("dominant-baseline", "middle")
|
| 219 |
+
.style("font-family", "Arial, sans-serif")
|
| 220 |
+
.style("font-size", `${fontSize(10)}px`)
|
| 221 |
+
.style("font-weight", "700")
|
| 222 |
+
.style("fill", navy)
|
| 223 |
+
.text(`${String(item.group).slice(0, 25)}${yUnit ? ` (${yUnit})` : ""}`);
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
yTicks.forEach(tick => {
|
| 227 |
+
const y = yScale(tick);
|
| 228 |
+
svg.append("line")
|
| 229 |
+
.attr("class", "gridline")
|
| 230 |
+
.attr("x1", plot.x)
|
| 231 |
+
.attr("x2", plot.x + plot.width)
|
| 232 |
+
.attr("y1", y)
|
| 233 |
+
.attr("y2", y)
|
| 234 |
+
.attr("stroke", gridStroke)
|
| 235 |
+
.attr("stroke-width", 1)
|
| 236 |
+
.attr("stroke-dasharray", "2 4");
|
| 237 |
+
svg.append("text")
|
| 238 |
+
.attr("x", plot.x - 12)
|
| 239 |
+
.attr("y", y)
|
| 240 |
+
.attr("text-anchor", "end")
|
| 241 |
+
.attr("dominant-baseline", "middle")
|
| 242 |
+
.style("font-family", "Arial, sans-serif")
|
| 243 |
+
.style("font-size", `${fontSize(10)}px`)
|
| 244 |
+
.style("fill", muted)
|
| 245 |
+
.text(valueText(tick));
|
| 246 |
+
});
|
| 247 |
+
|
| 248 |
+
xTicks.forEach(tick => {
|
| 249 |
+
const x = xScale(tick);
|
| 250 |
+
svg.append("line")
|
| 251 |
+
.attr("x1", x)
|
| 252 |
+
.attr("x2", x)
|
| 253 |
+
.attr("y1", plot.y)
|
| 254 |
+
.attr("y2", plot.y + plot.height)
|
| 255 |
+
.attr("stroke", gridStroke)
|
| 256 |
+
.attr("stroke-width", 1)
|
| 257 |
+
.attr("stroke-dasharray", "2 4");
|
| 258 |
+
svg.append("text")
|
| 259 |
+
.attr("x", x)
|
| 260 |
+
.attr("y", plot.y + plot.height + 24)
|
| 261 |
+
.attr("text-anchor", "middle")
|
| 262 |
+
.style("font-family", "Arial, sans-serif")
|
| 263 |
+
.style("font-size", `${fontSize(11)}px`)
|
| 264 |
+
.style("fill", "#4f554e")
|
| 265 |
+
.text(yearFormat(tick));
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
svg.append("line")
|
| 269 |
+
.attr("x1", plot.x)
|
| 270 |
+
.attr("x2", plot.x + plot.width)
|
| 271 |
+
.attr("y1", plot.y + plot.height)
|
| 272 |
+
.attr("y2", plot.y + plot.height)
|
| 273 |
+
.attr("stroke", "#9a9a90")
|
| 274 |
+
.attr("stroke-width", 1.2);
|
| 275 |
+
svg.append("line")
|
| 276 |
+
.attr("x1", plot.x)
|
| 277 |
+
.attr("x2", plot.x)
|
| 278 |
+
.attr("y1", plot.y)
|
| 279 |
+
.attr("y2", plot.y + plot.height)
|
| 280 |
+
.attr("stroke", "#9a9a90")
|
| 281 |
+
.attr("stroke-width", 1.2);
|
| 282 |
+
svg.append("text")
|
| 283 |
+
.attr("x", plot.x)
|
| 284 |
+
.attr("y", panel.y + 20)
|
| 285 |
+
.style("font-family", "Arial, sans-serif")
|
| 286 |
+
.style("font-size", `${fontSize(10)}px`)
|
| 287 |
+
.style("font-weight", "700")
|
| 288 |
+
.style("fill", "#757a73")
|
| 289 |
+
.text(yDescription.length > 42 ? `${yDescription.slice(0, 39)}...` : yDescription);
|
| 290 |
+
|
| 291 |
+
const line = d3.line()
|
| 292 |
+
.x(d => xScale(d.x))
|
| 293 |
+
.y(d => yScale(d.y))
|
| 294 |
+
.curve(d3.curveMonotoneX);
|
| 295 |
+
const area = d3.area()
|
| 296 |
+
.x(d => xScale(d.top.x))
|
| 297 |
+
.y0(d => yScale(Math.min(d.top.y, d.bottom.y)))
|
| 298 |
+
.y1(d => yScale(Math.max(d.top.y, d.bottom.y)))
|
| 299 |
+
.curve(d3.curveMonotoneX);
|
| 300 |
+
|
| 301 |
+
svg.append("path")
|
| 302 |
+
.datum(areaData)
|
| 303 |
+
.attr("class", "range-area-fill")
|
| 304 |
+
.attr("d", area)
|
| 305 |
+
.attr("fill", `url(#${fillGradientId})`);
|
| 306 |
+
svg.append("path")
|
| 307 |
+
.datum(upperData)
|
| 308 |
+
.attr("class", "upper-range-boundary")
|
| 309 |
+
.attr("d", line)
|
| 310 |
+
.attr("fill", "none")
|
| 311 |
+
.attr("stroke", orange)
|
| 312 |
+
.attr("stroke-width", 3)
|
| 313 |
+
.attr("stroke-linecap", "round")
|
| 314 |
+
.attr("stroke-linejoin", "round");
|
| 315 |
+
svg.append("path")
|
| 316 |
+
.datum(lowerData)
|
| 317 |
+
.attr("class", "lower-range-boundary")
|
| 318 |
+
.attr("d", line)
|
| 319 |
+
.attr("fill", "none")
|
| 320 |
+
.attr("stroke", green)
|
| 321 |
+
.attr("stroke-width", 3)
|
| 322 |
+
.attr("stroke-linecap", "round")
|
| 323 |
+
.attr("stroke-linejoin", "round");
|
| 324 |
+
|
| 325 |
+
const endpointGroups = [
|
| 326 |
+
{ role: "start-upper", d: upperData[0], color: orange, dx: 16, dy: -22, anchor: "start" },
|
| 327 |
+
{ role: "end-upper", d: upperData[upperData.length - 1], color: orange, dx: 16, dy: -12, anchor: "start" },
|
| 328 |
+
{ role: "start-lower", d: lowerData[0], color: green, dx: -16, dy: 4, anchor: "end" },
|
| 329 |
+
{ role: "end-lower", d: lowerData[lowerData.length - 1], color: green, dx: 16, dy: 12, anchor: "start" }
|
| 330 |
+
];
|
| 331 |
+
endpointGroups.forEach(item => {
|
| 332 |
+
const x = xScale(item.d.x);
|
| 333 |
+
const y = yScale(item.d.y);
|
| 334 |
+
svg.append("circle")
|
| 335 |
+
.attr("class", `endpoint-marker ${item.role}`)
|
| 336 |
+
.attr("cx", x)
|
| 337 |
+
.attr("cy", y)
|
| 338 |
+
.attr("r", 6)
|
| 339 |
+
.attr("fill", "#fffefa")
|
| 340 |
+
.attr("stroke", item.color)
|
| 341 |
+
.attr("stroke-width", 3);
|
| 342 |
+
svg.append("text")
|
| 343 |
+
.attr("class", `endpoint-label ${item.role}`)
|
| 344 |
+
.attr("x", Math.max(panel.x + 14, Math.min(panel.x + panel.width - 14, x + item.dx)))
|
| 345 |
+
.attr("y", Math.max(panel.y + 18, Math.min(panel.y + panel.height - 18, y + item.dy)))
|
| 346 |
+
.attr("text-anchor", item.anchor)
|
| 347 |
+
.attr("dominant-baseline", "middle")
|
| 348 |
+
.style("font-family", "Arial, sans-serif")
|
| 349 |
+
.style("font-size", `${fontSize(19)}px`)
|
| 350 |
+
.style("font-weight", "800")
|
| 351 |
+
.style("fill", item.color)
|
| 352 |
+
.text(valueWithUnit(item.d.y));
|
| 353 |
+
});
|
| 354 |
+
|
| 355 |
+
const drawCallout = (top, bottom, side) => {
|
| 356 |
+
const ratio = bottom.y === 0 ? null : top.y / bottom.y;
|
| 357 |
+
if (!ratio || !Number.isFinite(ratio)) return;
|
| 358 |
+
const anchorX = xScale(top.x);
|
| 359 |
+
const midY = (yScale(top.y) + yScale(bottom.y)) / 2;
|
| 360 |
+
const cardW = 68;
|
| 361 |
+
const cardH = 70;
|
| 362 |
+
const clampX = x => Math.max(panel.x + 8, Math.min(panel.x + panel.width - cardW - 8, x));
|
| 363 |
+
const clampY = y => Math.max(panel.y + 48, Math.min(panel.y + panel.height - cardH - 38, y));
|
| 364 |
+
const preferredX = side === "start" ? anchorX + 16 : anchorX - cardW - 16;
|
| 365 |
+
const gapTop = Math.min(yScale(top.y), yScale(bottom.y));
|
| 366 |
+
const gapBottom = Math.max(yScale(top.y), yScale(bottom.y));
|
| 367 |
+
const candidates = [
|
| 368 |
+
{ x: clampX(preferredX), y: clampY(midY - cardH / 2) },
|
| 369 |
+
{ x: clampX(preferredX), y: clampY(gapTop + 12) },
|
| 370 |
+
{ x: clampX(preferredX), y: clampY(gapBottom - cardH - 12) },
|
| 371 |
+
{ x: clampX(side === "start" ? anchorX + 32 : anchorX - cardW - 32), y: clampY(midY - cardH / 2) },
|
| 372 |
+
{ x: clampX(side === "start" ? anchorX - cardW - 16 : anchorX + 16), y: clampY(midY - cardH / 2) }
|
| 373 |
+
];
|
| 374 |
+
const linePoints = [];
|
| 375 |
+
const addLineSamples = series => {
|
| 376 |
+
const points = series.map(d => ({ x: xScale(d.x), y: yScale(d.y) }));
|
| 377 |
+
points.forEach(p => linePoints.push(p));
|
| 378 |
+
for (let i = 0; i < points.length - 1; i += 1) {
|
| 379 |
+
const a = points[i];
|
| 380 |
+
const b = points[i + 1];
|
| 381 |
+
const steps = Math.max(4, Math.ceil(Math.abs(b.x - a.x) / 18));
|
| 382 |
+
for (let j = 1; j < steps; j += 1) {
|
| 383 |
+
const t = j / steps;
|
| 384 |
+
linePoints.push({
|
| 385 |
+
x: a.x + (b.x - a.x) * t,
|
| 386 |
+
y: a.y + (b.y - a.y) * t
|
| 387 |
+
});
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
};
|
| 391 |
+
addLineSamples(upperData);
|
| 392 |
+
addLineSamples(lowerData);
|
| 393 |
+
const markerPad = 10;
|
| 394 |
+
const scoreCandidate = c => {
|
| 395 |
+
const x0 = c.x - markerPad;
|
| 396 |
+
const x1 = c.x + cardW + markerPad;
|
| 397 |
+
const y0 = c.y - markerPad;
|
| 398 |
+
const y1 = c.y + cardH + markerPad;
|
| 399 |
+
const overlaps = linePoints.filter(p => p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1).length;
|
| 400 |
+
const edgePenalty = Math.abs(c.x - preferredX) * 0.01 + Math.abs((c.y + cardH / 2) - midY) * 0.01;
|
| 401 |
+
return overlaps * 1000 + edgePenalty;
|
| 402 |
+
};
|
| 403 |
+
const ranked = candidates
|
| 404 |
+
.map(c => ({ ...c, score: scoreCandidate(c) }))
|
| 405 |
+
.sort((a, b) => d3.ascending(a.score, b.score));
|
| 406 |
+
if (!ranked.length || ranked[0].score >= 1000) return;
|
| 407 |
+
const x = ranked[0].x;
|
| 408 |
+
const y = ranked[0].y;
|
| 409 |
+
const connectorX = side === "start" ? x : x + cardW;
|
| 410 |
+
const connectorY = y + cardH / 2;
|
| 411 |
+
svg.append("line")
|
| 412 |
+
.attr("class", "ratio-callout-leader")
|
| 413 |
+
.attr("x1", connectorX)
|
| 414 |
+
.attr("y1", connectorY)
|
| 415 |
+
.attr("x2", anchorX)
|
| 416 |
+
.attr("y2", midY)
|
| 417 |
+
.attr("stroke", "#e2a35e")
|
| 418 |
+
.attr("stroke-width", 1.3)
|
| 419 |
+
.attr("stroke-dasharray", "3 3");
|
| 420 |
+
const g = svg.append("g")
|
| 421 |
+
.attr("class", "ratio-callout")
|
| 422 |
+
.attr("data-relationship", "floating-gap-midpoint")
|
| 423 |
+
.attr("transform", `translate(${x}, ${y})`);
|
| 424 |
+
g.append("rect")
|
| 425 |
+
.attr("width", cardW)
|
| 426 |
+
.attr("height", cardH)
|
| 427 |
+
.attr("rx", 9)
|
| 428 |
+
.attr("fill", "#fffefa")
|
| 429 |
+
.attr("stroke", "#e7dccc")
|
| 430 |
+
.attr("stroke-width", 1.1);
|
| 431 |
+
g.append("text")
|
| 432 |
+
.attr("x", cardW / 2)
|
| 433 |
+
.attr("y", 20)
|
| 434 |
+
.attr("text-anchor", "middle")
|
| 435 |
+
.style("font-family", "Arial, sans-serif")
|
| 436 |
+
.style("font-size", `${fontSize(18)}px`)
|
| 437 |
+
.style("font-weight", "800")
|
| 438 |
+
.style("fill", side === "start" ? orange : green)
|
| 439 |
+
.text(chartUtils.format.fixed(ratio, 1).text);
|
| 440 |
+
g.append("text")
|
| 441 |
+
.attr("x", cardW / 2)
|
| 442 |
+
.attr("y", 38)
|
| 443 |
+
.attr("text-anchor", "middle")
|
| 444 |
+
.style("font-family", "Arial, sans-serif")
|
| 445 |
+
.style("font-size", `${fontSize(9)}px`)
|
| 446 |
+
.style("font-weight", "700")
|
| 447 |
+
.style("fill", "#4f554e")
|
| 448 |
+
.text("Ratio");
|
| 449 |
+
g.append("text")
|
| 450 |
+
.attr("x", cardW / 2)
|
| 451 |
+
.attr("y", 52)
|
| 452 |
+
.attr("text-anchor", "middle")
|
| 453 |
+
.style("font-family", "Arial, sans-serif")
|
| 454 |
+
.style("font-size", `${fontSize(7)}px`)
|
| 455 |
+
.style("fill", "#686d66")
|
| 456 |
+
.text("upper/lower");
|
| 457 |
+
};
|
| 458 |
+
drawCallout(upperData[0], lowerByTime.get(upperData[0].x.getTime()), "start");
|
| 459 |
+
drawCallout(upperData[upperData.length - 1], lowerByTime.get(upperData[upperData.length - 1].x.getTime()), "end");
|
| 460 |
+
|
| 461 |
+
svg.append("g")
|
| 462 |
+
.attr("class", "reserved-asset-slot title-art-slot")
|
| 463 |
+
.attr("data-type", "image-slot")
|
| 464 |
+
.attr("data-asset-slot", "range-area-title-art")
|
| 465 |
+
.attr("data-slot-policy", "image2_asset_or_neutral_placeholder")
|
| 466 |
+
.attr("data-asset-source-policy", "image2_asset_or_neutral_placeholder")
|
| 467 |
+
.attr("data-slot-anchor", "top-center-title-zone")
|
| 468 |
+
.attr("data-slot-collision", "stay-above-title-and-do-not-overlap-legend-or-chart-panel")
|
| 469 |
+
.attr("data-bbox", `${(width / 2 - 18).toFixed(1)},0,36,20`)
|
| 470 |
+
.attr("data-no-embedded-raster", "true")
|
| 471 |
+
.attr("data-no-copied-reference-art", "true")
|
| 472 |
+
.attr("opacity", 0);
|
| 473 |
+
|
| 474 |
+
return svg.node();
|
| 475 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/small_multiple_area_chart_01.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Small Multiple Area Chart",
|
| 5 |
+
"chart_name": "small_multiple_area_chart_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 50], [0, "inf"], [2, 20]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": ["background"],
|
| 13 |
+
"supported_effects": ["gradient", "opacity"],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "dark",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
// 提取数据
|
| 27 |
+
const jsonData = data;
|
| 28 |
+
const chartData = jsonData.data.data;
|
| 29 |
+
const variables = jsonData.variables;
|
| 30 |
+
const typography = jsonData.typography;
|
| 31 |
+
const colors = jsonData.colors_dark || {};
|
| 32 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 33 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 34 |
+
const images = jsonData.images || {};
|
| 35 |
+
|
| 36 |
+
// 清空容器
|
| 37 |
+
d3.select(containerSelector).html("");
|
| 38 |
+
|
| 39 |
+
// 获取字段名
|
| 40 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 41 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 42 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 43 |
+
|
| 44 |
+
// 获取唯一的组值
|
| 45 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 46 |
+
|
| 47 |
+
// 计算最长组名的宽度,用于动态调整左边距
|
| 48 |
+
const maxGroupNameWidth = d3.max(groups, group => chartUtils.text.measure(null, group, { fontSize: 14, fontWeight: "bold" }).width) || 0;
|
| 49 |
+
const dynamicLeftMargin = Math.max(120, maxGroupNameWidth + 40);
|
| 50 |
+
|
| 51 |
+
// 设置尺寸和边距 - 动态调整左侧边距和右侧边距(为Y轴留出空间)
|
| 52 |
+
const width = variables.width;
|
| 53 |
+
const height = variables.height;
|
| 54 |
+
const margin = {
|
| 55 |
+
top: 60,
|
| 56 |
+
right: 60, // 增加右边距为Y轴留出空间
|
| 57 |
+
bottom: 60,
|
| 58 |
+
left: dynamicLeftMargin
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
// 创建SVG
|
| 62 |
+
const svg = d3.select(containerSelector)
|
| 63 |
+
.append("svg")
|
| 64 |
+
.attr("width", "100%")
|
| 65 |
+
.attr("height", height)
|
| 66 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 67 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 68 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 69 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 70 |
+
|
| 71 |
+
// 创建图表区域
|
| 72 |
+
const chartWidth = width - margin.left - margin.right;
|
| 73 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 74 |
+
|
| 75 |
+
const g = svg.append("g")
|
| 76 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 77 |
+
|
| 78 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
|
| 79 |
+
|
| 80 |
+
// 为每个组计算最大值
|
| 81 |
+
const groupMaxValues = {};
|
| 82 |
+
groups.forEach(group => {
|
| 83 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 84 |
+
groupMaxValues[group] = d3.max(groupData, d => +d[yField]);
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
// 计算每个组的垂直位置(上下错开)
|
| 88 |
+
const groupHeight = chartHeight / (groups.length * 1.5); // 每个组的高度,留出一些间距
|
| 89 |
+
const groupPositions = {};
|
| 90 |
+
groups.forEach((group, i) => {
|
| 91 |
+
groupPositions[group] = i * groupHeight * 1.5; // 乘以1.5是为了留出间距
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
// 创建y轴比例尺(每个组有自己的比例尺)
|
| 95 |
+
const yScales = {};
|
| 96 |
+
groups.forEach(group => {
|
| 97 |
+
yScales[group] = d3.scaleLinear()
|
| 98 |
+
.domain([0, groupMaxValues[group]])
|
| 99 |
+
.range([groupHeight, 0]); // 从下到上
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
// 添加网格线和x轴刻度
|
| 103 |
+
|
| 104 |
+
// 添加垂直网格线
|
| 105 |
+
xTicks.forEach(tick => {
|
| 106 |
+
g.append("line")
|
| 107 |
+
.attr("x1", xScale(tick))
|
| 108 |
+
.attr("y1", 0)
|
| 109 |
+
.attr("x2", xScale(tick))
|
| 110 |
+
.attr("y2", chartHeight)
|
| 111 |
+
.attr("stroke", "#ffffff")
|
| 112 |
+
.attr("stroke-opacity", 0.1)
|
| 113 |
+
.attr("stroke-width", 1);
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
xTicks.forEach(tick => {
|
| 117 |
+
// 添加上方x轴年份标签
|
| 118 |
+
g.append("text")
|
| 119 |
+
.attr("x", xScale(tick))
|
| 120 |
+
.attr("y", -10)
|
| 121 |
+
.attr("text-anchor", "middle")
|
| 122 |
+
.attr("dominant-baseline", "bottom")
|
| 123 |
+
.attr("fill", "#ffffff")
|
| 124 |
+
.style("font-size", "12px")
|
| 125 |
+
.text(xFormat(tick));
|
| 126 |
+
|
| 127 |
+
// 添加下方x轴年份标签
|
| 128 |
+
g.append("text")
|
| 129 |
+
.attr("x", xScale(tick))
|
| 130 |
+
.attr("y", chartHeight + 15)
|
| 131 |
+
.attr("text-anchor", "middle")
|
| 132 |
+
.attr("dominant-baseline", "hanging")
|
| 133 |
+
.attr("fill", "#ffffff")
|
| 134 |
+
.style("font-size", "12px")
|
| 135 |
+
.text(xFormat(tick));
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
// 创建面积生成器
|
| 139 |
+
const area = (group) => d3.area()
|
| 140 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 141 |
+
.y0(groupPositions[group] + groupHeight) // 底部是固定的
|
| 142 |
+
.y1(d => groupPositions[group] + yScales[group](d[yField])) // 顶部根据数据变化
|
| 143 |
+
.curve(d3.curveBasis); // 使用平滑曲线
|
| 144 |
+
|
| 145 |
+
// 为每个组创建面积图和Y轴
|
| 146 |
+
groups.forEach(group => {
|
| 147 |
+
// 过滤该组的数据
|
| 148 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 149 |
+
|
| 150 |
+
// 确保数据按日期排序
|
| 151 |
+
groupData.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 152 |
+
|
| 153 |
+
// 获取颜色
|
| 154 |
+
const color = colorResolver.field(group, groups.indexOf(group), { palette: "category10" }).value;
|
| 155 |
+
|
| 156 |
+
// 添加0线 - 水平线位于组的底部位置,延伸到图表结束
|
| 157 |
+
g.append("line")
|
| 158 |
+
.attr("x1", -margin.left + 20) // 从左侧边缘开始
|
| 159 |
+
.attr("y1", groupPositions[group] + groupHeight) // y0位置
|
| 160 |
+
.attr("x2", chartWidth) // 延伸到图表区域结束
|
| 161 |
+
.attr("y2", groupPositions[group] + groupHeight)
|
| 162 |
+
.attr("stroke", color)
|
| 163 |
+
.attr("stroke-width", 1.5)
|
| 164 |
+
.attr("stroke-opacity", 0.8);
|
| 165 |
+
|
| 166 |
+
// 绘制面积
|
| 167 |
+
g.append("path")
|
| 168 |
+
.datum(groupData)
|
| 169 |
+
.attr("fill", color)
|
| 170 |
+
.attr("d", area(group));
|
| 171 |
+
|
| 172 |
+
// 添加组名标签 - 放在面积图左侧,距离更远
|
| 173 |
+
g.append("text")
|
| 174 |
+
.attr("x", -margin.left + 20) // 增加与图表的距离
|
| 175 |
+
.attr("y", groupPositions[group] + groupHeight - 10)
|
| 176 |
+
.attr("text-anchor", "start") // 左对齐
|
| 177 |
+
.attr("dominant-baseline", "middle")
|
| 178 |
+
.attr("fill", color)
|
| 179 |
+
.attr("font-weight", "bold")
|
| 180 |
+
.style("font-size", "14px")
|
| 181 |
+
.text(group);
|
| 182 |
+
|
| 183 |
+
// 创建Y轴 - 在右侧
|
| 184 |
+
const yAxis = d3.axisRight(yScales[group])
|
| 185 |
+
.ticks(3) // 减少刻度数量,避免拥挤
|
| 186 |
+
.tickSize(0) // 不显示刻度线
|
| 187 |
+
.tickFormat(d => chartUtils.format.autoText(+(d))); // 使用简洁格式
|
| 188 |
+
|
| 189 |
+
// 添加Y轴
|
| 190 |
+
const yAxisGroup = g.append("g")
|
| 191 |
+
.attr("transform", `translate(${chartWidth}, ${groupPositions[group]})`)
|
| 192 |
+
.call(yAxis);
|
| 193 |
+
|
| 194 |
+
// 设置Y轴样式
|
| 195 |
+
yAxisGroup.selectAll("path")
|
| 196 |
+
.attr("stroke", color);
|
| 197 |
+
|
| 198 |
+
yAxisGroup.selectAll("text")
|
| 199 |
+
.attr("fill", color)
|
| 200 |
+
.attr("font-size", "10px")
|
| 201 |
+
.attr("dx", "0.5em"); // 向右移动文本
|
| 202 |
+
|
| 203 |
+
// 添加水平辅助线
|
| 204 |
+
yScales[group].ticks(3).forEach(tick => {
|
| 205 |
+
if (tick > 0) { // 跳过0线,因为已经单独添加了
|
| 206 |
+
g.append("line")
|
| 207 |
+
.attr("x1", 0)
|
| 208 |
+
.attr("y1", groupPositions[group] + yScales[group](tick))
|
| 209 |
+
.attr("x2", chartWidth)
|
| 210 |
+
.attr("y2", groupPositions[group] + yScales[group](tick))
|
| 211 |
+
.attr("stroke", color)
|
| 212 |
+
.attr("stroke-opacity", 0.2)
|
| 213 |
+
.attr("stroke-width", 0.5)
|
| 214 |
+
.attr("stroke-dasharray", "2,2"); // 虚线
|
| 215 |
+
}
|
| 216 |
+
});
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
return svg.node();
|
| 220 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/small_multiple_area_chart_02.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Small Multiple Area Chart",
|
| 5 |
+
"chart_name": "small_multiple_area_chart_02",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 50], [0, "inf"], [2, 20]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": ["background"],
|
| 13 |
+
"supported_effects": ["gradient", "opacity"],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
// 提取数据
|
| 27 |
+
const jsonData = data;
|
| 28 |
+
const chartData = jsonData.data.data;
|
| 29 |
+
const variables = jsonData.variables;
|
| 30 |
+
const typography = jsonData.typography;
|
| 31 |
+
const colors = jsonData.colors || {};
|
| 32 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 33 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 34 |
+
const images = jsonData.images || {};
|
| 35 |
+
|
| 36 |
+
// 清空容器
|
| 37 |
+
d3.select(containerSelector).html("");
|
| 38 |
+
|
| 39 |
+
// 获取字段名
|
| 40 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 41 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 42 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 43 |
+
|
| 44 |
+
// 获取唯一的组值
|
| 45 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 46 |
+
|
| 47 |
+
// 计算最长组名的宽度,用于动态调整左边距
|
| 48 |
+
const maxGroupNameWidth = d3.max(groups, group => chartUtils.text.measure(null, group, { fontSize: 14, fontWeight: "bold" }).width) || 0;
|
| 49 |
+
const dynamicLeftMargin = Math.max(120, maxGroupNameWidth + 40);
|
| 50 |
+
|
| 51 |
+
// 设置尺寸和边距 - 动态调整左侧边距和右侧边距(为Y轴留出空间)
|
| 52 |
+
const width = variables.width;
|
| 53 |
+
const height = variables.height;
|
| 54 |
+
const margin = {
|
| 55 |
+
top: 60,
|
| 56 |
+
right: 60, // 增加右边距为Y轴留出空间
|
| 57 |
+
bottom: 60,
|
| 58 |
+
left: dynamicLeftMargin
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
// 创建SVG
|
| 62 |
+
const svg = d3.select(containerSelector)
|
| 63 |
+
.append("svg")
|
| 64 |
+
.attr("width", "100%")
|
| 65 |
+
.attr("height", height)
|
| 66 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 67 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 68 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 69 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 70 |
+
|
| 71 |
+
// 创建图表区域
|
| 72 |
+
const chartWidth = width - margin.left - margin.right;
|
| 73 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 74 |
+
|
| 75 |
+
const g = svg.append("g")
|
| 76 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 77 |
+
|
| 78 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
|
| 79 |
+
|
| 80 |
+
// 为每个组计算最大值
|
| 81 |
+
const groupMaxValues = {};
|
| 82 |
+
groups.forEach(group => {
|
| 83 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 84 |
+
groupMaxValues[group] = d3.max(groupData, d => +d[yField]);
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
// 计算每个组的垂直位置(上下错开)
|
| 88 |
+
const groupHeight = chartHeight / (groups.length * 1.5); // 每个组的高度,留出一些间距
|
| 89 |
+
const groupPositions = {};
|
| 90 |
+
groups.forEach((group, i) => {
|
| 91 |
+
groupPositions[group] = i * groupHeight * 1.5; // 乘以1.5是为了留出间距
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
// 创建y轴比例尺(每个组有自己的比例尺)
|
| 95 |
+
const yScales = {};
|
| 96 |
+
groups.forEach(group => {
|
| 97 |
+
yScales[group] = d3.scaleLinear()
|
| 98 |
+
.domain([0, groupMaxValues[group]])
|
| 99 |
+
.range([groupHeight, 0]); // 从下到上
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
// 添加网格线和x轴刻度
|
| 103 |
+
|
| 104 |
+
// 添加垂直网格线
|
| 105 |
+
xTicks.forEach(tick => {
|
| 106 |
+
g.append("line")
|
| 107 |
+
.attr("x1", xScale(tick))
|
| 108 |
+
.attr("y1", 0)
|
| 109 |
+
.attr("x2", xScale(tick))
|
| 110 |
+
.attr("y2", chartHeight)
|
| 111 |
+
.attr("stroke", "#e0e0e0") // 改为更明显的灰色网格线
|
| 112 |
+
.attr("stroke-opacity", 0.5) // 增加不透明度
|
| 113 |
+
.attr("stroke-width", 1);
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
xTicks.forEach(tick => {
|
| 117 |
+
// 添加上方x轴年份标签
|
| 118 |
+
g.append("text")
|
| 119 |
+
.attr("x", xScale(tick))
|
| 120 |
+
.attr("y", -10)
|
| 121 |
+
.attr("text-anchor", "middle")
|
| 122 |
+
.attr("dominant-baseline", "bottom")
|
| 123 |
+
.attr("fill", "#000000") // 改为黑色文字
|
| 124 |
+
.style("font-size", "12px")
|
| 125 |
+
.text(xFormat(tick));
|
| 126 |
+
|
| 127 |
+
// 添加下方x轴年份标签
|
| 128 |
+
g.append("text")
|
| 129 |
+
.attr("x", xScale(tick))
|
| 130 |
+
.attr("y", chartHeight + 15)
|
| 131 |
+
.attr("text-anchor", "middle")
|
| 132 |
+
.attr("dominant-baseline", "hanging")
|
| 133 |
+
.attr("fill", "#000000") // 改为黑色文字
|
| 134 |
+
.style("font-size", "12px")
|
| 135 |
+
.text(xFormat(tick));
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
// 创建面积生成器
|
| 139 |
+
const area = (group) => d3.area()
|
| 140 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 141 |
+
.y0(groupPositions[group] + groupHeight) // 底部是固定的
|
| 142 |
+
.y1(d => groupPositions[group] + yScales[group](d[yField])) // 顶部根据数据变化
|
| 143 |
+
.curve(d3.curveBasis); // 使用平滑曲线
|
| 144 |
+
|
| 145 |
+
// 为每个组创建面积图和Y轴
|
| 146 |
+
groups.forEach(group => {
|
| 147 |
+
// 过滤该组的数据
|
| 148 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 149 |
+
|
| 150 |
+
// 确保数据按日期排序
|
| 151 |
+
groupData.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 152 |
+
|
| 153 |
+
// 获取颜色
|
| 154 |
+
const color = colorResolver.field(group, groups.indexOf(group), { palette: "category10" }).value;
|
| 155 |
+
|
| 156 |
+
// 添加0线 - 水平线位于组的底部位置,延伸到图表结束
|
| 157 |
+
g.append("line")
|
| 158 |
+
.attr("x1", -margin.left + 20) // 从左侧边缘开始
|
| 159 |
+
.attr("y1", groupPositions[group] + groupHeight) // y0位置
|
| 160 |
+
.attr("x2", chartWidth) // 延伸到图表区域结束
|
| 161 |
+
.attr("y2", groupPositions[group] + groupHeight)
|
| 162 |
+
.attr("stroke", color)
|
| 163 |
+
.attr("stroke-width", 1.5)
|
| 164 |
+
.attr("stroke-opacity", 0.8);
|
| 165 |
+
|
| 166 |
+
// 绘制面积
|
| 167 |
+
g.append("path")
|
| 168 |
+
.datum(groupData)
|
| 169 |
+
.attr("fill", color)
|
| 170 |
+
.attr("d", area(group));
|
| 171 |
+
|
| 172 |
+
// 添加组名标签 - 放在面积图左侧,距离更远
|
| 173 |
+
g.append("text")
|
| 174 |
+
.attr("x", -margin.left + 20) // 增加与图表的距离
|
| 175 |
+
.attr("y", groupPositions[group] + groupHeight - 10)
|
| 176 |
+
.attr("text-anchor", "start") // 左对齐
|
| 177 |
+
.attr("dominant-baseline", "middle")
|
| 178 |
+
.attr("fill", color)
|
| 179 |
+
.attr("font-weight", "bold")
|
| 180 |
+
.style("font-size", "14px")
|
| 181 |
+
.text(group);
|
| 182 |
+
|
| 183 |
+
// 创建Y轴 - 在右侧
|
| 184 |
+
const yAxis = d3.axisRight(yScales[group])
|
| 185 |
+
.ticks(3) // 减少刻度数量,避免拥挤
|
| 186 |
+
.tickSize(0) // 不显示刻度线
|
| 187 |
+
.tickFormat(d => chartUtils.format.autoText(+(d))); // 使用简洁格式
|
| 188 |
+
|
| 189 |
+
// 添加Y轴
|
| 190 |
+
const yAxisGroup = g.append("g")
|
| 191 |
+
.attr("transform", `translate(${chartWidth}, ${groupPositions[group]})`)
|
| 192 |
+
.call(yAxis);
|
| 193 |
+
|
| 194 |
+
// 设置Y轴样式
|
| 195 |
+
yAxisGroup.selectAll("path")
|
| 196 |
+
.attr("stroke", color);
|
| 197 |
+
|
| 198 |
+
yAxisGroup.selectAll("text")
|
| 199 |
+
.attr("fill", "#000000") // Y轴文字改为黑色
|
| 200 |
+
.attr("font-size", "10px")
|
| 201 |
+
.attr("dx", "0.5em"); // 向右移动文本
|
| 202 |
+
|
| 203 |
+
// 添加水平辅助线
|
| 204 |
+
yScales[group].ticks(3).forEach(tick => {
|
| 205 |
+
if (tick > 0) { // 跳过0线,因为已经单独添加了
|
| 206 |
+
g.append("line")
|
| 207 |
+
.attr("x1", 0)
|
| 208 |
+
.attr("y1", groupPositions[group] + yScales[group](tick))
|
| 209 |
+
.attr("x2", chartWidth)
|
| 210 |
+
.attr("y2", groupPositions[group] + yScales[group](tick))
|
| 211 |
+
.attr("stroke", color)
|
| 212 |
+
.attr("stroke-opacity", 0.2)
|
| 213 |
+
.attr("stroke-width", 0.5)
|
| 214 |
+
.attr("stroke-dasharray", "2,2"); // 虚线
|
| 215 |
+
}
|
| 216 |
+
});
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
return svg.node();
|
| 220 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/spline_area_chart_01.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Spline Area Chart",
|
| 5 |
+
"chart_name": "spline_area_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_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "no",
|
| 21 |
+
"chart_for": "trend"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
// 提取数据
|
| 28 |
+
const jsonData = data;
|
| 29 |
+
const chartData = jsonData.data.data;
|
| 30 |
+
const variables = jsonData.variables;
|
| 31 |
+
const typography = jsonData.typography;
|
| 32 |
+
const colors = jsonData.colors_dark || {};
|
| 33 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 34 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 35 |
+
const images = jsonData.images || {};
|
| 36 |
+
|
| 37 |
+
// 清空容器
|
| 38 |
+
d3.select(containerSelector).html("");
|
| 39 |
+
|
| 40 |
+
// 获取字段名
|
| 41 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 42 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 43 |
+
|
| 44 |
+
// 设置尺寸和边距
|
| 45 |
+
const width = variables.width;
|
| 46 |
+
const height = variables.height;
|
| 47 |
+
const margin = { top: 120, right: 30, bottom: 120, left: 60 };
|
| 48 |
+
const chartWidth = width - margin.left - margin.right;
|
| 49 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 50 |
+
|
| 51 |
+
// 创建SVG
|
| 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 |
+
|
| 61 |
+
// 创建图表组
|
| 62 |
+
const g = svg.append("g")
|
| 63 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 64 |
+
|
| 65 |
+
// 确保数据按日期排序
|
| 66 |
+
chartData.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 67 |
+
|
| 68 |
+
// 创建x轴比例尺
|
| 69 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
|
| 70 |
+
|
| 71 |
+
// 创建y轴比例尺 - 从0开始
|
| 72 |
+
const yMax = d3.max(chartData, d => +d[yField]);
|
| 73 |
+
const yScale = d3.scaleLinear()
|
| 74 |
+
.domain([0, yMax * 1.4]) // 顶部留出10%的空间
|
| 75 |
+
.range([chartHeight, 0]);
|
| 76 |
+
|
| 77 |
+
// 获取主色调
|
| 78 |
+
const primaryColor = colorResolver.other("primary", { fallback: "#a67eb7" }).value;
|
| 79 |
+
|
| 80 |
+
// 创建渐变
|
| 81 |
+
const defs = svg.append("defs");
|
| 82 |
+
const gradientId = "area-gradient";
|
| 83 |
+
const gradient = defs.append("linearGradient")
|
| 84 |
+
.attr("id", gradientId)
|
| 85 |
+
.attr("x1", "0%")
|
| 86 |
+
.attr("y1", "0%")
|
| 87 |
+
.attr("x2", "0%")
|
| 88 |
+
.attr("y2", "100%");
|
| 89 |
+
|
| 90 |
+
gradient.append("stop")
|
| 91 |
+
.attr("offset", "0%")
|
| 92 |
+
.attr("stop-color", primaryColor)
|
| 93 |
+
.attr("stop-opacity", 0.7);
|
| 94 |
+
|
| 95 |
+
gradient.append("stop")
|
| 96 |
+
.attr("offset", "100%")
|
| 97 |
+
.attr("stop-color", primaryColor)
|
| 98 |
+
.attr("stop-opacity", 0.0);
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
// 创建面积生成器
|
| 102 |
+
const area = d3.area()
|
| 103 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 104 |
+
.y0(chartHeight)
|
| 105 |
+
.y1(d => yScale(+d[yField]))
|
| 106 |
+
.curve(d3.curveBasis);
|
| 107 |
+
|
| 108 |
+
// 绘制面积
|
| 109 |
+
g.append("path")
|
| 110 |
+
.datum(chartData)
|
| 111 |
+
.attr("fill", `url(#${gradientId})`)
|
| 112 |
+
.attr("d", area);
|
| 113 |
+
|
| 114 |
+
// 绘制线条
|
| 115 |
+
const line = d3.line()
|
| 116 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 117 |
+
.y(d => yScale(+d[yField]))
|
| 118 |
+
.curve(d3.curveBasis);
|
| 119 |
+
|
| 120 |
+
g.append("path")
|
| 121 |
+
.datum(chartData)
|
| 122 |
+
.attr("fill", "none")
|
| 123 |
+
.attr("stroke", primaryColor)
|
| 124 |
+
.attr("stroke-width", 3)
|
| 125 |
+
.attr("d", line);
|
| 126 |
+
|
| 127 |
+
const sampleLabelIndex = sampleLabels(chartData.length);
|
| 128 |
+
|
| 129 |
+
// 添加数据点和标签 - 优化标签位置,包括首尾点
|
| 130 |
+
chartData.forEach((d, i) => {
|
| 131 |
+
if (!sampleLabelIndex.includes(i)) {
|
| 132 |
+
return;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
const x = xScale(chartUtils.format.parseDate(d[xField]));
|
| 136 |
+
const y = yScale(+d[yField]);
|
| 137 |
+
|
| 138 |
+
// 计算更好的标签位置,避免遮挡线条
|
| 139 |
+
let labelY = y;
|
| 140 |
+
|
| 141 |
+
// 根据点的位置计算标签位置
|
| 142 |
+
if (i === 0) {
|
| 143 |
+
// 第一个点 - 考虑后一个点
|
| 144 |
+
if (chartData.length > 1) {
|
| 145 |
+
const nextPoint = chartData[i+1];
|
| 146 |
+
const nextY = yScale(nextPoint[yField]);
|
| 147 |
+
|
| 148 |
+
// 计算向前20%的插值
|
| 149 |
+
const forwardY = y + (nextY - y) * 0.2;
|
| 150 |
+
|
| 151 |
+
// 取���个值中的最小值
|
| 152 |
+
labelY = Math.min(y, forwardY) - 4;
|
| 153 |
+
}
|
| 154 |
+
// 确保标签至少在数据点上方30像素
|
| 155 |
+
labelY = Math.min(labelY, y - 4);
|
| 156 |
+
} else if (i === chartData.length - 1) {
|
| 157 |
+
// 最后一个点 - 考虑前一个点
|
| 158 |
+
const prevPoint = chartData[i-1];
|
| 159 |
+
const prevY = yScale(prevPoint[yField]);
|
| 160 |
+
|
| 161 |
+
// 计算向后20%的插值
|
| 162 |
+
const backwardY = y + (prevY - y) * 0.2;
|
| 163 |
+
|
| 164 |
+
// 取两个值中的最小值
|
| 165 |
+
labelY = Math.min(y, backwardY) - 4;
|
| 166 |
+
|
| 167 |
+
// 确保标签至少在数据点上方30像素
|
| 168 |
+
labelY = Math.min(labelY, y - 4);
|
| 169 |
+
} else {
|
| 170 |
+
// 中间点 - 考虑前后两个点
|
| 171 |
+
const prevPoint = chartData[i-1];
|
| 172 |
+
const nextPoint = chartData[i+1];
|
| 173 |
+
|
| 174 |
+
// 计算前一个点的y值
|
| 175 |
+
const prevY = yScale(prevPoint[yField]);
|
| 176 |
+
|
| 177 |
+
// 计算后一个点的y值
|
| 178 |
+
const nextY = yScale(nextPoint[yField]);
|
| 179 |
+
|
| 180 |
+
// 计算向前20%的插值
|
| 181 |
+
const forwardY = y + (nextY - y) * 0.2;
|
| 182 |
+
|
| 183 |
+
// 计算向后20%的插值
|
| 184 |
+
const backwardY = y + (prevY - y) * 0.2;
|
| 185 |
+
|
| 186 |
+
// 取三个值中的最小值(在SVG中,较小的y值表示较高的位置)
|
| 187 |
+
labelY = Math.min(y, forwardY, backwardY) - 4;
|
| 188 |
+
|
| 189 |
+
// 确保标签至少在数据点上方30像素
|
| 190 |
+
labelY = Math.min(labelY, y - 4);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
// 添加标签背景
|
| 194 |
+
const labelValue = chartUtils.format.autoText(+d[yField]);
|
| 195 |
+
const labelWidth = chartUtils.text.measure(null, labelValue, { fontSize: 14 }).width + 20;
|
| 196 |
+
const labelHeight = 25;
|
| 197 |
+
|
| 198 |
+
g.append("rect")
|
| 199 |
+
.attr("x", x - labelWidth / 2)
|
| 200 |
+
.attr("y", labelY - labelHeight - 15)
|
| 201 |
+
.attr("width", labelWidth)
|
| 202 |
+
.attr("height", labelHeight)
|
| 203 |
+
.attr("rx", 5)
|
| 204 |
+
.attr("ry", 5)
|
| 205 |
+
.attr("fill", primaryColor)
|
| 206 |
+
.attr("opacity", 0.9);
|
| 207 |
+
|
| 208 |
+
// 添加标签文本
|
| 209 |
+
g.append("text")
|
| 210 |
+
.attr("x", x)
|
| 211 |
+
.attr("y", labelY - 15 - labelHeight / 2)
|
| 212 |
+
.attr("text-anchor", "middle")
|
| 213 |
+
.attr("dominant-baseline", "middle")
|
| 214 |
+
.attr("fill", "#fff")
|
| 215 |
+
.attr("font-weight", "bold")
|
| 216 |
+
.style("font-size", "14px")
|
| 217 |
+
.text(labelValue);
|
| 218 |
+
|
| 219 |
+
// 添加倒三角形 - 连接到标签
|
| 220 |
+
const triangleSize = 8;
|
| 221 |
+
g.append("path")
|
| 222 |
+
.attr("d", `M${x-triangleSize/2},${labelY-15} L${x+triangleSize/2},${labelY-15} L${x},${labelY-15+triangleSize} Z`)
|
| 223 |
+
.attr("fill", primaryColor);
|
| 224 |
+
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
// 添加X轴刻度文本
|
| 228 |
+
xTicks.forEach(tick => {
|
| 229 |
+
const x = xScale(tick);
|
| 230 |
+
|
| 231 |
+
g.append("text")
|
| 232 |
+
.attr("x", x)
|
| 233 |
+
.attr("y", chartHeight + 25)
|
| 234 |
+
.attr("text-anchor", "middle")
|
| 235 |
+
.attr("fill", "#aaa")
|
| 236 |
+
.style("font-size", "14px")
|
| 237 |
+
.text(xFormat(tick));
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
return svg.node();
|
| 241 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/spline_area_chart_02.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Spline Area Chart",
|
| 5 |
+
"chart_name": "spline_area_chart_02",
|
| 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": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "no",
|
| 21 |
+
"chart_for": "trend"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
// 提取数据
|
| 28 |
+
const jsonData = data;
|
| 29 |
+
const chartData = jsonData.data.data;
|
| 30 |
+
const variables = jsonData.variables;
|
| 31 |
+
const typography = jsonData.typography;
|
| 32 |
+
const colors = jsonData.colors || {};
|
| 33 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 34 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 35 |
+
const images = jsonData.images || {};
|
| 36 |
+
|
| 37 |
+
// 清空容器
|
| 38 |
+
d3.select(containerSelector).html("");
|
| 39 |
+
|
| 40 |
+
// 获取字段名
|
| 41 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 42 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 43 |
+
|
| 44 |
+
// 设置尺寸和边距
|
| 45 |
+
const width = variables.width;
|
| 46 |
+
const height = variables.height;
|
| 47 |
+
const margin = { top: 120, right: 30, bottom: 120, left: 60 };
|
| 48 |
+
const chartWidth = width - margin.left - margin.right;
|
| 49 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 50 |
+
|
| 51 |
+
// 创建SVG
|
| 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 |
+
|
| 61 |
+
// 创建图表组
|
| 62 |
+
const g = svg.append("g")
|
| 63 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 64 |
+
|
| 65 |
+
// 确保数据按日期排序
|
| 66 |
+
chartData.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 67 |
+
|
| 68 |
+
// 创建x轴比例尺
|
| 69 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
|
| 70 |
+
|
| 71 |
+
// 创建y轴比例尺 - 从0开始
|
| 72 |
+
const yMax = d3.max(chartData, d => +d[yField]);
|
| 73 |
+
const yScale = d3.scaleLinear()
|
| 74 |
+
.domain([0, yMax * 1.4]) // 顶部留出10%的空间
|
| 75 |
+
.range([chartHeight, 0]);
|
| 76 |
+
|
| 77 |
+
// 获取主色调
|
| 78 |
+
const primaryColor = colorResolver.other("primary", { fallback: "#a67eb7" }).value;
|
| 79 |
+
|
| 80 |
+
// 创建渐变
|
| 81 |
+
const defs = svg.append("defs");
|
| 82 |
+
const gradientId = "area-gradient";
|
| 83 |
+
const gradient = defs.append("linearGradient")
|
| 84 |
+
.attr("id", gradientId)
|
| 85 |
+
.attr("x1", "0%")
|
| 86 |
+
.attr("y1", "0%")
|
| 87 |
+
.attr("x2", "0%")
|
| 88 |
+
.attr("y2", "100%");
|
| 89 |
+
|
| 90 |
+
gradient.append("stop")
|
| 91 |
+
.attr("offset", "0%")
|
| 92 |
+
.attr("stop-color", primaryColor)
|
| 93 |
+
.attr("stop-opacity", 0.7);
|
| 94 |
+
|
| 95 |
+
gradient.append("stop")
|
| 96 |
+
.attr("offset", "100%")
|
| 97 |
+
.attr("stop-color", primaryColor)
|
| 98 |
+
.attr("stop-opacity", 0);
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
// 创建面积生成器
|
| 102 |
+
const area = d3.area()
|
| 103 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 104 |
+
.y0(chartHeight)
|
| 105 |
+
.y1(d => yScale(+d[yField]))
|
| 106 |
+
.curve(d3.curveBasis);
|
| 107 |
+
|
| 108 |
+
// 绘制面积
|
| 109 |
+
g.append("path")
|
| 110 |
+
.datum(chartData)
|
| 111 |
+
.attr("fill", `url(#${gradientId})`)
|
| 112 |
+
.attr("d", area);
|
| 113 |
+
|
| 114 |
+
// 绘制线条
|
| 115 |
+
const line = d3.line()
|
| 116 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 117 |
+
.y(d => yScale(+d[yField]))
|
| 118 |
+
.curve(d3.curveBasis);
|
| 119 |
+
|
| 120 |
+
g.append("path")
|
| 121 |
+
.datum(chartData)
|
| 122 |
+
.attr("fill", "none")
|
| 123 |
+
.attr("stroke", primaryColor)
|
| 124 |
+
.attr("stroke-width", 3)
|
| 125 |
+
.attr("d", line);
|
| 126 |
+
|
| 127 |
+
const sampleLabelIndex = sampleLabels(chartData.length);
|
| 128 |
+
|
| 129 |
+
// 添加数据点和标签 - 优化标签位置,包括首尾点
|
| 130 |
+
chartData.forEach((d, i) => {
|
| 131 |
+
if (!sampleLabelIndex.includes(i)) {
|
| 132 |
+
return;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
const x = xScale(chartUtils.format.parseDate(d[xField]));
|
| 136 |
+
const y = yScale(+d[yField]);
|
| 137 |
+
|
| 138 |
+
// 计算更好的标签位置,避免遮挡线条
|
| 139 |
+
let labelY = y;
|
| 140 |
+
|
| 141 |
+
// 根据点的位置计算标签位置
|
| 142 |
+
if (i === 0) {
|
| 143 |
+
// 第一个点 - 考虑后一个点
|
| 144 |
+
if (chartData.length > 1) {
|
| 145 |
+
const nextPoint = chartData[i+1];
|
| 146 |
+
const nextY = yScale(nextPoint[yField]);
|
| 147 |
+
|
| 148 |
+
// 计算向前20%的插值
|
| 149 |
+
const forwardY = y + (nextY - y) * 0.2;
|
| 150 |
+
|
| 151 |
+
// 取两个���中的最小值
|
| 152 |
+
labelY = Math.min(y, forwardY) - 4;
|
| 153 |
+
}
|
| 154 |
+
// 确保标签至少在数据点上方30像素
|
| 155 |
+
labelY = Math.min(labelY, y - 4);
|
| 156 |
+
} else if (i === chartData.length - 1) {
|
| 157 |
+
// 最后一个点 - 考虑前一个点
|
| 158 |
+
const prevPoint = chartData[i-1];
|
| 159 |
+
const prevY = yScale(prevPoint[yField]);
|
| 160 |
+
|
| 161 |
+
// 计算向后20%的插值
|
| 162 |
+
const backwardY = y + (prevY - y) * 0.2;
|
| 163 |
+
|
| 164 |
+
// 取两个值中的最小值
|
| 165 |
+
labelY = Math.min(y, backwardY) - 4;
|
| 166 |
+
|
| 167 |
+
// 确保标签至少在数据点上方30像素
|
| 168 |
+
labelY = Math.min(labelY, y - 4);
|
| 169 |
+
} else {
|
| 170 |
+
// 中间点 - 考虑前后两个点
|
| 171 |
+
const prevPoint = chartData[i-1];
|
| 172 |
+
const nextPoint = chartData[i+1];
|
| 173 |
+
|
| 174 |
+
// 计算前一个点的y值
|
| 175 |
+
const prevY = yScale(prevPoint[yField]);
|
| 176 |
+
|
| 177 |
+
// 计算后一个点的y值
|
| 178 |
+
const nextY = yScale(nextPoint[yField]);
|
| 179 |
+
|
| 180 |
+
// 计算向前20%的插值
|
| 181 |
+
const forwardY = y + (nextY - y) * 0.2;
|
| 182 |
+
|
| 183 |
+
// 计算向后20%的插值
|
| 184 |
+
const backwardY = y + (prevY - y) * 0.2;
|
| 185 |
+
|
| 186 |
+
// 取三个值中的最小值(在SVG中,较小的y值表示较高的位置)
|
| 187 |
+
labelY = Math.min(y, forwardY, backwardY) - 4;
|
| 188 |
+
|
| 189 |
+
// 确保标签至少在数据点上方30像素
|
| 190 |
+
labelY = Math.min(labelY, y - 4);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
// 添加标签背景
|
| 194 |
+
const labelValue = chartUtils.format.integer(d[yField]).text;
|
| 195 |
+
const labelWidth = chartUtils.text.measure(null, labelValue, { fontSize: 14 }).width + 20;
|
| 196 |
+
const labelHeight = 25;
|
| 197 |
+
|
| 198 |
+
g.append("rect")
|
| 199 |
+
.attr("x", x - labelWidth / 2)
|
| 200 |
+
.attr("y", labelY - labelHeight - 15)
|
| 201 |
+
.attr("width", labelWidth)
|
| 202 |
+
.attr("height", labelHeight)
|
| 203 |
+
.attr("rx", 5)
|
| 204 |
+
.attr("ry", 5)
|
| 205 |
+
.attr("fill", primaryColor)
|
| 206 |
+
.attr("opacity", 0.9);
|
| 207 |
+
|
| 208 |
+
// 添加标签文本
|
| 209 |
+
g.append("text")
|
| 210 |
+
.attr("x", x)
|
| 211 |
+
.attr("y", labelY - 15 - labelHeight / 2)
|
| 212 |
+
.attr("text-anchor", "middle")
|
| 213 |
+
.attr("dominant-baseline", "middle")
|
| 214 |
+
.attr("fill", "#fff")
|
| 215 |
+
.attr("font-weight", "bold")
|
| 216 |
+
.style("font-size", "14px")
|
| 217 |
+
.text(labelValue);
|
| 218 |
+
|
| 219 |
+
// 添加倒三角形 - 连接到标签
|
| 220 |
+
const triangleSize = 8;
|
| 221 |
+
g.append("path")
|
| 222 |
+
.attr("d", `M${x-triangleSize/2},${labelY-15} L${x+triangleSize/2},${labelY-15} L${x},${labelY-15+triangleSize} Z`)
|
| 223 |
+
.attr("fill", primaryColor);
|
| 224 |
+
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
// 添加X轴刻度文本
|
| 228 |
+
xTicks.forEach(tick => {
|
| 229 |
+
const x = xScale(tick);
|
| 230 |
+
|
| 231 |
+
g.append("text")
|
| 232 |
+
.attr("x", x)
|
| 233 |
+
.attr("y", chartHeight + 25)
|
| 234 |
+
.attr("text-anchor", "middle")
|
| 235 |
+
.attr("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 236 |
+
.style("font-size", "14px")
|
| 237 |
+
.text(xFormat(tick));
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
return svg.node();
|
| 241 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/spline_layered_area_chart_01.js
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Layered Area Chart",
|
| 5 |
+
"chart_name": "spline_layered_area_chart_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 50], [0, "inf"], [2, 6]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": ["gradient", "opacity"],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "dark",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
const jsonData = data;
|
| 27 |
+
const chartData = jsonData.data.data;
|
| 28 |
+
const variables = jsonData.variables;
|
| 29 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 30 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 31 |
+
const fontSize = chartUtils.text.fontSize;
|
| 32 |
+
|
| 33 |
+
d3.select(containerSelector).html("");
|
| 34 |
+
|
| 35 |
+
const columnName = column => column && (column.field || column.name || column.key);
|
| 36 |
+
let xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 37 |
+
let yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 38 |
+
let groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 39 |
+
if (!chartData.length || chartData[0][xField] === undefined) xField = columnName(dataColumns[0]);
|
| 40 |
+
if (!chartData.length || chartData[0][yField] === undefined) yField = columnName(dataColumns[1]);
|
| 41 |
+
if (!chartData.length || chartData[0][groupField] === undefined) groupField = columnName(dataColumns[2]);
|
| 42 |
+
|
| 43 |
+
const width = Math.max(620, +(variables.width || 650));
|
| 44 |
+
const height = Math.max(900, +(variables.height || 975));
|
| 45 |
+
const title = (jsonData.titles && jsonData.titles.main_title)
|
| 46 |
+
|| (jsonData.metadata && jsonData.metadata.title)
|
| 47 |
+
|| "";
|
| 48 |
+
const subtitle = (jsonData.titles && jsonData.titles.sub_title)
|
| 49 |
+
|| (jsonData.metadata && (jsonData.metadata.description || jsonData.metadata.main_insight))
|
| 50 |
+
|| "";
|
| 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 |
+
|
| 61 |
+
const defs = svg.append("defs");
|
| 62 |
+
const bg = defs.append("radialGradient")
|
| 63 |
+
.attr("id", "spline-layered-bg")
|
| 64 |
+
.attr("cx", "54%")
|
| 65 |
+
.attr("cy", "32%")
|
| 66 |
+
.attr("r", "80%");
|
| 67 |
+
bg.append("stop").attr("offset", "0%").attr("stop-color", "#082748");
|
| 68 |
+
bg.append("stop").attr("offset", "58%").attr("stop-color", "#041A31");
|
| 69 |
+
bg.append("stop").attr("offset", "100%").attr("stop-color", "#020B17");
|
| 70 |
+
|
| 71 |
+
const glowFilter = defs.append("filter")
|
| 72 |
+
.attr("id", "spline-layered-glow")
|
| 73 |
+
.attr("x", "-60%")
|
| 74 |
+
.attr("y", "-60%")
|
| 75 |
+
.attr("width", "220%")
|
| 76 |
+
.attr("height", "220%");
|
| 77 |
+
glowFilter.append("feGaussianBlur")
|
| 78 |
+
.attr("stdDeviation", 8)
|
| 79 |
+
.attr("result", "blur");
|
| 80 |
+
const glowMerge = glowFilter.append("feMerge");
|
| 81 |
+
glowMerge.append("feMergeNode").attr("in", "blur");
|
| 82 |
+
glowMerge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 83 |
+
|
| 84 |
+
const shadowFilter = defs.append("filter")
|
| 85 |
+
.attr("id", "spline-layered-card-shadow")
|
| 86 |
+
.attr("x", "-40%")
|
| 87 |
+
.attr("y", "-40%")
|
| 88 |
+
.attr("width", "180%")
|
| 89 |
+
.attr("height", "180%");
|
| 90 |
+
shadowFilter.append("feDropShadow")
|
| 91 |
+
.attr("dx", 0)
|
| 92 |
+
.attr("dy", 10)
|
| 93 |
+
.attr("stdDeviation", 10)
|
| 94 |
+
.attr("flood-color", "#000000")
|
| 95 |
+
.attr("flood-opacity", 0.28);
|
| 96 |
+
|
| 97 |
+
svg.append("rect")
|
| 98 |
+
.attr("width", width)
|
| 99 |
+
.attr("height", height)
|
| 100 |
+
.attr("fill", "url(#spline-layered-bg)");
|
| 101 |
+
svg.append("circle")
|
| 102 |
+
.attr("cx", width * 0.82)
|
| 103 |
+
.attr("cy", height * 0.15)
|
| 104 |
+
.attr("r", width * 0.30)
|
| 105 |
+
.attr("fill", "#154D85")
|
| 106 |
+
.attr("opacity", 0.08);
|
| 107 |
+
|
| 108 |
+
const headerSlot = {
|
| 109 |
+
x: width * 0.055,
|
| 110 |
+
y: height * 0.045,
|
| 111 |
+
size: Math.min(150, width * 0.22)
|
| 112 |
+
};
|
| 113 |
+
const slot = svg.append("g")
|
| 114 |
+
.attr("class", "reserved-asset-slot spline-layered-header-topic-icon-slot")
|
| 115 |
+
.attr("data-asset-slot", "header_topic_icon_slot")
|
| 116 |
+
.attr("data-reserved-slot", "header_topic_icon_slot")
|
| 117 |
+
.attr("data-slot-policy", "image2_asset_or_input_provided")
|
| 118 |
+
.attr("data-asset-source-policy", "image2_asset_or_input_provided")
|
| 119 |
+
.attr("data-anchor", "upper-left-header")
|
| 120 |
+
.attr("data-collision-rule", "avoid title subtitle metric pill and chart panel")
|
| 121 |
+
.attr("data-bbox", `${headerSlot.x.toFixed(1)} ${headerSlot.y.toFixed(1)} ${headerSlot.size.toFixed(1)} ${headerSlot.size.toFixed(1)}`)
|
| 122 |
+
.attr("transform", `translate(${headerSlot.x},${headerSlot.y})`);
|
| 123 |
+
slot.append("circle")
|
| 124 |
+
.attr("cx", headerSlot.size / 2)
|
| 125 |
+
.attr("cy", headerSlot.size / 2)
|
| 126 |
+
.attr("r", headerSlot.size * 0.47)
|
| 127 |
+
.attr("fill", "#061B36")
|
| 128 |
+
.attr("stroke", "#3E8CFF")
|
| 129 |
+
.attr("stroke-width", 3)
|
| 130 |
+
.attr("opacity", 0.95)
|
| 131 |
+
.style("filter", "url(#spline-layered-glow)");
|
| 132 |
+
slot.append("circle")
|
| 133 |
+
.attr("cx", headerSlot.size / 2)
|
| 134 |
+
.attr("cy", headerSlot.size / 2)
|
| 135 |
+
.attr("r", headerSlot.size * 0.34)
|
| 136 |
+
.attr("fill", "none")
|
| 137 |
+
.attr("stroke", "#9DC8FF")
|
| 138 |
+
.attr("stroke-width", 2)
|
| 139 |
+
.attr("stroke-dasharray", "6 8")
|
| 140 |
+
.attr("opacity", 0.58);
|
| 141 |
+
slot.append("path")
|
| 142 |
+
.attr("class", "neutral-asset-placeholder")
|
| 143 |
+
.attr("d", `M${headerSlot.size * 0.31},${headerSlot.size * 0.60} L${headerSlot.size * 0.43},${headerSlot.size * 0.49} L${headerSlot.size * 0.52},${headerSlot.size * 0.56} L${headerSlot.size * 0.69},${headerSlot.size * 0.39}`)
|
| 144 |
+
.attr("fill", "none")
|
| 145 |
+
.attr("stroke", "#BFD9FF")
|
| 146 |
+
.attr("stroke-width", 5)
|
| 147 |
+
.attr("stroke-linecap", "round")
|
| 148 |
+
.attr("stroke-linejoin", "round")
|
| 149 |
+
.attr("opacity", 0.72);
|
| 150 |
+
|
| 151 |
+
function wrapText(text, maxChars, maxLines) {
|
| 152 |
+
const words = String(text || "").replace(/^\"|\"$/g, "").split(/\s+/).filter(Boolean);
|
| 153 |
+
const lines = [];
|
| 154 |
+
let line = "";
|
| 155 |
+
words.forEach(word => {
|
| 156 |
+
const test = line ? `${line} ${word}` : word;
|
| 157 |
+
if (test.length > maxChars && line) {
|
| 158 |
+
lines.push(line);
|
| 159 |
+
line = word;
|
| 160 |
+
} else {
|
| 161 |
+
line = test;
|
| 162 |
+
}
|
| 163 |
+
});
|
| 164 |
+
if (line) lines.push(line);
|
| 165 |
+
if (lines.length > maxLines) {
|
| 166 |
+
const kept = lines.slice(0, maxLines);
|
| 167 |
+
const overflowWords = lines.slice(maxLines).join(" ").split(/\s+/).filter(Boolean);
|
| 168 |
+
while (overflowWords.length && `${kept[maxLines - 1]} ${overflowWords[0]}`.length <= maxChars) {
|
| 169 |
+
kept[maxLines - 1] = `${kept[maxLines - 1]} ${overflowWords.shift()}`;
|
| 170 |
+
}
|
| 171 |
+
return kept;
|
| 172 |
+
}
|
| 173 |
+
return lines.length ? lines : [""];
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const titleX = width * 0.29;
|
| 177 |
+
const titleSafeWidth = width - titleX - 30;
|
| 178 |
+
const titleLines = wrapText(title, width < 660 ? 24 : 26, 3);
|
| 179 |
+
const titleSize = titleLines.length > 2 ? 33 : 39;
|
| 180 |
+
const titleGroup = svg.append("g")
|
| 181 |
+
.attr("class", "spline-layered-title-stack")
|
| 182 |
+
.attr("data-reserved-slot", "header-title-typography")
|
| 183 |
+
.attr("data-asset-source-policy", "input-text")
|
| 184 |
+
.attr("data-anchor", "top-right-header")
|
| 185 |
+
.attr("data-collision-rule", "stay right of header topic slot and above metric pill");
|
| 186 |
+
titleLines.forEach((line, i) => {
|
| 187 |
+
const isLast = i === titleLines.length - 1;
|
| 188 |
+
titleGroup.append("text")
|
| 189 |
+
.attr("x", titleX)
|
| 190 |
+
.attr("y", height * 0.075 + i * titleSize * 1.08)
|
| 191 |
+
.attr("font-family", "Georgia, 'Times New Roman', serif")
|
| 192 |
+
.attr("font-size", titleSize)
|
| 193 |
+
.attr("font-weight", 700)
|
| 194 |
+
.attr("fill", isLast ? "#74AFFF" : "#FFFFFF")
|
| 195 |
+
.attr("letter-spacing", 0)
|
| 196 |
+
.attr("textLength", Math.min(titleSafeWidth, Math.max(0, line.length * titleSize * 0.56)) || null)
|
| 197 |
+
.attr("lengthAdjust", "spacingAndGlyphs")
|
| 198 |
+
.text(line);
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
wrapText(subtitle, 45, 3).forEach((line, i) => {
|
| 202 |
+
titleGroup.append("text")
|
| 203 |
+
.attr("x", titleX)
|
| 204 |
+
.attr("y", height * 0.20 + i * 24)
|
| 205 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 206 |
+
.attr("font-size", 18)
|
| 207 |
+
.attr("font-weight", 400)
|
| 208 |
+
.attr("fill", "#C9D6E8")
|
| 209 |
+
.attr("opacity", 0.94)
|
| 210 |
+
.attr("textLength", Math.min(titleSafeWidth, Math.max(0, line.length * 18 * 0.54)) || null)
|
| 211 |
+
.attr("lengthAdjust", "spacingAndGlyphs")
|
| 212 |
+
.text(line);
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
const plot = {
|
| 216 |
+
x: width * 0.085,
|
| 217 |
+
y: height * 0.365,
|
| 218 |
+
width: width * 0.81,
|
| 219 |
+
height: height * 0.43
|
| 220 |
+
};
|
| 221 |
+
const metricW = width * 0.58;
|
| 222 |
+
const metricX = plot.x + (plot.width - metricW) / 2;
|
| 223 |
+
const metricY = plot.y - 58;
|
| 224 |
+
const yColumn = dataColumns.find(c => chartUtils.schema.columnField([c], 0) === yField) || dataColumns[1] || {};
|
| 225 |
+
const yLabel = yColumn.description || yField;
|
| 226 |
+
svg.append("rect")
|
| 227 |
+
.attr("class", "spline-layered-metric-pill")
|
| 228 |
+
.attr("x", metricX)
|
| 229 |
+
.attr("y", metricY)
|
| 230 |
+
.attr("width", metricW)
|
| 231 |
+
.attr("height", 40)
|
| 232 |
+
.attr("rx", 20)
|
| 233 |
+
.attr("fill", "#08264B")
|
| 234 |
+
.attr("stroke", "#1D68B7")
|
| 235 |
+
.attr("stroke-width", 1.4)
|
| 236 |
+
.attr("opacity", 0.94);
|
| 237 |
+
svg.append("text")
|
| 238 |
+
.attr("x", metricX + metricW / 2)
|
| 239 |
+
.attr("y", metricY + 18)
|
| 240 |
+
.attr("text-anchor", "middle")
|
| 241 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 242 |
+
.attr("font-size", 13)
|
| 243 |
+
.attr("font-weight", 800)
|
| 244 |
+
.attr("letter-spacing", 1.5)
|
| 245 |
+
.attr("fill", "#DDEBFF")
|
| 246 |
+
.attr("textLength", metricW - 34)
|
| 247 |
+
.attr("lengthAdjust", "spacingAndGlyphs")
|
| 248 |
+
.text(String(yLabel).toUpperCase().slice(0, 58));
|
| 249 |
+
svg.append("text")
|
| 250 |
+
.attr("x", metricX + metricW / 2)
|
| 251 |
+
.attr("y", metricY + 33)
|
| 252 |
+
.attr("text-anchor", "middle")
|
| 253 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 254 |
+
.attr("font-size", 12)
|
| 255 |
+
.attr("fill", "#B8C9DE")
|
| 256 |
+
.text(`(${yField})`);
|
| 257 |
+
|
| 258 |
+
const parseX = value => {
|
| 259 |
+
const parsed = chartUtils.format.parseDate(value);
|
| 260 |
+
if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed;
|
| 261 |
+
const numeric = +value;
|
| 262 |
+
if (Number.isFinite(numeric)) return new Date(numeric, 0, 1);
|
| 263 |
+
return new Date(String(value));
|
| 264 |
+
};
|
| 265 |
+
const xValues = Array.from(new Set(chartData.map(d => d[xField])))
|
| 266 |
+
.map(value => ({ value, date: parseX(value) }))
|
| 267 |
+
.sort((a, b) => a.date - b.date);
|
| 268 |
+
const xValueByKey = new Map(xValues.map(d => [String(d.value), d]));
|
| 269 |
+
const xDomain = d3.extent(xValues, d => d.date);
|
| 270 |
+
const xScale = d3.scaleTime()
|
| 271 |
+
.domain(xDomain)
|
| 272 |
+
.range([plot.x, plot.x + plot.width]);
|
| 273 |
+
|
| 274 |
+
const grouped = d3.group(chartData, d => d[groupField]);
|
| 275 |
+
const groups = Array.from(grouped.keys()).map((group, idx) => {
|
| 276 |
+
const values = grouped.get(group)
|
| 277 |
+
.map(d => ({ ...d, __date: parseX(d[xField]), __y: +d[yField] }))
|
| 278 |
+
.sort((a, b) => a.__date - b.__date);
|
| 279 |
+
const latest = values[values.length - 1] || {};
|
| 280 |
+
return { group, idx, values, latestValue: +latest.__y || 0, latest };
|
| 281 |
+
}).sort((a, b) => b.latestValue - a.latestValue);
|
| 282 |
+
|
| 283 |
+
const maxY = d3.max(chartData, d => +d[yField]) || 1;
|
| 284 |
+
const yMax = Math.max(1, Math.ceil(maxY * 1.12 / 5) * 5);
|
| 285 |
+
const yScale = d3.scaleLinear()
|
| 286 |
+
.domain([0, yMax])
|
| 287 |
+
.range([plot.y + plot.height, plot.y]);
|
| 288 |
+
|
| 289 |
+
const palette = ["#3F8CFF", "#F25A9D", "#2FD0C6", "#FFC94A", "#8A7CFF", "#54E6A0"];
|
| 290 |
+
const colorFor = (group, idx) => {
|
| 291 |
+
if (idx < 2) return palette[idx];
|
| 292 |
+
return colorResolver.field(group, idx, { fallbackKey: "primary" }).value || palette[idx % palette.length];
|
| 293 |
+
};
|
| 294 |
+
const softColorFor = (group, idx) => {
|
| 295 |
+
const c = d3.color(colorFor(group, idx));
|
| 296 |
+
return c ? c.formatHex() : palette[idx % palette.length];
|
| 297 |
+
};
|
| 298 |
+
|
| 299 |
+
groups.forEach((series, idx) => {
|
| 300 |
+
const gradientId = `spline-layered-area-gradient-${idx}`;
|
| 301 |
+
const gradient = defs.append("linearGradient")
|
| 302 |
+
.attr("id", gradientId)
|
| 303 |
+
.attr("x1", "0%")
|
| 304 |
+
.attr("y1", "0%")
|
| 305 |
+
.attr("x2", "0%")
|
| 306 |
+
.attr("y2", "100%");
|
| 307 |
+
gradient.append("stop")
|
| 308 |
+
.attr("offset", "0%")
|
| 309 |
+
.attr("stop-color", softColorFor(series.group, idx))
|
| 310 |
+
.attr("stop-opacity", 0.88);
|
| 311 |
+
gradient.append("stop")
|
| 312 |
+
.attr("offset", "100%")
|
| 313 |
+
.attr("stop-color", softColorFor(series.group, idx))
|
| 314 |
+
.attr("stop-opacity", 0.20);
|
| 315 |
+
});
|
| 316 |
+
|
| 317 |
+
const yTicks = d3.ticks(0, yMax, 6);
|
| 318 |
+
yTicks.forEach(tick => {
|
| 319 |
+
const y = yScale(tick);
|
| 320 |
+
svg.append("line")
|
| 321 |
+
.attr("x1", plot.x)
|
| 322 |
+
.attr("x2", plot.x + plot.width)
|
| 323 |
+
.attr("y1", y)
|
| 324 |
+
.attr("y2", y)
|
| 325 |
+
.attr("stroke", "#55718F")
|
| 326 |
+
.attr("stroke-width", 1)
|
| 327 |
+
.attr("stroke-dasharray", tick === 0 ? null : "3 5")
|
| 328 |
+
.attr("opacity", tick === 0 ? 0.80 : 0.28);
|
| 329 |
+
svg.append("text")
|
| 330 |
+
.attr("x", plot.x - 16)
|
| 331 |
+
.attr("y", y + 5)
|
| 332 |
+
.attr("text-anchor", "end")
|
| 333 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 334 |
+
.attr("font-size", 17)
|
| 335 |
+
.attr("font-weight", tick === 0 ? 700 : 500)
|
| 336 |
+
.attr("fill", "#B2C2D8")
|
| 337 |
+
.attr("opacity", tick === 0 ? 0.94 : 0.86)
|
| 338 |
+
.text(tick);
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
+
const preferredTickIndexes = new Set();
|
| 342 |
+
const targetTickCount = xValues.length > 8 ? 6 : xValues.length;
|
| 343 |
+
const tickStep = Math.max(1, Math.ceil(xValues.length / targetTickCount));
|
| 344 |
+
xValues.forEach((_, i) => {
|
| 345 |
+
if (i % tickStep === 0) preferredTickIndexes.add(i);
|
| 346 |
+
});
|
| 347 |
+
preferredTickIndexes.add(0);
|
| 348 |
+
preferredTickIndexes.add(xValues.length - 1);
|
| 349 |
+
const xTickLabelIndexes = [];
|
| 350 |
+
const minTickLabelGap = 58;
|
| 351 |
+
Array.from(preferredTickIndexes).sort((a, b) => a - b).forEach(i => {
|
| 352 |
+
const x = xScale(xValues[i].date);
|
| 353 |
+
const previousIndex = xTickLabelIndexes[xTickLabelIndexes.length - 1];
|
| 354 |
+
if (previousIndex === undefined) {
|
| 355 |
+
xTickLabelIndexes.push(i);
|
| 356 |
+
return;
|
| 357 |
+
}
|
| 358 |
+
const previousX = xScale(xValues[previousIndex].date);
|
| 359 |
+
if (x - previousX >= minTickLabelGap) {
|
| 360 |
+
xTickLabelIndexes.push(i);
|
| 361 |
+
return;
|
| 362 |
+
}
|
| 363 |
+
if (i === xValues.length - 1 && previousIndex !== 0) {
|
| 364 |
+
xTickLabelIndexes[xTickLabelIndexes.length - 1] = i;
|
| 365 |
+
}
|
| 366 |
+
});
|
| 367 |
+
const xTickLabelIndexSet = new Set(xTickLabelIndexes);
|
| 368 |
+
|
| 369 |
+
xValues.forEach((tick, i) => {
|
| 370 |
+
const x = xScale(tick.date);
|
| 371 |
+
svg.append("line")
|
| 372 |
+
.attr("x1", x)
|
| 373 |
+
.attr("x2", x)
|
| 374 |
+
.attr("y1", plot.y)
|
| 375 |
+
.attr("y2", plot.y + plot.height)
|
| 376 |
+
.attr("stroke", "#55718F")
|
| 377 |
+
.attr("stroke-width", 1)
|
| 378 |
+
.attr("stroke-dasharray", "4 5")
|
| 379 |
+
.attr("opacity", 0.24);
|
| 380 |
+
if (xTickLabelIndexSet.has(i)) {
|
| 381 |
+
svg.append("text")
|
| 382 |
+
.attr("x", x)
|
| 383 |
+
.attr("y", plot.y + plot.height + 31)
|
| 384 |
+
.attr("text-anchor", "middle")
|
| 385 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 386 |
+
.attr("font-size", 18)
|
| 387 |
+
.attr("font-weight", 800)
|
| 388 |
+
.attr("fill", i === xValues.length - 1 ? "#54A2FF" : "#C7D3E5")
|
| 389 |
+
.text(tick.date instanceof Date && !Number.isNaN(tick.date.getTime()) && /^\d{4}$/.test(String(tick.value))
|
| 390 |
+
? String(tick.value)
|
| 391 |
+
: String(tick.value == null ? "" : tick.value));
|
| 392 |
+
}
|
| 393 |
+
});
|
| 394 |
+
|
| 395 |
+
svg.append("line")
|
| 396 |
+
.attr("x1", plot.x)
|
| 397 |
+
.attr("y1", plot.y)
|
| 398 |
+
.attr("x2", plot.x)
|
| 399 |
+
.attr("y2", plot.y + plot.height)
|
| 400 |
+
.attr("stroke", "#D9E7F9")
|
| 401 |
+
.attr("stroke-width", 1.2)
|
| 402 |
+
.attr("opacity", 0.72);
|
| 403 |
+
svg.append("line")
|
| 404 |
+
.attr("x1", plot.x)
|
| 405 |
+
.attr("y1", plot.y + plot.height)
|
| 406 |
+
.attr("x2", plot.x + plot.width)
|
| 407 |
+
.attr("y2", plot.y + plot.height)
|
| 408 |
+
.attr("stroke", "#D9E7F9")
|
| 409 |
+
.attr("stroke-width", 1.2)
|
| 410 |
+
.attr("opacity", 0.72);
|
| 411 |
+
|
| 412 |
+
const area = d3.area()
|
| 413 |
+
.x(d => xScale(d.__date))
|
| 414 |
+
.y0(plot.y + plot.height)
|
| 415 |
+
.y1(d => yScale(d.__y))
|
| 416 |
+
.curve(d3.curveMonotoneX);
|
| 417 |
+
|
| 418 |
+
groups.forEach((series, idx) => {
|
| 419 |
+
svg.append("path")
|
| 420 |
+
.datum(series.values)
|
| 421 |
+
.attr("class", "spline-layered-area-mark")
|
| 422 |
+
.attr("data-series", series.group)
|
| 423 |
+
.attr("fill", `url(#spline-layered-area-gradient-${idx})`)
|
| 424 |
+
.attr("stroke", colorFor(series.group, idx))
|
| 425 |
+
.attr("stroke-width", 2.4)
|
| 426 |
+
.attr("stroke-linejoin", "round")
|
| 427 |
+
.attr("opacity", idx === 0 ? 0.92 : 0.86)
|
| 428 |
+
.attr("d", area)
|
| 429 |
+
.style("filter", "url(#spline-layered-card-shadow)");
|
| 430 |
+
});
|
| 431 |
+
|
| 432 |
+
const visibleSeries = groups.slice(0, Math.min(2, groups.length));
|
| 433 |
+
visibleSeries.forEach((series, idx) => {
|
| 434 |
+
const color = colorFor(series.group, idx);
|
| 435 |
+
series.values.forEach((d, pointIdx) => {
|
| 436 |
+
const x = xScale(d.__date);
|
| 437 |
+
const y = yScale(d.__y);
|
| 438 |
+
svg.append("circle")
|
| 439 |
+
.attr("class", "spline-layered-point-marker")
|
| 440 |
+
.attr("data-series", series.group)
|
| 441 |
+
.attr("data-x", d[xField])
|
| 442 |
+
.attr("data-y", d[yField])
|
| 443 |
+
.attr("cx", x)
|
| 444 |
+
.attr("cy", y)
|
| 445 |
+
.attr("r", 5.6)
|
| 446 |
+
.attr("fill", color)
|
| 447 |
+
.attr("stroke", "#EAF4FF")
|
| 448 |
+
.attr("stroke-width", 2.2)
|
| 449 |
+
.style("filter", "url(#spline-layered-glow)");
|
| 450 |
+
const showEvery = series.values.length > 8 ? Math.ceil(series.values.length / 5) : 1;
|
| 451 |
+
if (pointIdx % showEvery === 0 || pointIdx === series.values.length - 1) {
|
| 452 |
+
svg.append("text")
|
| 453 |
+
.attr("x", x)
|
| 454 |
+
.attr("y", y - 14 - idx * 2)
|
| 455 |
+
.attr("text-anchor", "middle")
|
| 456 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 457 |
+
.attr("font-size", 16)
|
| 458 |
+
.attr("font-weight", 900)
|
| 459 |
+
.attr("fill", color)
|
| 460 |
+
.text(chartUtils.format.integer(d.__y).text);
|
| 461 |
+
}
|
| 462 |
+
});
|
| 463 |
+
});
|
| 464 |
+
|
| 465 |
+
const lastX = xScale(xValues[xValues.length - 1].date);
|
| 466 |
+
svg.append("line")
|
| 467 |
+
.attr("x1", lastX)
|
| 468 |
+
.attr("x2", lastX)
|
| 469 |
+
.attr("y1", plot.y)
|
| 470 |
+
.attr("y2", plot.y + plot.height)
|
| 471 |
+
.attr("stroke", "#D6E8FF")
|
| 472 |
+
.attr("stroke-width", 1.2)
|
| 473 |
+
.attr("stroke-dasharray", "4 5")
|
| 474 |
+
.attr("opacity", 0.72);
|
| 475 |
+
|
| 476 |
+
const placedCallouts = [];
|
| 477 |
+
visibleSeries.forEach((series, idx) => {
|
| 478 |
+
const d = series.latest;
|
| 479 |
+
const color = colorFor(series.group, idx);
|
| 480 |
+
const pointX = xScale(d.__date);
|
| 481 |
+
const pointY = yScale(d.__y);
|
| 482 |
+
const labelText = String(series.group).toUpperCase();
|
| 483 |
+
const boxW = Math.min(142, Math.max(92, 48 + labelText.length * 4.2));
|
| 484 |
+
const boxH = 58;
|
| 485 |
+
let boxX = Math.min(width - boxW - 22, pointX + 18);
|
| 486 |
+
let boxY = pointY - (idx === 0 ? 57 : -22);
|
| 487 |
+
placedCallouts.forEach(prev => {
|
| 488 |
+
if (Math.abs((boxY + boxH / 2) - (prev.y + prev.h / 2)) < boxH + 8) {
|
| 489 |
+
boxY = prev.y + prev.h + 12;
|
| 490 |
+
}
|
| 491 |
+
});
|
| 492 |
+
boxY = Math.max(plot.y + 8, Math.min(plot.y + plot.height - boxH - 8, boxY));
|
| 493 |
+
placedCallouts.push({ x: boxX, y: boxY, w: boxW, h: boxH });
|
| 494 |
+
|
| 495 |
+
svg.append("path")
|
| 496 |
+
.attr("class", "spline-layered-endpoint-leader")
|
| 497 |
+
.attr("data-series", series.group)
|
| 498 |
+
.attr("d", `M${pointX},${pointY} L${boxX},${boxY + boxH * 0.50} L${boxX},${boxY + boxH * 0.68} Z`)
|
| 499 |
+
.attr("fill", color)
|
| 500 |
+
.attr("opacity", 0.92);
|
| 501 |
+
svg.append("rect")
|
| 502 |
+
.attr("class", "spline-layered-endpoint-callout")
|
| 503 |
+
.attr("data-series", series.group)
|
| 504 |
+
.attr("x", boxX)
|
| 505 |
+
.attr("y", boxY)
|
| 506 |
+
.attr("width", boxW)
|
| 507 |
+
.attr("height", boxH)
|
| 508 |
+
.attr("rx", 10)
|
| 509 |
+
.attr("fill", color)
|
| 510 |
+
.attr("stroke", "#BBD8FF")
|
| 511 |
+
.attr("stroke-width", idx === 0 ? 1.6 : 1.0)
|
| 512 |
+
.style("filter", "url(#spline-layered-card-shadow)");
|
| 513 |
+
const labelFontSize = Math.min(12, Math.max(8, (boxW - 16) / Math.max(1, labelText.length) * 1.55));
|
| 514 |
+
const shouldFitLabel = labelText.length * labelFontSize * 0.62 > boxW - 16;
|
| 515 |
+
const label = svg.append("text")
|
| 516 |
+
.attr("x", boxX + boxW / 2)
|
| 517 |
+
.attr("y", boxY + 18)
|
| 518 |
+
.attr("text-anchor", "middle")
|
| 519 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 520 |
+
.attr("font-size", labelFontSize)
|
| 521 |
+
.attr("font-weight", 800)
|
| 522 |
+
.attr("fill", "#FFFFFF")
|
| 523 |
+
.attr("opacity", 0.92);
|
| 524 |
+
if (shouldFitLabel) {
|
| 525 |
+
label.attr("textLength", boxW - 16)
|
| 526 |
+
.attr("lengthAdjust", "spacingAndGlyphs");
|
| 527 |
+
}
|
| 528 |
+
label.text(labelText);
|
| 529 |
+
svg.append("text")
|
| 530 |
+
.attr("x", boxX + boxW / 2)
|
| 531 |
+
.attr("y", boxY + 48)
|
| 532 |
+
.attr("text-anchor", "middle")
|
| 533 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 534 |
+
.attr("font-size", 29)
|
| 535 |
+
.attr("font-weight", 900)
|
| 536 |
+
.attr("fill", "#FFFFFF")
|
| 537 |
+
.text(chartUtils.format.integer(d.__y).text);
|
| 538 |
+
});
|
| 539 |
+
|
| 540 |
+
if (visibleSeries.length >= 2 && visibleSeries[1].latestValue > 0) {
|
| 541 |
+
const gain = Math.round(((visibleSeries[0].latestValue - visibleSeries[1].latestValue) / visibleSeries[1].latestValue) * 100);
|
| 542 |
+
const topY = yScale(visibleSeries[0].latestValue);
|
| 543 |
+
const lowY = yScale(visibleSeries[1].latestValue);
|
| 544 |
+
const gainR = 58;
|
| 545 |
+
const candidates = [
|
| 546 |
+
{ x: plot.x + plot.width * 0.88, y: (topY + lowY) / 2 },
|
| 547 |
+
{ x: plot.x + plot.width * 0.78, y: plot.y + plot.height * 0.48 },
|
| 548 |
+
{ x: plot.x + plot.width * 0.72, y: plot.y + plot.height * 0.32 }
|
| 549 |
+
];
|
| 550 |
+
const collides = c => placedCallouts.some(b =>
|
| 551 |
+
c.x + gainR > b.x && c.x - gainR < b.x + b.w && c.y + gainR > b.y && c.y - gainR < b.y + b.h
|
| 552 |
+
);
|
| 553 |
+
const chosen = candidates.find(c => !collides(c)) || candidates[candidates.length - 1];
|
| 554 |
+
svg.append("g")
|
| 555 |
+
.attr("class", "spline-layered-gain-callout")
|
| 556 |
+
.attr("data-annotation", "latest-relative-gain")
|
| 557 |
+
.attr("data-anchor", "latest-series-gap")
|
| 558 |
+
.attr("data-collision-rule", "avoid endpoint callouts markers axes metric pill and plot edge")
|
| 559 |
+
.attr("transform", `translate(${chosen.x},${chosen.y})`)
|
| 560 |
+
.call(gainG => {
|
| 561 |
+
gainG.append("circle")
|
| 562 |
+
.attr("r", gainR)
|
| 563 |
+
.attr("fill", "#082349")
|
| 564 |
+
.attr("stroke", "#3F8CFF")
|
| 565 |
+
.attr("stroke-width", 3)
|
| 566 |
+
.style("filter", "url(#spline-layered-glow)");
|
| 567 |
+
gainG.append("circle")
|
| 568 |
+
.attr("r", gainR - 10)
|
| 569 |
+
.attr("fill", "none")
|
| 570 |
+
.attr("stroke", "#75B4FF")
|
| 571 |
+
.attr("stroke-width", 1.3)
|
| 572 |
+
.attr("opacity", 0.65);
|
| 573 |
+
gainG.append("text")
|
| 574 |
+
.attr("x", 0)
|
| 575 |
+
.attr("y", 2)
|
| 576 |
+
.attr("text-anchor", "middle")
|
| 577 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 578 |
+
.attr("font-size", String(gain).length > 3 ? 28 : 37)
|
| 579 |
+
.attr("font-weight", 900)
|
| 580 |
+
.attr("fill", "#FFFFFF")
|
| 581 |
+
.text(`${gain >= 0 ? "+" : ""}${gain}%`);
|
| 582 |
+
gainG.append("text")
|
| 583 |
+
.attr("x", 0)
|
| 584 |
+
.attr("y", 28)
|
| 585 |
+
.attr("text-anchor", "middle")
|
| 586 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 587 |
+
.attr("font-size", 11)
|
| 588 |
+
.attr("font-weight", 700)
|
| 589 |
+
.attr("fill", "#DDEBFF")
|
| 590 |
+
.text("LATEST GAP");
|
| 591 |
+
});
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
return svg.node();
|
| 595 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/spline_multiple_area_chart_01.js
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Area Chart",
|
| 5 |
+
"chart_name": "spline_multiple_area_chart_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 50], [0, "inf"], [2, 2]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "dark",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
const jsonData = data;
|
| 27 |
+
const chartData = jsonData.data.data;
|
| 28 |
+
const variables = jsonData.variables;
|
| 29 |
+
const colors = jsonData.colors_dark || {};
|
| 30 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 31 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 32 |
+
|
| 33 |
+
d3.select(containerSelector).html("");
|
| 34 |
+
|
| 35 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 36 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 37 |
+
const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
|
| 38 |
+
const yLabel = yColumn.rawLabel || yField;
|
| 39 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 40 |
+
|
| 41 |
+
let groups = [...new Set(chartData.map(d => d[groupField]))].slice(0, 2);
|
| 42 |
+
if (groups.length < 2) {
|
| 43 |
+
groups = groups.concat(["Series B"]).slice(0, 2);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const width = +variables.width || 800;
|
| 47 |
+
const inputHeight = +variables.height || 600;
|
| 48 |
+
const height = Math.max(inputHeight, Math.round(width * 1.15));
|
| 49 |
+
const uid = `spline-area-${Math.floor(Math.random() * 1e9)}`;
|
| 50 |
+
|
| 51 |
+
const svg = d3.select(containerSelector)
|
| 52 |
+
.append("svg")
|
| 53 |
+
.attr("width", "100%")
|
| 54 |
+
.attr("height", height)
|
| 55 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 56 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 57 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 58 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 59 |
+
|
| 60 |
+
const defs = svg.append("defs");
|
| 61 |
+
defs.append("linearGradient")
|
| 62 |
+
.attr("id", `${uid}-panel`)
|
| 63 |
+
.attr("x1", "0%")
|
| 64 |
+
.attr("y1", "0%")
|
| 65 |
+
.attr("x2", "100%")
|
| 66 |
+
.attr("y2", "100%")
|
| 67 |
+
.call(grad => {
|
| 68 |
+
grad.append("stop").attr("offset", "0%").attr("stop-color", "#052742");
|
| 69 |
+
grad.append("stop").attr("offset", "56%").attr("stop-color", "#062033");
|
| 70 |
+
grad.append("stop").attr("offset", "100%").attr("stop-color", "#031524");
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
defs.append("radialGradient")
|
| 74 |
+
.attr("id", `${uid}-light`)
|
| 75 |
+
.attr("cx", "82%")
|
| 76 |
+
.attr("cy", "0%")
|
| 77 |
+
.attr("r", "80%")
|
| 78 |
+
.call(grad => {
|
| 79 |
+
grad.append("stop").attr("offset", "0%").attr("stop-color", "#2ed1d0").attr("stop-opacity", 0.28);
|
| 80 |
+
grad.append("stop").attr("offset", "46%").attr("stop-color", "#0b4264").attr("stop-opacity", 0.16);
|
| 81 |
+
grad.append("stop").attr("offset", "100%").attr("stop-color", "#00101f").attr("stop-opacity", 0);
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
defs.append("radialGradient")
|
| 85 |
+
.attr("id", `${uid}-slotFill`)
|
| 86 |
+
.attr("cx", "50%")
|
| 87 |
+
.attr("cy", "42%")
|
| 88 |
+
.attr("r", "58%")
|
| 89 |
+
.call(grad => {
|
| 90 |
+
grad.append("stop").attr("offset", "0%").attr("stop-color", "#1a8195").attr("stop-opacity", 0.44);
|
| 91 |
+
grad.append("stop").attr("offset", "100%").attr("stop-color", "#06293e").attr("stop-opacity", 0.22);
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
defs.append("linearGradient")
|
| 95 |
+
.attr("id", `${uid}-leftArea`)
|
| 96 |
+
.attr("x1", "0%")
|
| 97 |
+
.attr("y1", "0%")
|
| 98 |
+
.attr("x2", "100%")
|
| 99 |
+
.attr("y2", "0%")
|
| 100 |
+
.call(grad => {
|
| 101 |
+
grad.append("stop").attr("offset", "0%").attr("stop-color", "#bdf5eb");
|
| 102 |
+
grad.append("stop").attr("offset", "100%").attr("stop-color", "#7fe4d6");
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
defs.append("linearGradient")
|
| 106 |
+
.attr("id", `${uid}-rightArea`)
|
| 107 |
+
.attr("x1", "0%")
|
| 108 |
+
.attr("y1", "0%")
|
| 109 |
+
.attr("x2", "100%")
|
| 110 |
+
.attr("y2", "0%")
|
| 111 |
+
.call(grad => {
|
| 112 |
+
grad.append("stop").attr("offset", "0%").attr("stop-color", "#28d5cf");
|
| 113 |
+
grad.append("stop").attr("offset", "100%").attr("stop-color", "#1aabae");
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
defs.append("filter")
|
| 117 |
+
.attr("id", `${uid}-softGlow`)
|
| 118 |
+
.attr("x", "-18%")
|
| 119 |
+
.attr("y", "-18%")
|
| 120 |
+
.attr("width", "136%")
|
| 121 |
+
.attr("height", "136%")
|
| 122 |
+
.call(filter => {
|
| 123 |
+
filter.append("feGaussianBlur").attr("stdDeviation", 3).attr("result", "blur");
|
| 124 |
+
const merge = filter.append("feMerge");
|
| 125 |
+
merge.append("feMergeNode").attr("in", "blur");
|
| 126 |
+
merge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
svg.append("rect")
|
| 130 |
+
.attr("x", 0)
|
| 131 |
+
.attr("y", 0)
|
| 132 |
+
.attr("width", width)
|
| 133 |
+
.attr("height", height)
|
| 134 |
+
.attr("rx", 0)
|
| 135 |
+
.attr("fill", `url(#${uid}-panel)`)
|
| 136 |
+
.attr("fill-opacity", 0.28);
|
| 137 |
+
|
| 138 |
+
svg.append("rect")
|
| 139 |
+
.attr("x", 0)
|
| 140 |
+
.attr("y", 0)
|
| 141 |
+
.attr("width", width)
|
| 142 |
+
.attr("height", height)
|
| 143 |
+
.attr("fill", `url(#${uid}-light)`);
|
| 144 |
+
|
| 145 |
+
const margin = {
|
| 146 |
+
top: Math.max(62, height * 0.09),
|
| 147 |
+
right: Math.max(34, width * 0.06),
|
| 148 |
+
bottom: Math.max(128, height * 0.20),
|
| 149 |
+
left: Math.max(34, width * 0.06)
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
const plot = {
|
| 153 |
+
x: margin.left,
|
| 154 |
+
y: margin.top,
|
| 155 |
+
w: width - margin.left - margin.right,
|
| 156 |
+
h: Math.max(230, height - margin.top - margin.bottom)
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
const centerX = plot.x + plot.w / 2;
|
| 160 |
+
const centerWidth = Math.max(54, Math.min(78, plot.w * 0.13));
|
| 161 |
+
const halfCenter = centerWidth / 2;
|
| 162 |
+
const leftBase = centerX - halfCenter;
|
| 163 |
+
const rightBase = centerX + halfCenter;
|
| 164 |
+
const leftOuter = plot.x + Math.max(6, plot.w * 0.015);
|
| 165 |
+
const rightOuter = plot.x + plot.w - Math.max(6, plot.w * 0.015);
|
| 166 |
+
|
| 167 |
+
const parseTemporal = value => {
|
| 168 |
+
const parsed = chartUtils.format.parseDate(value);
|
| 169 |
+
if (parsed instanceof Date && !Number.isNaN(+parsed)) return parsed;
|
| 170 |
+
const numeric = Number(value);
|
| 171 |
+
if (Number.isFinite(numeric)) return new Date(numeric, 0, 1);
|
| 172 |
+
return new Date(String(value));
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
const prepared = chartData
|
| 176 |
+
.filter(d => groups.includes(d[groupField]))
|
| 177 |
+
.map((d, index) => ({
|
| 178 |
+
raw: d,
|
| 179 |
+
index,
|
| 180 |
+
group: d[groupField],
|
| 181 |
+
date: parseTemporal(d[xField]),
|
| 182 |
+
value: Math.max(0, +d[yField] || 0)
|
| 183 |
+
}))
|
| 184 |
+
.filter(d => d.date instanceof Date && !Number.isNaN(+d.date));
|
| 185 |
+
|
| 186 |
+
const allDates = [...new Map(prepared.map(d => [+d.date, d.date])).values()]
|
| 187 |
+
.sort((a, b) => +a - +b);
|
| 188 |
+
|
| 189 |
+
const xScale = d3.scaleTime()
|
| 190 |
+
.domain(d3.extent(allDates))
|
| 191 |
+
.range([plot.y + 2, plot.y + plot.h - 2]);
|
| 192 |
+
|
| 193 |
+
const maxValue = d3.max(prepared, d => d.value) || 1;
|
| 194 |
+
const step = niceStep(maxValue);
|
| 195 |
+
const niceMax = Math.max(step, Math.ceil(maxValue / step) * step);
|
| 196 |
+
|
| 197 |
+
const leftScale = d3.scaleLinear()
|
| 198 |
+
.domain([0, niceMax])
|
| 199 |
+
.range([leftBase, leftOuter]);
|
| 200 |
+
|
| 201 |
+
const rightScale = d3.scaleLinear()
|
| 202 |
+
.domain([0, niceMax])
|
| 203 |
+
.range([rightBase, rightOuter]);
|
| 204 |
+
|
| 205 |
+
const panel = svg.append("g").attr("class", "spline-multiple-area-panel");
|
| 206 |
+
|
| 207 |
+
for (let i = 0; i < 10; i++) {
|
| 208 |
+
panel.append("path")
|
| 209 |
+
.attr("d", wavePath(width, height, i))
|
| 210 |
+
.attr("fill", "none")
|
| 211 |
+
.attr("stroke", "#2db7c2")
|
| 212 |
+
.attr("stroke-opacity", i % 2 ? 0.06 : 0.038)
|
| 213 |
+
.attr("stroke-width", 1);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
for (let i = 0; i < 6; i++) {
|
| 217 |
+
panel.append("circle")
|
| 218 |
+
.attr("class", "ambient-bubble")
|
| 219 |
+
.attr("cx", width * (0.08 + i * 0.16))
|
| 220 |
+
.attr("cy", height * (0.18 + (i % 3) * 0.18))
|
| 221 |
+
.attr("r", 2.4 + (i % 2) * 1.6)
|
| 222 |
+
.attr("fill", "#9EECEF")
|
| 223 |
+
.attr("fill-opacity", 0.10 + (i % 2) * 0.05);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
const tickDates = chooseTemporalTicks(allDates);
|
| 227 |
+
const yearFormat = d3.timeFormat("%Y");
|
| 228 |
+
|
| 229 |
+
panel.append("rect")
|
| 230 |
+
.attr("class", "center-year-band")
|
| 231 |
+
.attr("x", leftBase)
|
| 232 |
+
.attr("y", plot.y - 1)
|
| 233 |
+
.attr("width", centerWidth)
|
| 234 |
+
.attr("height", plot.h + 2)
|
| 235 |
+
.attr("rx", 3)
|
| 236 |
+
.attr("fill", "#061a2d")
|
| 237 |
+
.attr("fill-opacity", 0.88)
|
| 238 |
+
.attr("stroke", "#4ea9cb")
|
| 239 |
+
.attr("stroke-opacity", 0.35);
|
| 240 |
+
|
| 241 |
+
tickDates.forEach(date => {
|
| 242 |
+
const y = xScale(date);
|
| 243 |
+
panel.append("line")
|
| 244 |
+
.attr("class", "year-guide year-guide--left")
|
| 245 |
+
.attr("x1", leftOuter)
|
| 246 |
+
.attr("x2", leftBase - 2)
|
| 247 |
+
.attr("y1", y)
|
| 248 |
+
.attr("y2", y)
|
| 249 |
+
.attr("stroke", "#8ac8df")
|
| 250 |
+
.attr("stroke-width", 1)
|
| 251 |
+
.attr("stroke-dasharray", "2 4")
|
| 252 |
+
.attr("stroke-opacity", 0.42);
|
| 253 |
+
panel.append("line")
|
| 254 |
+
.attr("class", "year-guide year-guide--right")
|
| 255 |
+
.attr("x1", rightBase + 2)
|
| 256 |
+
.attr("x2", rightOuter)
|
| 257 |
+
.attr("y1", y)
|
| 258 |
+
.attr("y2", y)
|
| 259 |
+
.attr("stroke", "#8ac8df")
|
| 260 |
+
.attr("stroke-width", 1)
|
| 261 |
+
.attr("stroke-dasharray", "2 4")
|
| 262 |
+
.attr("stroke-opacity", 0.42);
|
| 263 |
+
panel.append("text")
|
| 264 |
+
.attr("class", "center-year-label")
|
| 265 |
+
.attr("x", centerX)
|
| 266 |
+
.attr("y", y)
|
| 267 |
+
.attr("text-anchor", "middle")
|
| 268 |
+
.attr("dominant-baseline", "middle")
|
| 269 |
+
.attr("fill", "#f2fbff")
|
| 270 |
+
.style("font-size", `${Math.max(11, Math.min(14, height * 0.022))}px`)
|
| 271 |
+
.style("font-weight", 700)
|
| 272 |
+
.text(yearFormat(date));
|
| 273 |
+
});
|
| 274 |
+
|
| 275 |
+
const areaGroups = groups.map((group, i) => {
|
| 276 |
+
const series = prepared
|
| 277 |
+
.filter(d => d.group === group)
|
| 278 |
+
.sort((a, b) => +a.date - +b.date);
|
| 279 |
+
return { group, index: i, series };
|
| 280 |
+
});
|
| 281 |
+
|
| 282 |
+
areaGroups.forEach(({ group, index, series }) => {
|
| 283 |
+
const isLeft = index === 0;
|
| 284 |
+
const scale = isLeft ? leftScale : rightScale;
|
| 285 |
+
const baseX = isLeft ? leftBase : rightBase;
|
| 286 |
+
const gradientId = isLeft ? `${uid}-leftArea` : `${uid}-rightArea`;
|
| 287 |
+
const stroke = isLeft ? "#c9fff4" : "#66f6ec";
|
| 288 |
+
|
| 289 |
+
const area = d3.area()
|
| 290 |
+
.x0(baseX)
|
| 291 |
+
.x1(d => scale(d.value))
|
| 292 |
+
.y(d => xScale(d.date))
|
| 293 |
+
.curve(d3.curveBasis);
|
| 294 |
+
|
| 295 |
+
panel.append("path")
|
| 296 |
+
.datum(series)
|
| 297 |
+
.attr("class", `area-mark area-mark--${isLeft ? "left" : "right"}`)
|
| 298 |
+
.attr("data-chart-mark", "spline-area")
|
| 299 |
+
.attr("data-series", group)
|
| 300 |
+
.attr("fill", `url(#${gradientId})`)
|
| 301 |
+
.attr("fill-opacity", 0.96)
|
| 302 |
+
.attr("stroke", stroke)
|
| 303 |
+
.attr("stroke-width", 2)
|
| 304 |
+
.attr("stroke-linejoin", "round")
|
| 305 |
+
.attr("filter", `url(#${uid}-softGlow)`)
|
| 306 |
+
.attr("d", area);
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
drawUnitBadge(panel, centerX, Math.max(16, plot.y - 48), yLabel, yColumn.unit || "");
|
| 310 |
+
drawBottomAxis(panel, plot, leftOuter, leftBase, rightBase, rightOuter, niceMax, yLabel, yColumn.unit || "");
|
| 311 |
+
drawGroupCallouts(panel, plot, groups, colorResolver);
|
| 312 |
+
drawLegend(panel, plot, groups, colorResolver);
|
| 313 |
+
|
| 314 |
+
return svg.node();
|
| 315 |
+
|
| 316 |
+
function drawUnitBadge(root, x, y, label, unit) {
|
| 317 |
+
const badgeW = Math.min(166, Math.max(112, chartUtils.text.measure(null, label, { fontSize: 12, fontWeight: "bold" }).width + 44));
|
| 318 |
+
root.append("rect")
|
| 319 |
+
.attr("class", "unit-badge")
|
| 320 |
+
.attr("x", x - badgeW / 2)
|
| 321 |
+
.attr("y", y)
|
| 322 |
+
.attr("width", badgeW)
|
| 323 |
+
.attr("height", 42)
|
| 324 |
+
.attr("rx", 8)
|
| 325 |
+
.attr("fill", "#082b48")
|
| 326 |
+
.attr("fill-opacity", 0.86)
|
| 327 |
+
.attr("stroke", "#57cae1")
|
| 328 |
+
.attr("stroke-opacity", 0.64);
|
| 329 |
+
root.append("text")
|
| 330 |
+
.attr("x", x)
|
| 331 |
+
.attr("y", y + 17)
|
| 332 |
+
.attr("text-anchor", "middle")
|
| 333 |
+
.attr("fill", "#f6fbff")
|
| 334 |
+
.style("font-size", "13px")
|
| 335 |
+
.style("font-weight", 800)
|
| 336 |
+
.style("letter-spacing", "1px")
|
| 337 |
+
.text(label.toUpperCase());
|
| 338 |
+
root.append("text")
|
| 339 |
+
.attr("x", x)
|
| 340 |
+
.attr("y", y + 33)
|
| 341 |
+
.attr("text-anchor", "middle")
|
| 342 |
+
.attr("fill", "#d7edf4")
|
| 343 |
+
.style("font-size", "12px")
|
| 344 |
+
.text(unit ? `(${unit})` : "");
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
function drawBottomAxis(root, plotBox, leftStart, leftEnd, rightStart, rightEnd, axisMax, label, unit) {
|
| 348 |
+
const axisY = plotBox.y + plotBox.h + 16;
|
| 349 |
+
const ticks = d3.ticks(0, axisMax, 5);
|
| 350 |
+
|
| 351 |
+
root.append("line")
|
| 352 |
+
.attr("x1", leftStart)
|
| 353 |
+
.attr("x2", leftEnd)
|
| 354 |
+
.attr("y1", axisY)
|
| 355 |
+
.attr("y2", axisY)
|
| 356 |
+
.attr("stroke", "#d9f6ff")
|
| 357 |
+
.attr("stroke-width", 1.2)
|
| 358 |
+
.attr("stroke-opacity", 0.82);
|
| 359 |
+
root.append("line")
|
| 360 |
+
.attr("x1", rightStart)
|
| 361 |
+
.attr("x2", rightEnd)
|
| 362 |
+
.attr("y1", axisY)
|
| 363 |
+
.attr("y2", axisY)
|
| 364 |
+
.attr("stroke", "#d9f6ff")
|
| 365 |
+
.attr("stroke-width", 1.2)
|
| 366 |
+
.attr("stroke-opacity", 0.82);
|
| 367 |
+
|
| 368 |
+
ticks.forEach(tick => {
|
| 369 |
+
const lx = leftScale(tick);
|
| 370 |
+
const rx = rightScale(tick);
|
| 371 |
+
[lx, rx].forEach(x => {
|
| 372 |
+
root.append("circle")
|
| 373 |
+
.attr("cx", x)
|
| 374 |
+
.attr("cy", axisY)
|
| 375 |
+
.attr("r", 3)
|
| 376 |
+
.attr("fill", "#d9f6ff")
|
| 377 |
+
.attr("fill-opacity", 0.9);
|
| 378 |
+
root.append("line")
|
| 379 |
+
.attr("x1", x)
|
| 380 |
+
.attr("x2", x)
|
| 381 |
+
.attr("y1", axisY - 8)
|
| 382 |
+
.attr("y2", axisY + 8)
|
| 383 |
+
.attr("stroke", "#d9f6ff")
|
| 384 |
+
.attr("stroke-opacity", 0.65);
|
| 385 |
+
});
|
| 386 |
+
root.append("text")
|
| 387 |
+
.attr("x", lx)
|
| 388 |
+
.attr("y", axisY + 28)
|
| 389 |
+
.attr("text-anchor", "middle")
|
| 390 |
+
.attr("fill", "#e9f6fb")
|
| 391 |
+
.style("font-size", "12px")
|
| 392 |
+
.text(formatTick(tick));
|
| 393 |
+
root.append("text")
|
| 394 |
+
.attr("x", rx)
|
| 395 |
+
.attr("y", axisY + 28)
|
| 396 |
+
.attr("text-anchor", "middle")
|
| 397 |
+
.attr("fill", "#e9f6fb")
|
| 398 |
+
.style("font-size", "12px")
|
| 399 |
+
.text(formatTick(tick));
|
| 400 |
+
});
|
| 401 |
+
|
| 402 |
+
root.append("text")
|
| 403 |
+
.attr("x", centerX)
|
| 404 |
+
.attr("y", axisY + 47)
|
| 405 |
+
.attr("text-anchor", "middle")
|
| 406 |
+
.attr("fill", "#f5fbff")
|
| 407 |
+
.style("font-size", "15px")
|
| 408 |
+
.style("font-weight", 800)
|
| 409 |
+
.style("letter-spacing", "1px")
|
| 410 |
+
.text(`${label.toUpperCase()}${unit ? ` (${unit.toUpperCase()})` : ""}`);
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
function drawGroupCallouts(root, plotBox, labels, resolver) {
|
| 414 |
+
const calloutY = plotBox.y + plotBox.h * 0.58;
|
| 415 |
+
const slotY = calloutY - 63;
|
| 416 |
+
const callouts = [
|
| 417 |
+
{ label: labels[0], side: "left", x: plotBox.x + Math.min(30, plotBox.w * 0.06), textAnchor: "middle" },
|
| 418 |
+
{ label: labels[1], side: "right", x: plotBox.x + plotBox.w - Math.min(42, plotBox.w * 0.08), textAnchor: "middle" }
|
| 419 |
+
];
|
| 420 |
+
|
| 421 |
+
callouts.forEach((item, i) => {
|
| 422 |
+
const w = Math.min(154, Math.max(102, plotBox.w * 0.25));
|
| 423 |
+
const h = 48;
|
| 424 |
+
const x = clamp(item.x - w / 2, plotBox.x + 2, plotBox.x + plotBox.w - w - 2);
|
| 425 |
+
const color = resolver.field(item.label, i, { palette: "category10" }).value;
|
| 426 |
+
|
| 427 |
+
root.append("circle")
|
| 428 |
+
.attr("class", `reserved-asset-slot reserved-asset-slot--side-${item.side}`)
|
| 429 |
+
.attr("data-asset-slot", `side_callout_pictogram_${item.side}`)
|
| 430 |
+
.attr("data-anchor", `${item.side}-group-callout`)
|
| 431 |
+
.attr("data-bbox", `${Math.round(item.x - 24)},${Math.round(slotY - 24)},48,48`)
|
| 432 |
+
.attr("data-collision-rule", "avoid area marks, group label, center year axis, bottom axis, and legend")
|
| 433 |
+
.attr("data-slot-policy", "neutral_placeholder_or_image2_asset")
|
| 434 |
+
.attr("data-asset-source-policy", "neutral_placeholder")
|
| 435 |
+
.attr("cx", item.x)
|
| 436 |
+
.attr("cy", slotY)
|
| 437 |
+
.attr("r", 24)
|
| 438 |
+
.attr("fill", `url(#${uid}-slotFill)`)
|
| 439 |
+
.attr("fill-opacity", 0.92)
|
| 440 |
+
.attr("stroke", color)
|
| 441 |
+
.attr("stroke-width", 2)
|
| 442 |
+
.attr("stroke-dasharray", "5 5")
|
| 443 |
+
.attr("stroke-opacity", 0.82);
|
| 444 |
+
|
| 445 |
+
root.append("rect")
|
| 446 |
+
.attr("class", "group-callout-box")
|
| 447 |
+
.attr("x", x)
|
| 448 |
+
.attr("y", calloutY - h / 2)
|
| 449 |
+
.attr("width", w)
|
| 450 |
+
.attr("height", h)
|
| 451 |
+
.attr("rx", 7)
|
| 452 |
+
.attr("fill", "#dff8f8")
|
| 453 |
+
.attr("fill-opacity", 0.88)
|
| 454 |
+
.attr("stroke", "#ffffff")
|
| 455 |
+
.attr("stroke-opacity", 0.55);
|
| 456 |
+
|
| 457 |
+
const text = root.append("text")
|
| 458 |
+
.attr("class", "group-callout-label")
|
| 459 |
+
.attr("x", x + w / 2)
|
| 460 |
+
.attr("y", calloutY - 7)
|
| 461 |
+
.attr("text-anchor", item.textAnchor)
|
| 462 |
+
.attr("fill", "#07304a")
|
| 463 |
+
.style("font-size", "13px")
|
| 464 |
+
.style("font-weight", 800);
|
| 465 |
+
wrapText(text, item.label, w - 16, 15, 2);
|
| 466 |
+
});
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
function drawLegend(root, plotBox, labels, resolver) {
|
| 470 |
+
const legendY = height - Math.max(30, height * 0.052);
|
| 471 |
+
const itemW = Math.min(220, plotBox.w * 0.42);
|
| 472 |
+
const legendW = itemW * 2 + 24;
|
| 473 |
+
const legendX = clamp(centerX - legendW / 2, 14, width - legendW - 14);
|
| 474 |
+
|
| 475 |
+
root.append("rect")
|
| 476 |
+
.attr("class", "chart-owned-legend")
|
| 477 |
+
.attr("x", legendX)
|
| 478 |
+
.attr("y", legendY - 24)
|
| 479 |
+
.attr("width", legendW)
|
| 480 |
+
.attr("height", 48)
|
| 481 |
+
.attr("rx", 8)
|
| 482 |
+
.attr("fill", "#05243d")
|
| 483 |
+
.attr("fill-opacity", 0.72)
|
| 484 |
+
.attr("stroke", "#3fa9c9")
|
| 485 |
+
.attr("stroke-opacity", 0.35);
|
| 486 |
+
|
| 487 |
+
labels.forEach((label, i) => {
|
| 488 |
+
const x = legendX + 16 + i * itemW;
|
| 489 |
+
const color = i === 0 ? "#9eece1" : "#21c6c2";
|
| 490 |
+
root.append("rect")
|
| 491 |
+
.attr("x", x)
|
| 492 |
+
.attr("y", legendY - 12)
|
| 493 |
+
.attr("width", 24)
|
| 494 |
+
.attr("height", 24)
|
| 495 |
+
.attr("rx", 4)
|
| 496 |
+
.attr("fill", color)
|
| 497 |
+
.attr("stroke", "#d7fffb")
|
| 498 |
+
.attr("stroke-opacity", 0.7);
|
| 499 |
+
const text = root.append("text")
|
| 500 |
+
.attr("x", x + 34)
|
| 501 |
+
.attr("y", legendY + 4)
|
| 502 |
+
.attr("fill", "#f6fbff")
|
| 503 |
+
.style("font-size", "13px")
|
| 504 |
+
.style("font-weight", 600);
|
| 505 |
+
wrapText(text, label, itemW - 48, 14, 2);
|
| 506 |
+
});
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
function chooseTemporalTicks(dates) {
|
| 510 |
+
if (dates.length <= 8) return dates;
|
| 511 |
+
const stride = Math.ceil(dates.length / 8);
|
| 512 |
+
const picked = dates.filter((_, i) => i % stride === 0);
|
| 513 |
+
const last = dates[dates.length - 1];
|
| 514 |
+
if (+picked[picked.length - 1] !== +last) picked.push(last);
|
| 515 |
+
return picked;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
function niceStep(value) {
|
| 519 |
+
if (value <= 1) return 0.2;
|
| 520 |
+
const exponent = Math.floor(Math.log10(value));
|
| 521 |
+
const base = Math.pow(10, exponent);
|
| 522 |
+
const scaled = value / base;
|
| 523 |
+
if (scaled <= 2) return base / 5;
|
| 524 |
+
if (scaled <= 5) return base / 2;
|
| 525 |
+
return base;
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
function formatTick(value) {
|
| 529 |
+
if (Math.abs(value) >= 1000) return d3.format(".2s")(value).replace("G", "B");
|
| 530 |
+
if (Math.abs(value) < 1 && value !== 0) return d3.format(".2f")(value);
|
| 531 |
+
return d3.format("~g")(value);
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
function wavePath(w, h, i) {
|
| 535 |
+
const y = h * (0.16 + i * 0.095);
|
| 536 |
+
const amp = 10 + i * 2;
|
| 537 |
+
return `M ${-20} ${y} C ${w * 0.18} ${y - amp}, ${w * 0.28} ${y + amp}, ${w * 0.48} ${y} S ${w * 0.82} ${y - amp}, ${w + 20} ${y + amp * 0.35}`;
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
function wrapText(selection, text, maxWidth, lineHeight, maxLines) {
|
| 541 |
+
const words = String(text).split(/\s+/).filter(Boolean);
|
| 542 |
+
const x = +selection.attr("x");
|
| 543 |
+
const y = +selection.attr("y");
|
| 544 |
+
selection.text(null);
|
| 545 |
+
let line = [];
|
| 546 |
+
let lineNumber = 0;
|
| 547 |
+
let tspan = selection.append("tspan").attr("x", x).attr("y", y);
|
| 548 |
+
|
| 549 |
+
words.forEach(word => {
|
| 550 |
+
line.push(word);
|
| 551 |
+
tspan.text(line.join(" "));
|
| 552 |
+
if (tspan.node().getComputedTextLength() > maxWidth && line.length > 1) {
|
| 553 |
+
line.pop();
|
| 554 |
+
tspan.text(line.join(" "));
|
| 555 |
+
line = [word];
|
| 556 |
+
lineNumber += 1;
|
| 557 |
+
if (lineNumber >= maxLines) {
|
| 558 |
+
tspan.text(`${tspan.text()}...`);
|
| 559 |
+
return;
|
| 560 |
+
}
|
| 561 |
+
tspan = selection.append("tspan")
|
| 562 |
+
.attr("x", x)
|
| 563 |
+
.attr("y", y + lineNumber * lineHeight)
|
| 564 |
+
.text(word);
|
| 565 |
+
}
|
| 566 |
+
});
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
function clamp(value, min, max) {
|
| 570 |
+
return Math.max(min, Math.min(max, value));
|
| 571 |
+
}
|
| 572 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/spline_stacked_area_chart_01.js
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Stacked Area Chart",
|
| 5 |
+
"chart_name": "spline_stacked_area_chart_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 50], [0, "inf"], [2, 6]],
|
| 9 |
+
"required_fields_icons": ["group"],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "dark",
|
| 17 |
+
"icon_mark": "overlay",
|
| 18 |
+
"icon_label": "side",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
const jsonData = data;
|
| 27 |
+
const rawRows = jsonData.data.data || [];
|
| 28 |
+
const variables = jsonData.variables || {};
|
| 29 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 30 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 31 |
+
const images = jsonData.images || {};
|
| 32 |
+
const fontSize = chartUtils.text.fontSize;
|
| 33 |
+
|
| 34 |
+
d3.select(containerSelector).html("");
|
| 35 |
+
|
| 36 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 37 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 38 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 39 |
+
|
| 40 |
+
const width = variables.width || 600;
|
| 41 |
+
const height = Math.max(720, Math.round((variables.height || 600) * 1.2));
|
| 42 |
+
const uid = `splineStackedArea${Math.random().toString(36).slice(2)}`;
|
| 43 |
+
|
| 44 |
+
const parseTemporal = value => {
|
| 45 |
+
if (value instanceof Date) return value;
|
| 46 |
+
if (typeof value === "number" || /^[0-9]{3,4}$/.test(String(value))) {
|
| 47 |
+
return new Date(Number(value), 0, 1);
|
| 48 |
+
}
|
| 49 |
+
const parsed = chartUtils.format.parseDate(value);
|
| 50 |
+
if (parsed instanceof Date && !isNaN(parsed)) return parsed;
|
| 51 |
+
const fallback = new Date(value);
|
| 52 |
+
return isNaN(fallback) ? new Date(0) : fallback;
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const formatYear = date => {
|
| 56 |
+
const parsed = date instanceof Date ? date : parseTemporal(date);
|
| 57 |
+
return chartUtils.format.date(parsed, { output: "%Y" }).text;
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
const rows = rawRows
|
| 61 |
+
.map((d, index) => ({
|
| 62 |
+
...d,
|
| 63 |
+
__index: index,
|
| 64 |
+
__date: parseTemporal(d[xField]),
|
| 65 |
+
__value: Math.max(0, +d[yField] || 0),
|
| 66 |
+
__group: String(d[groupField])
|
| 67 |
+
}))
|
| 68 |
+
.sort((a, b) => a.__date - b.__date);
|
| 69 |
+
|
| 70 |
+
const groupStats = Array.from(d3.group(rows, d => d.__group), ([group, values]) => {
|
| 71 |
+
const ordered = values.slice().sort((a, b) => a.__date - b.__date);
|
| 72 |
+
const latest = ordered[ordered.length - 1];
|
| 73 |
+
return {
|
| 74 |
+
group,
|
| 75 |
+
values: ordered,
|
| 76 |
+
latestValue: latest ? latest.__value : 0,
|
| 77 |
+
avgValue: d3.mean(ordered, d => d.__value) || 0
|
| 78 |
+
};
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
const groups = groupStats
|
| 82 |
+
.slice()
|
| 83 |
+
.sort((a, b) => a.avgValue - b.avgValue)
|
| 84 |
+
.map(d => d.group);
|
| 85 |
+
|
| 86 |
+
const distinctDates = Array.from(new Map(rows.map(d => [+d.__date, d.__date])).values())
|
| 87 |
+
.sort((a, b) => a - b);
|
| 88 |
+
|
| 89 |
+
const stackData = distinctDates.map(date => {
|
| 90 |
+
const entry = { date };
|
| 91 |
+
groups.forEach(group => {
|
| 92 |
+
const row = rows.find(d => +d.__date === +date && d.__group === group);
|
| 93 |
+
entry[group] = row ? row.__value : 0;
|
| 94 |
+
});
|
| 95 |
+
return entry;
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
const latestDate = distinctDates[distinctDates.length - 1];
|
| 99 |
+
const latestYear = latestDate ? formatYear(latestDate) : "";
|
| 100 |
+
const latestTotal = d3.sum(groups, group => {
|
| 101 |
+
const stat = groupStats.find(d => d.group === group);
|
| 102 |
+
return stat ? stat.latestValue : 0;
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
const safeText = value => String(value == null ? "" : value);
|
| 106 |
+
const title = safeText(jsonData.titles && jsonData.titles.main_title ? jsonData.titles.main_title : jsonData.metadata && jsonData.metadata.title ? jsonData.metadata.title : "");
|
| 107 |
+
const subtitle = safeText(jsonData.titles && jsonData.titles.sub_title ? jsonData.titles.sub_title : jsonData.description || "").replace(/^"|"$/g, "");
|
| 108 |
+
|
| 109 |
+
const svg = d3.select(containerSelector)
|
| 110 |
+
.append("svg")
|
| 111 |
+
.attr("width", "100%")
|
| 112 |
+
.attr("height", height)
|
| 113 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 114 |
+
.attr("style", "max-width: 100%; height: auto; font-family: Arial, 'Liberation Sans', sans-serif;")
|
| 115 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 116 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 117 |
+
|
| 118 |
+
const defs = svg.append("defs");
|
| 119 |
+
const bgGradient = defs.append("radialGradient")
|
| 120 |
+
.attr("id", `${uid}-bg`)
|
| 121 |
+
.attr("cx", "58%")
|
| 122 |
+
.attr("cy", "48%")
|
| 123 |
+
.attr("r", "78%");
|
| 124 |
+
bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#092445");
|
| 125 |
+
bgGradient.append("stop").attr("offset", "58%").attr("stop-color", "#04162b");
|
| 126 |
+
bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#010915");
|
| 127 |
+
|
| 128 |
+
const panelGradient = defs.append("linearGradient")
|
| 129 |
+
.attr("id", `${uid}-panel`)
|
| 130 |
+
.attr("x1", "0%").attr("x2", "100%").attr("y1", "0%").attr("y2", "100%");
|
| 131 |
+
panelGradient.append("stop").attr("offset", "0%").attr("stop-color", "#06172c");
|
| 132 |
+
panelGradient.append("stop").attr("offset", "100%").attr("stop-color", "#082a4c");
|
| 133 |
+
|
| 134 |
+
const glow = defs.append("filter")
|
| 135 |
+
.attr("id", `${uid}-glow`)
|
| 136 |
+
.attr("x", "-30%").attr("y", "-30%")
|
| 137 |
+
.attr("width", "160%").attr("height", "160%");
|
| 138 |
+
glow.append("feGaussianBlur").attr("stdDeviation", 3.2).attr("result", "blur");
|
| 139 |
+
const merge = glow.append("feMerge");
|
| 140 |
+
merge.append("feMergeNode").attr("in", "blur");
|
| 141 |
+
merge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 142 |
+
|
| 143 |
+
const cardShadow = defs.append("filter")
|
| 144 |
+
.attr("id", `${uid}-cardShadow`)
|
| 145 |
+
.attr("x", "-20%").attr("y", "-20%")
|
| 146 |
+
.attr("width", "140%").attr("height", "150%");
|
| 147 |
+
cardShadow.append("feDropShadow")
|
| 148 |
+
.attr("dx", 0)
|
| 149 |
+
.attr("dy", 7)
|
| 150 |
+
.attr("stdDeviation", 8)
|
| 151 |
+
.attr("flood-color", "#000814")
|
| 152 |
+
.attr("flood-opacity", 0.45);
|
| 153 |
+
|
| 154 |
+
const starData = [
|
| 155 |
+
[22, 58, 1.4], [82, 38, 1.1], [505, 52, 1.3], [558, 88, 1.0],
|
| 156 |
+
[70, 292, 0.8], [518, 310, 1.0], [138, 392, 0.8], [500, 557, 1.2],
|
| 157 |
+
[430, 102, 1.2], [40, 175, 0.9], [548, 210, 0.8]
|
| 158 |
+
];
|
| 159 |
+
svg.selectAll(".decor-star")
|
| 160 |
+
.data(starData)
|
| 161 |
+
.join("circle")
|
| 162 |
+
.attr("class", "decor-star")
|
| 163 |
+
.attr("cx", d => d[0])
|
| 164 |
+
.attr("cy", d => d[1])
|
| 165 |
+
.attr("r", d => d[2])
|
| 166 |
+
.attr("fill", d => d[0] % 2 ? "#99d8ff" : "#b9ff62")
|
| 167 |
+
.attr("opacity", 0.75);
|
| 168 |
+
|
| 169 |
+
const splitTitle = value => {
|
| 170 |
+
const words = value.split(/\s+/).filter(Boolean);
|
| 171 |
+
if (words.length <= 4) return { top: value, highlight: "" };
|
| 172 |
+
const leadIndex = words.findIndex(w => /^lead/i.test(w));
|
| 173 |
+
if (leadIndex > 1 && leadIndex < words.length - 1) {
|
| 174 |
+
return {
|
| 175 |
+
top: words.slice(0, leadIndex).join(" "),
|
| 176 |
+
highlight: words.slice(leadIndex).join(" ")
|
| 177 |
+
};
|
| 178 |
+
}
|
| 179 |
+
const split = Math.ceil(words.length / 2);
|
| 180 |
+
return {
|
| 181 |
+
top: words.slice(0, split).join(" "),
|
| 182 |
+
highlight: words.slice(split).join(" ")
|
| 183 |
+
};
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
+
const fitSize = (value, maxChars, large, small) => {
|
| 187 |
+
const len = value.length;
|
| 188 |
+
if (len <= maxChars) return large;
|
| 189 |
+
return Math.max(small, large - (len - maxChars) * 0.8);
|
| 190 |
+
};
|
| 191 |
+
|
| 192 |
+
const groupCardY = 25;
|
| 193 |
+
const groupCardH = 76;
|
| 194 |
+
const visibleGroups = groupStats
|
| 195 |
+
.slice()
|
| 196 |
+
.sort((a, b) => b.latestValue - a.latestValue)
|
| 197 |
+
.slice(0, Math.min(groups.length, 3));
|
| 198 |
+
const cardGap = 10;
|
| 199 |
+
const cardW = visibleGroups.length === 2 ? 232 : (width - 72 - cardGap * (visibleGroups.length - 1)) / visibleGroups.length;
|
| 200 |
+
const groupCardsX = visibleGroups.length === 2 ? (width - cardW * 2 - cardGap) / 2 : 36;
|
| 201 |
+
|
| 202 |
+
const basePalette = ["#a9f33e", "#43a7ff", "#f1d46b", "#f1844a", "#a782ff", "#57dab7"];
|
| 203 |
+
const colorForGroup = (group, index) => {
|
| 204 |
+
const lowered = group.toLowerCase();
|
| 205 |
+
if (lowered.includes("space") || lowered.includes("potassium")) return "#43a7ff";
|
| 206 |
+
if (lowered.includes("ground") || lowered.includes("nitrogen")) return "#a9f33e";
|
| 207 |
+
const resolved = colorResolver.field(group, index, { fallbackKey: "primary" }).value;
|
| 208 |
+
return resolved || basePalette[index % basePalette.length];
|
| 209 |
+
};
|
| 210 |
+
const groupColors = new Map(groups.map((group, i) => [group, colorForGroup(group, i)]));
|
| 211 |
+
|
| 212 |
+
const wrapText = (selection, text, maxChars, maxLines) => {
|
| 213 |
+
const words = safeText(text).split(/\s+/).filter(Boolean);
|
| 214 |
+
const lines = [];
|
| 215 |
+
let line = "";
|
| 216 |
+
words.forEach(word => {
|
| 217 |
+
const candidate = line ? `${line} ${word}` : word;
|
| 218 |
+
if (candidate.length > maxChars && line) {
|
| 219 |
+
lines.push(line);
|
| 220 |
+
line = word;
|
| 221 |
+
} else {
|
| 222 |
+
line = candidate;
|
| 223 |
+
}
|
| 224 |
+
});
|
| 225 |
+
if (line) lines.push(line);
|
| 226 |
+
lines.slice(0, maxLines).forEach((value, i) => {
|
| 227 |
+
selection.append("tspan")
|
| 228 |
+
.attr("x", selection.attr("data-x"))
|
| 229 |
+
.attr("dy", i === 0 ? 0 : 15)
|
| 230 |
+
.text(i === maxLines - 1 && lines.length > maxLines ? `${value.slice(0, Math.max(0, maxChars - 1))}...` : value);
|
| 231 |
+
});
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
const groupCard = svg.selectAll(".top-group-card")
|
| 235 |
+
.data(visibleGroups)
|
| 236 |
+
.join("g")
|
| 237 |
+
.attr("class", "top-group-card")
|
| 238 |
+
.attr("transform", (d, i) => `translate(${groupCardsX + i * (cardW + cardGap)}, ${groupCardY})`);
|
| 239 |
+
|
| 240 |
+
groupCard.append("rect")
|
| 241 |
+
.attr("width", cardW)
|
| 242 |
+
.attr("height", groupCardH)
|
| 243 |
+
.attr("rx", 18)
|
| 244 |
+
.attr("fill", "#061a32")
|
| 245 |
+
.attr("stroke", d => d3.color(groupColors.get(d.group)).copy({ opacity: 0.42 }))
|
| 246 |
+
.attr("stroke-width", 1.2)
|
| 247 |
+
.attr("filter", `url(#${uid}-cardShadow)`);
|
| 248 |
+
|
| 249 |
+
groupCard.each(function(d, i) {
|
| 250 |
+
const g = d3.select(this);
|
| 251 |
+
const color = groupColors.get(d.group);
|
| 252 |
+
const clipId = `${uid}-topSlot-${i}`;
|
| 253 |
+
defs.append("clipPath")
|
| 254 |
+
.attr("id", clipId)
|
| 255 |
+
.append("circle")
|
| 256 |
+
.attr("cx", 38)
|
| 257 |
+
.attr("cy", 38)
|
| 258 |
+
.attr("r", 25);
|
| 259 |
+
|
| 260 |
+
g.append("circle")
|
| 261 |
+
.attr("cx", 38)
|
| 262 |
+
.attr("cy", 38)
|
| 263 |
+
.attr("r", 27)
|
| 264 |
+
.attr("fill", d3.color(color).copy({ opacity: 0.22 }))
|
| 265 |
+
.attr("stroke", color)
|
| 266 |
+
.attr("stroke-width", 2)
|
| 267 |
+
.attr("data-asset-slot", "top_group_image_slot")
|
| 268 |
+
.attr("data-bbox", `${groupCardsX + i * (cardW + cardGap) + 11},${groupCardY + 11},54,54`)
|
| 269 |
+
.attr("data-anchor", `top-card:${d.group}`)
|
| 270 |
+
.attr("data-collision-rule", "inside card icon circle; avoid card text, subtitle, neighboring cards, and chart panel")
|
| 271 |
+
.attr("data-asset-source-policy", "neutral-placeholder-or-downstream-image2")
|
| 272 |
+
.attr("data-no-embedded-raster", "true")
|
| 273 |
+
.attr("data-no-copied-reference-art", "true");
|
| 274 |
+
|
| 275 |
+
g.append("circle")
|
| 276 |
+
.attr("cx", 38)
|
| 277 |
+
.attr("cy", 38)
|
| 278 |
+
.attr("r", 19)
|
| 279 |
+
.attr("fill", "none")
|
| 280 |
+
.attr("stroke", color)
|
| 281 |
+
.attr("stroke-width", 1.4)
|
| 282 |
+
.attr("stroke-dasharray", "4 4")
|
| 283 |
+
.attr("opacity", 0.58)
|
| 284 |
+
.attr("data-asset-slot", "top_group_image_slot")
|
| 285 |
+
.attr("data-anchor", `top-card:${d.group}`)
|
| 286 |
+
.attr("data-no-embedded-raster", "true")
|
| 287 |
+
.attr("data-no-copied-reference-art", "true");
|
| 288 |
+
|
| 289 |
+
g.append("text")
|
| 290 |
+
.attr("x", 38)
|
| 291 |
+
.attr("y", 43)
|
| 292 |
+
.attr("text-anchor", "middle")
|
| 293 |
+
.attr("fill", color)
|
| 294 |
+
.attr("font-size", fontSize(18))
|
| 295 |
+
.attr("font-weight", 800)
|
| 296 |
+
.attr("data-asset-slot", "top_group_image_slot")
|
| 297 |
+
.attr("data-no-embedded-raster", "true")
|
| 298 |
+
.attr("data-no-copied-reference-art", "true")
|
| 299 |
+
.text(d.group.slice(0, 1).toUpperCase());
|
| 300 |
+
|
| 301 |
+
const textX = visibleGroups.length === 3 ? 72 : 80;
|
| 302 |
+
g.append("text")
|
| 303 |
+
.attr("x", textX)
|
| 304 |
+
.attr("y", 29)
|
| 305 |
+
.attr("fill", color)
|
| 306 |
+
.attr("font-size", fontSize(visibleGroups.length === 3 ? 12 : 14))
|
| 307 |
+
.attr("font-weight", 800)
|
| 308 |
+
.text(d.group);
|
| 309 |
+
|
| 310 |
+
const share = latestTotal ? Math.round(d.latestValue / latestTotal * 100) : 0;
|
| 311 |
+
const detail = `${chartUtils.format.autoText(d.latestValue).text || chartUtils.format.autoText(d.latestValue)} ${yField} by ${latestYear} (${share}% share)`;
|
| 312 |
+
const detailText = g.append("text")
|
| 313 |
+
.attr("data-x", textX)
|
| 314 |
+
.attr("x", textX)
|
| 315 |
+
.attr("y", 49)
|
| 316 |
+
.attr("fill", "#f3f8ff")
|
| 317 |
+
.attr("font-size", fontSize(visibleGroups.length === 3 ? 10 : 12))
|
| 318 |
+
.attr("font-weight", 500);
|
| 319 |
+
wrapText(detailText, detail, visibleGroups.length === 3 ? 18 : 26, 2);
|
| 320 |
+
});
|
| 321 |
+
|
| 322 |
+
const panel = { x: 32, y: 105, w: width - 64, h: height - 195 };
|
| 323 |
+
const plot = {
|
| 324 |
+
x: panel.x + 66,
|
| 325 |
+
y: panel.y + 28,
|
| 326 |
+
w: panel.w - 106,
|
| 327 |
+
h: panel.h - 82
|
| 328 |
+
};
|
| 329 |
+
|
| 330 |
+
const stack = d3.stack()
|
| 331 |
+
.keys(groups)
|
| 332 |
+
.order(d3.stackOrderNone)
|
| 333 |
+
.offset(d3.stackOffsetNone);
|
| 334 |
+
const stackedData = stack(stackData);
|
| 335 |
+
|
| 336 |
+
const maxTotal = d3.max(stackData, d => d3.sum(groups, group => d[group] || 0)) || 1;
|
| 337 |
+
const roundBase = maxTotal > 250 ? 50 : maxTotal > 100 ? 25 : maxTotal > 30 ? 10 : 5;
|
| 338 |
+
const yMax = Math.max(roundBase, Math.ceil(maxTotal * 1.1 / roundBase) * roundBase);
|
| 339 |
+
const xScale = d3.scaleTime()
|
| 340 |
+
.domain(d3.extent(distinctDates))
|
| 341 |
+
.range([0, plot.w]);
|
| 342 |
+
const yScale = d3.scaleLinear()
|
| 343 |
+
.domain([0, yMax])
|
| 344 |
+
.range([plot.h, 0]);
|
| 345 |
+
|
| 346 |
+
const chartG = svg.append("g")
|
| 347 |
+
.attr("transform", `translate(${plot.x}, ${plot.y})`);
|
| 348 |
+
|
| 349 |
+
const yTicks = yScale.ticks(6);
|
| 350 |
+
yTicks.forEach(tick => {
|
| 351 |
+
chartG.append("line")
|
| 352 |
+
.attr("x1", 0)
|
| 353 |
+
.attr("x2", plot.w)
|
| 354 |
+
.attr("y1", yScale(tick))
|
| 355 |
+
.attr("y2", yScale(tick))
|
| 356 |
+
.attr("stroke", "#8fb7df")
|
| 357 |
+
.attr("stroke-width", 0.7)
|
| 358 |
+
.attr("stroke-dasharray", "4 5")
|
| 359 |
+
.attr("opacity", 0.26);
|
| 360 |
+
|
| 361 |
+
chartG.append("text")
|
| 362 |
+
.attr("x", -14)
|
| 363 |
+
.attr("y", yScale(tick) + 4)
|
| 364 |
+
.attr("text-anchor", "end")
|
| 365 |
+
.attr("fill", "#edf5ff")
|
| 366 |
+
.attr("font-size", fontSize(12))
|
| 367 |
+
.attr("font-weight", 600)
|
| 368 |
+
.text(chartUtils.format.autoText(tick).text || chartUtils.format.autoText(tick));
|
| 369 |
+
});
|
| 370 |
+
|
| 371 |
+
chartG.append("line")
|
| 372 |
+
.attr("x1", 0)
|
| 373 |
+
.attr("x2", plot.w)
|
| 374 |
+
.attr("y1", plot.h)
|
| 375 |
+
.attr("y2", plot.h)
|
| 376 |
+
.attr("stroke", "#d9efff")
|
| 377 |
+
.attr("stroke-width", 1.2);
|
| 378 |
+
|
| 379 |
+
chartG.append("line")
|
| 380 |
+
.attr("x1", 0)
|
| 381 |
+
.attr("x2", 0)
|
| 382 |
+
.attr("y1", 0)
|
| 383 |
+
.attr("y2", plot.h)
|
| 384 |
+
.attr("stroke", "#d9efff")
|
| 385 |
+
.attr("stroke-width", 1.2);
|
| 386 |
+
|
| 387 |
+
const xTickDates = distinctDates.length <= 7
|
| 388 |
+
? distinctDates
|
| 389 |
+
: distinctDates.filter((d, i) => i === 0 || i === distinctDates.length - 1 || i % Math.ceil(distinctDates.length / 5) === 0);
|
| 390 |
+
xTickDates.forEach(date => {
|
| 391 |
+
chartG.append("text")
|
| 392 |
+
.attr("x", xScale(date))
|
| 393 |
+
.attr("y", plot.h + 28)
|
| 394 |
+
.attr("text-anchor", "middle")
|
| 395 |
+
.attr("fill", "#edf5ff")
|
| 396 |
+
.attr("font-size", fontSize(12))
|
| 397 |
+
.attr("font-weight", 600)
|
| 398 |
+
.text(formatYear(date));
|
| 399 |
+
});
|
| 400 |
+
|
| 401 |
+
svg.append("text")
|
| 402 |
+
.attr("transform", `translate(${panel.x + 22}, ${plot.y + plot.h / 2}) rotate(-90)`)
|
| 403 |
+
.attr("text-anchor", "middle")
|
| 404 |
+
.attr("fill", "#edf5ff")
|
| 405 |
+
.attr("font-size", fontSize(12))
|
| 406 |
+
.attr("font-weight", 800)
|
| 407 |
+
.attr("letter-spacing", 1.2)
|
| 408 |
+
.text(yField.toUpperCase());
|
| 409 |
+
|
| 410 |
+
groups.forEach((group, i) => {
|
| 411 |
+
const color = groupColors.get(group);
|
| 412 |
+
const grad = defs.append("linearGradient")
|
| 413 |
+
.attr("id", `${uid}-area-${i}`)
|
| 414 |
+
.attr("x1", "0%")
|
| 415 |
+
.attr("x2", "0%")
|
| 416 |
+
.attr("y1", "0%")
|
| 417 |
+
.attr("y2", "100%");
|
| 418 |
+
grad.append("stop")
|
| 419 |
+
.attr("offset", "0%")
|
| 420 |
+
.attr("stop-color", d3.color(color).brighter(0.8))
|
| 421 |
+
.attr("stop-opacity", 0.96);
|
| 422 |
+
grad.append("stop")
|
| 423 |
+
.attr("offset", "100%")
|
| 424 |
+
.attr("stop-color", d3.color(color).darker(0.8))
|
| 425 |
+
.attr("stop-opacity", 0.74);
|
| 426 |
+
});
|
| 427 |
+
|
| 428 |
+
const area = d3.area()
|
| 429 |
+
.x(d => xScale(d.data.date))
|
| 430 |
+
.y0(d => yScale(d[0]))
|
| 431 |
+
.y1(d => yScale(d[1]))
|
| 432 |
+
.curve(d3.curveBasis);
|
| 433 |
+
|
| 434 |
+
const topLine = d3.line()
|
| 435 |
+
.x(d => xScale(d.data.date))
|
| 436 |
+
.y(d => yScale(d[1]))
|
| 437 |
+
.curve(d3.curveBasis);
|
| 438 |
+
|
| 439 |
+
const areaLayer = chartG.append("g").attr("class", "stacked-area-layers");
|
| 440 |
+
areaLayer.selectAll("path.area-fill")
|
| 441 |
+
.data(stackedData)
|
| 442 |
+
.join("path")
|
| 443 |
+
.attr("class", "area-fill")
|
| 444 |
+
.attr("d", area)
|
| 445 |
+
.attr("fill", (d, i) => `url(#${uid}-area-${i})`)
|
| 446 |
+
.attr("stroke", "none")
|
| 447 |
+
.attr("opacity", 0.96)
|
| 448 |
+
.attr("data-series", d => d.key);
|
| 449 |
+
|
| 450 |
+
areaLayer.selectAll("path.area-glow")
|
| 451 |
+
.data(stackedData)
|
| 452 |
+
.join("path")
|
| 453 |
+
.attr("class", "area-glow")
|
| 454 |
+
.attr("d", topLine)
|
| 455 |
+
.attr("fill", "none")
|
| 456 |
+
.attr("stroke", d => groupColors.get(d.key))
|
| 457 |
+
.attr("stroke-width", 5.5)
|
| 458 |
+
.attr("stroke-opacity", 0.22)
|
| 459 |
+
.attr("filter", `url(#${uid}-glow)`);
|
| 460 |
+
|
| 461 |
+
areaLayer.selectAll("path.area-outline")
|
| 462 |
+
.data(stackedData)
|
| 463 |
+
.join("path")
|
| 464 |
+
.attr("class", "area-outline")
|
| 465 |
+
.attr("d", topLine)
|
| 466 |
+
.attr("fill", "none")
|
| 467 |
+
.attr("stroke", d => d3.color(groupColors.get(d.key)).brighter(0.8))
|
| 468 |
+
.attr("stroke-width", 1.8)
|
| 469 |
+
.attr("stroke-linecap", "round")
|
| 470 |
+
.attr("stroke-linejoin", "round");
|
| 471 |
+
|
| 472 |
+
const usedSlots = [];
|
| 473 |
+
stackedData.forEach((series, i) => {
|
| 474 |
+
const middleIndex = Math.floor(series.length * 0.68);
|
| 475 |
+
const point = series[middleIndex];
|
| 476 |
+
if (!point) return;
|
| 477 |
+
const segmentHeight = Math.abs(yScale(point[0]) - yScale(point[1]));
|
| 478 |
+
if (segmentHeight < 52 || i > 1) return;
|
| 479 |
+
const slotSize = Math.min(54, segmentHeight * 0.58);
|
| 480 |
+
const slotX = Math.min(plot.w - slotSize - 10, Math.max(10, xScale(point.data.date) - slotSize / 2));
|
| 481 |
+
const slotY = yScale((point[0] + point[1]) / 2) - slotSize / 2;
|
| 482 |
+
const overlapsUsed = usedSlots.some(slot => Math.abs(slot.x - slotX) < slotSize && Math.abs(slot.y - slotY) < slotSize);
|
| 483 |
+
if (overlapsUsed) return;
|
| 484 |
+
usedSlots.push({ x: slotX, y: slotY });
|
| 485 |
+
const slot = chartG.append("g")
|
| 486 |
+
.attr("class", "reserved-asset-slot plot-area-neutral-image-slot")
|
| 487 |
+
.attr("transform", `translate(${slotX},${slotY})`)
|
| 488 |
+
.attr("opacity", 0.18)
|
| 489 |
+
.attr("data-asset-slot", "plot_area_decorative_image_slot")
|
| 490 |
+
.attr("data-series", series.key)
|
| 491 |
+
.attr("data-bbox", `${plot.x + slotX},${plot.y + slotY},${slotSize},${slotSize}`)
|
| 492 |
+
.attr("data-anchor", `series-interior:${series.key}`)
|
| 493 |
+
.attr("data-collision-rule", "omit or keep translucent only when inside a large segment and away from endpoint cards, labels, and axes")
|
| 494 |
+
.attr("data-asset-source-policy", "neutral-placeholder-or-downstream-image2")
|
| 495 |
+
.attr("data-no-embedded-raster", "true")
|
| 496 |
+
.attr("data-no-copied-reference-art", "true");
|
| 497 |
+
slot.append("rect")
|
| 498 |
+
.attr("x", 0)
|
| 499 |
+
.attr("y", 0)
|
| 500 |
+
.attr("width", slotSize)
|
| 501 |
+
.attr("height", slotSize)
|
| 502 |
+
.attr("rx", Math.max(6, slotSize * 0.16))
|
| 503 |
+
.attr("fill", "none")
|
| 504 |
+
.attr("stroke", groupColors.get(series.key))
|
| 505 |
+
.attr("stroke-width", 1.4)
|
| 506 |
+
.attr("stroke-dasharray", "5 5");
|
| 507 |
+
slot.append("line")
|
| 508 |
+
.attr("x1", slotSize * 0.25)
|
| 509 |
+
.attr("y1", slotSize * 0.50)
|
| 510 |
+
.attr("x2", slotSize * 0.75)
|
| 511 |
+
.attr("y2", slotSize * 0.50)
|
| 512 |
+
.attr("stroke", groupColors.get(series.key))
|
| 513 |
+
.attr("stroke-width", 1.2);
|
| 514 |
+
});
|
| 515 |
+
|
| 516 |
+
const endpointData = stackedData.map((series, index) => {
|
| 517 |
+
const anchorIndex = Math.max(0, series.length - 2);
|
| 518 |
+
const anchor = series[anchorIndex];
|
| 519 |
+
const stat = groupStats.find(d => d.group === series.key);
|
| 520 |
+
const share = latestTotal && stat ? Math.round(stat.latestValue / latestTotal * 100) : 0;
|
| 521 |
+
return {
|
| 522 |
+
group: series.key,
|
| 523 |
+
value: stat ? stat.latestValue : 0,
|
| 524 |
+
share,
|
| 525 |
+
x: xScale(anchor.data.date),
|
| 526 |
+
y: yScale((anchor[0] + anchor[1]) / 2),
|
| 527 |
+
color: groupColors.get(series.key),
|
| 528 |
+
layerIndex: index
|
| 529 |
+
};
|
| 530 |
+
}).sort((a, b) => a.y - b.y);
|
| 531 |
+
|
| 532 |
+
const endpointCardW = Math.min(130, plot.w * 0.34);
|
| 533 |
+
const endpointCardH = 62;
|
| 534 |
+
const endpointMinY = plot.y + 8;
|
| 535 |
+
const endpointMaxY = plot.y + plot.h - endpointCardH - 8;
|
| 536 |
+
const endpointGap = 8;
|
| 537 |
+
endpointData.forEach(d => {
|
| 538 |
+
d.cardX = plot.x + plot.w * 0.58;
|
| 539 |
+
if (d.layerIndex === 0) d.cardX = plot.x + plot.w * 0.60;
|
| 540 |
+
d.cardX = Math.min(plot.x + plot.w - endpointCardW - 54, Math.max(plot.x + plot.w * 0.48, d.cardX));
|
| 541 |
+
d.preferredCardY = Math.max(endpointMinY, Math.min(endpointMaxY, plot.y + d.y - endpointCardH / 2));
|
| 542 |
+
});
|
| 543 |
+
|
| 544 |
+
let nextCardY = endpointMinY;
|
| 545 |
+
endpointData.forEach(d => {
|
| 546 |
+
d.cardY = Math.max(nextCardY, d.preferredCardY);
|
| 547 |
+
nextCardY = d.cardY + endpointCardH + endpointGap;
|
| 548 |
+
});
|
| 549 |
+
const bottomOverflow = nextCardY - endpointGap - (endpointMaxY + endpointCardH);
|
| 550 |
+
if (bottomOverflow > 0) {
|
| 551 |
+
endpointData.forEach(d => {
|
| 552 |
+
d.cardY -= bottomOverflow;
|
| 553 |
+
});
|
| 554 |
+
}
|
| 555 |
+
const topUnderflow = endpointMinY - (endpointData[0] ? endpointData[0].cardY : endpointMinY);
|
| 556 |
+
if (topUnderflow > 0) {
|
| 557 |
+
endpointData.forEach(d => {
|
| 558 |
+
d.cardY += topUnderflow;
|
| 559 |
+
});
|
| 560 |
+
}
|
| 561 |
+
for (let i = endpointData.length - 2; i >= 0; i -= 1) {
|
| 562 |
+
const next = endpointData[i + 1];
|
| 563 |
+
const current = endpointData[i];
|
| 564 |
+
if (current.cardY + endpointCardH + endpointGap > next.cardY) {
|
| 565 |
+
current.cardY = Math.max(endpointMinY, next.cardY - endpointCardH - endpointGap);
|
| 566 |
+
}
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
const endpointLayer = svg.append("g").attr("class", "endpoint-annotations");
|
| 570 |
+
endpointData.forEach(d => {
|
| 571 |
+
const dotX = Math.min(plot.x + plot.w - 18, Math.max(d.cardX + endpointCardW + 18, plot.x + d.x + 30));
|
| 572 |
+
const dotY = plot.y + d.y;
|
| 573 |
+
const leaderStartX = d.cardX + endpointCardW;
|
| 574 |
+
const leaderY = Math.max(d.cardY + 18, Math.min(d.cardY + endpointCardH - 18, dotY));
|
| 575 |
+
|
| 576 |
+
endpointLayer.append("circle")
|
| 577 |
+
.attr("cx", dotX)
|
| 578 |
+
.attr("cy", dotY)
|
| 579 |
+
.attr("r", 4.8)
|
| 580 |
+
.attr("fill", d.color)
|
| 581 |
+
.attr("stroke", "#e9fbff")
|
| 582 |
+
.attr("stroke-width", 1.4)
|
| 583 |
+
.attr("filter", `url(#${uid}-glow)`);
|
| 584 |
+
|
| 585 |
+
endpointLayer.append("path")
|
| 586 |
+
.attr("d", `M${leaderStartX},${leaderY} L${dotX - 16},${leaderY} Q${dotX - 10},${leaderY} ${dotX - 10},${dotY} L${dotX - 5},${dotY}`)
|
| 587 |
+
.attr("fill", "none")
|
| 588 |
+
.attr("stroke", d.color)
|
| 589 |
+
.attr("stroke-width", 1.2)
|
| 590 |
+
.attr("stroke-linecap", "round")
|
| 591 |
+
.attr("opacity", 0.88);
|
| 592 |
+
|
| 593 |
+
endpointLayer.append("rect")
|
| 594 |
+
.attr("x", d.cardX)
|
| 595 |
+
.attr("y", d.cardY)
|
| 596 |
+
.attr("width", endpointCardW)
|
| 597 |
+
.attr("height", endpointCardH)
|
| 598 |
+
.attr("rx", 10)
|
| 599 |
+
.attr("fill", "#061d36")
|
| 600 |
+
.attr("stroke", d.color)
|
| 601 |
+
.attr("stroke-width", 1.1)
|
| 602 |
+
.attr("stroke-opacity", 0.8)
|
| 603 |
+
.attr("filter", `url(#${uid}-cardShadow)`);
|
| 604 |
+
|
| 605 |
+
endpointLayer.append("text")
|
| 606 |
+
.attr("x", d.cardX + 10)
|
| 607 |
+
.attr("y", d.cardY + 19)
|
| 608 |
+
.attr("fill", d.color)
|
| 609 |
+
.attr("font-size", fontSize(11))
|
| 610 |
+
.attr("font-weight", 800)
|
| 611 |
+
.text(d.group.length > 12 ? `${d.group.slice(0, 11)}...` : d.group);
|
| 612 |
+
|
| 613 |
+
endpointLayer.append("text")
|
| 614 |
+
.attr("x", d.cardX + 10)
|
| 615 |
+
.attr("y", d.cardY + 42)
|
| 616 |
+
.attr("fill", "#eaf8ff")
|
| 617 |
+
.attr("font-size", fontSize(12))
|
| 618 |
+
.attr("font-weight", 700)
|
| 619 |
+
.text(`${chartUtils.format.autoText(d.value).text || chartUtils.format.autoText(d.value)} by ${latestYear}`);
|
| 620 |
+
|
| 621 |
+
endpointLayer.append("text")
|
| 622 |
+
.attr("x", d.cardX + 11)
|
| 623 |
+
.attr("y", d.cardY + 56)
|
| 624 |
+
.attr("fill", "#dbe8f4")
|
| 625 |
+
.attr("font-size", fontSize(10))
|
| 626 |
+
.attr("font-weight", 500)
|
| 627 |
+
.text(`~${d.share}% of total`);
|
| 628 |
+
});
|
| 629 |
+
|
| 630 |
+
return svg.node();
|
| 631 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/stacked_area_chart_01.js
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Stacked Area Chart",
|
| 5 |
+
"chart_name": "stacked_area_chart_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 50], [0, "inf"], [2, 10]],
|
| 9 |
+
"required_fields_icons": ["group"],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": ["opacity"],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "overlay",
|
| 18 |
+
"icon_label": "side",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
const jsonData = data || {};
|
| 27 |
+
const chartData = jsonData.data?.data || [];
|
| 28 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 29 |
+
const variables = jsonData.variables || {};
|
| 30 |
+
const typography = jsonData.typography || {};
|
| 31 |
+
const sourceColors = jsonData.colors || {};
|
| 32 |
+
const colorResolver = chartUtils.color.resolver(jsonData, { colors: sourceColors });
|
| 33 |
+
const fontSize = chartUtils.text.fontSize;
|
| 34 |
+
|
| 35 |
+
d3.select(containerSelector).html("");
|
| 36 |
+
|
| 37 |
+
const xColumn = dataColumns[0] || {};
|
| 38 |
+
const yColumn = dataColumns[1] || {};
|
| 39 |
+
const groupColumn = dataColumns[2] || {};
|
| 40 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 41 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 42 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 43 |
+
|
| 44 |
+
const width = Math.max(600, Number(variables.width) || 600);
|
| 45 |
+
const height = Math.max(600, Number(variables.height) || 600);
|
| 46 |
+
const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, Helvetica, sans-serif";
|
| 47 |
+
const textColor = sourceColors.text_color || "#0B2B4D";
|
| 48 |
+
const mutedText = "#657B8F";
|
| 49 |
+
const axisColor = "#2E506E";
|
| 50 |
+
const gridColor = "#B8D6E6";
|
| 51 |
+
const pageFill = "transparent";
|
| 52 |
+
const referenceAreaPalette = [
|
| 53 |
+
{ base: "#F7D94A", top: "#FFF2A5", bottom: "#F3CF25" },
|
| 54 |
+
{ base: "#22BBA4", top: "#30D0B7", bottom: "#169C91" },
|
| 55 |
+
{ base: "#1EB06E", top: "#29C881", bottom: "#098E59" },
|
| 56 |
+
{ base: "#82E2BF", top: "#A7F0D8", bottom: "#55CAA9" },
|
| 57 |
+
{ base: "#BDEB68", top: "#D7F58F", bottom: "#A8DF4C" },
|
| 58 |
+
{ base: "#6DD2E6", top: "#A8E8F3", bottom: "#3DB9D1" },
|
| 59 |
+
{ base: "#8ED36E", top: "#BBE99B", bottom: "#65B955" }
|
| 60 |
+
];
|
| 61 |
+
|
| 62 |
+
const svg = d3.select(containerSelector)
|
| 63 |
+
.append("svg")
|
| 64 |
+
.attr("width", "100%")
|
| 65 |
+
.attr("height", height)
|
| 66 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 67 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 68 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 69 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 70 |
+
.attr("class", "stacked-area-chart-01-reference-layout")
|
| 71 |
+
.attr("data-reference-layout", "selected-reference-sample00-v03")
|
| 72 |
+
.attr("data-temporal-order", "chronological");
|
| 73 |
+
|
| 74 |
+
const showMessage = message => {
|
| 75 |
+
svg.append("rect")
|
| 76 |
+
.attr("width", width)
|
| 77 |
+
.attr("height", height)
|
| 78 |
+
.attr("fill", pageFill);
|
| 79 |
+
svg.append("text")
|
| 80 |
+
.attr("x", width / 2)
|
| 81 |
+
.attr("y", height / 2)
|
| 82 |
+
.attr("text-anchor", "middle")
|
| 83 |
+
.attr("fill", textColor)
|
| 84 |
+
.style("font-family", fontFamily)
|
| 85 |
+
.style("font-size", `${fontSize(16)}px`)
|
| 86 |
+
.text(message);
|
| 87 |
+
return svg.node();
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
if (!xField || !yField || !groupField) {
|
| 91 |
+
return showMessage("Missing stacked area fields");
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
const parseTimeValue = value => {
|
| 95 |
+
const raw = String(value ?? "").trim();
|
| 96 |
+
if (!raw) return null;
|
| 97 |
+
if (/^\d{4}$/.test(raw)) return new Date(Number(raw), 0, 1);
|
| 98 |
+
const helperParsed = chartUtils.format.parseDate(value);
|
| 99 |
+
if (helperParsed instanceof Date && Number.isFinite(helperParsed.getTime())) return helperParsed;
|
| 100 |
+
const parsed = Date.parse(raw);
|
| 101 |
+
if (Number.isFinite(parsed)) return new Date(parsed);
|
| 102 |
+
const numeric = Number(raw);
|
| 103 |
+
if (Number.isFinite(numeric)) return new Date(numeric, 0, 1);
|
| 104 |
+
return null;
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
const formatYear = date => {
|
| 108 |
+
if (!(date instanceof Date) || !Number.isFinite(date.getTime())) return "";
|
| 109 |
+
return String(date.getFullYear());
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const cleanLabel = value => String(value ?? "")
|
| 113 |
+
.replace(/_/g, " ")
|
| 114 |
+
.replace(/\s+/g, " ")
|
| 115 |
+
.trim();
|
| 116 |
+
|
| 117 |
+
const truncate = (value, maxChars = 24) => {
|
| 118 |
+
const clean = cleanLabel(value);
|
| 119 |
+
if (clean.length <= maxChars) return clean;
|
| 120 |
+
return `${clean.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
const formatValue = value => {
|
| 124 |
+
const num = Number(value) || 0;
|
| 125 |
+
const abs = Math.abs(num);
|
| 126 |
+
const spec = abs >= 1000 ? ",.0f" : abs >= 100 ? ",.0f" : abs >= 10 ? ",.1f" : ",.2f";
|
| 127 |
+
return d3.format(spec)(num)
|
| 128 |
+
.replace(/\.0$/, "")
|
| 129 |
+
.replace(/(\.\d*?)0+$/, "$1")
|
| 130 |
+
.replace(/\.$/, "");
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const rows = chartData
|
| 134 |
+
.map((row, sourceIndex) => {
|
| 135 |
+
const date = parseTimeValue(row[xField]);
|
| 136 |
+
const value = Number(row[yField]);
|
| 137 |
+
return {
|
| 138 |
+
raw: row,
|
| 139 |
+
sourceIndex,
|
| 140 |
+
date,
|
| 141 |
+
xKey: date && Number.isFinite(date.getTime()) ? String(date.getTime()) : String(row[xField] ?? ""),
|
| 142 |
+
value: Number.isFinite(value) ? value : 0,
|
| 143 |
+
group: String(row[groupField] ?? "")
|
| 144 |
+
};
|
| 145 |
+
})
|
| 146 |
+
.filter(row => row.date && Number.isFinite(row.date.getTime()) && row.group);
|
| 147 |
+
|
| 148 |
+
if (rows.length < 2) {
|
| 149 |
+
return showMessage("Not enough data for stacked area");
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
const groups = [...new Set(rows.map(row => row.group))]
|
| 153 |
+
.sort((a, b) => {
|
| 154 |
+
const avgA = d3.mean(rows.filter(row => row.group === a), row => row.value) || 0;
|
| 155 |
+
const avgB = d3.mean(rows.filter(row => row.group === b), row => row.value) || 0;
|
| 156 |
+
return avgA - avgB || a.localeCompare(b);
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
const dates = [...new Map(rows
|
| 160 |
+
.sort((a, b) => a.date - b.date || a.sourceIndex - b.sourceIndex)
|
| 161 |
+
.map(row => [row.xKey, row.date])).values()];
|
| 162 |
+
|
| 163 |
+
const valuesByDate = d3.group(rows, row => row.xKey);
|
| 164 |
+
const stackData = dates.map(date => {
|
| 165 |
+
const key = String(date.getTime());
|
| 166 |
+
const values = valuesByDate.get(key) || [];
|
| 167 |
+
const obj = { date };
|
| 168 |
+
groups.forEach(group => {
|
| 169 |
+
const match = values.find(row => row.group === group);
|
| 170 |
+
obj[group] = match ? Math.max(0, Number(match.value) || 0) : 0;
|
| 171 |
+
});
|
| 172 |
+
return obj;
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
const stack = d3.stack()
|
| 176 |
+
.keys(groups)
|
| 177 |
+
.order(d3.stackOrderNone)
|
| 178 |
+
.offset(d3.stackOffsetNone);
|
| 179 |
+
const stackedData = stack(stackData);
|
| 180 |
+
|
| 181 |
+
const yMaxRaw = d3.max(stackedData, layer => d3.max(layer, point => point[1])) || 1;
|
| 182 |
+
const yMax = yMaxRaw * 1.08;
|
| 183 |
+
|
| 184 |
+
const margin = {
|
| 185 |
+
top: Math.max(12, height * 0.025),
|
| 186 |
+
right: Math.max(128, width * 0.22),
|
| 187 |
+
bottom: Math.max(58, height * 0.095),
|
| 188 |
+
left: Math.max(70, width * 0.118)
|
| 189 |
+
};
|
| 190 |
+
const chartWidth = Math.max(260, width - margin.left - margin.right);
|
| 191 |
+
const chartHeight = Math.max(330, height - margin.top - margin.bottom);
|
| 192 |
+
const plotX = margin.left;
|
| 193 |
+
const plotY = margin.top;
|
| 194 |
+
const xScale = d3.scaleTime()
|
| 195 |
+
.domain(d3.extent(dates))
|
| 196 |
+
.range([0, chartWidth]);
|
| 197 |
+
const yScale = d3.scaleLinear()
|
| 198 |
+
.domain([0, yMax])
|
| 199 |
+
.nice(5)
|
| 200 |
+
.range([chartHeight, 0]);
|
| 201 |
+
|
| 202 |
+
const defs = svg.append("defs");
|
| 203 |
+
const clipId = `stacked-area-01-plot-${Math.random().toString(36).slice(2, 9)}`;
|
| 204 |
+
defs.append("clipPath")
|
| 205 |
+
.attr("id", clipId)
|
| 206 |
+
.append("rect")
|
| 207 |
+
.attr("x", 0)
|
| 208 |
+
.attr("y", 0)
|
| 209 |
+
.attr("width", chartWidth)
|
| 210 |
+
.attr("height", chartHeight);
|
| 211 |
+
|
| 212 |
+
const g = svg.append("g")
|
| 213 |
+
.attr("class", "stacked-area-plot")
|
| 214 |
+
.attr("data-role", "chart-panel-plot-area")
|
| 215 |
+
.attr("data-reference-local-geometry", "endpoint labels attach to final-year band midpoints")
|
| 216 |
+
.attr("transform", `translate(${plotX}, ${plotY})`);
|
| 217 |
+
|
| 218 |
+
svg.append("rect")
|
| 219 |
+
.attr("x", 0)
|
| 220 |
+
.attr("y", 0)
|
| 221 |
+
.attr("width", width)
|
| 222 |
+
.attr("height", height)
|
| 223 |
+
.attr("fill", pageFill);
|
| 224 |
+
|
| 225 |
+
const yTicks = yScale.ticks(5);
|
| 226 |
+
g.append("g")
|
| 227 |
+
.attr("class", "grid-layer")
|
| 228 |
+
.selectAll("line")
|
| 229 |
+
.data(yTicks)
|
| 230 |
+
.enter()
|
| 231 |
+
.append("line")
|
| 232 |
+
.attr("x1", 0)
|
| 233 |
+
.attr("x2", chartWidth)
|
| 234 |
+
.attr("y1", d => yScale(d))
|
| 235 |
+
.attr("y2", d => yScale(d))
|
| 236 |
+
.attr("stroke", gridColor)
|
| 237 |
+
.attr("stroke-opacity", 0.58)
|
| 238 |
+
.attr("stroke-width", 0.9)
|
| 239 |
+
.attr("stroke-dasharray", "2 5");
|
| 240 |
+
|
| 241 |
+
const area = d3.area()
|
| 242 |
+
.x(d => xScale(d.data.date))
|
| 243 |
+
.y0(d => yScale(d[0]))
|
| 244 |
+
.y1(d => yScale(d[1]))
|
| 245 |
+
.curve(d3.curveLinear);
|
| 246 |
+
|
| 247 |
+
const layerGroup = g.append("g")
|
| 248 |
+
.attr("clip-path", `url(#${clipId})`);
|
| 249 |
+
|
| 250 |
+
const gradientIds = new Map();
|
| 251 |
+
stackedData.forEach((layer, index) => {
|
| 252 |
+
const palette = referenceAreaPalette[index % referenceAreaPalette.length];
|
| 253 |
+
const gradientId = `stacked-area-01-fill-${index}-${Math.random().toString(36).slice(2, 8)}`;
|
| 254 |
+
gradientIds.set(layer.key, gradientId);
|
| 255 |
+
const gradient = defs.append("linearGradient")
|
| 256 |
+
.attr("id", gradientId)
|
| 257 |
+
.attr("x1", "0%")
|
| 258 |
+
.attr("x2", "100%")
|
| 259 |
+
.attr("y1", "0%")
|
| 260 |
+
.attr("y2", "0%");
|
| 261 |
+
gradient.append("stop")
|
| 262 |
+
.attr("offset", "0%")
|
| 263 |
+
.attr("stop-color", palette.top);
|
| 264 |
+
gradient.append("stop")
|
| 265 |
+
.attr("offset", "58%")
|
| 266 |
+
.attr("stop-color", palette.base);
|
| 267 |
+
gradient.append("stop")
|
| 268 |
+
.attr("offset", "100%")
|
| 269 |
+
.attr("stop-color", palette.bottom);
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
+
layerGroup.selectAll("path.area-layer")
|
| 273 |
+
.data(stackedData)
|
| 274 |
+
.enter()
|
| 275 |
+
.append("path")
|
| 276 |
+
.attr("class", "area-layer")
|
| 277 |
+
.attr("data-series", d => d.key)
|
| 278 |
+
.attr("data-mark-type", "stacked-area-polygon")
|
| 279 |
+
.attr("data-rectangular-end-cap", "not-applicable-continuous-area")
|
| 280 |
+
.attr("fill", d => `url(#${gradientIds.get(d.key)})`)
|
| 281 |
+
.attr("fill-opacity", 0.9)
|
| 282 |
+
.attr("stroke", "#FFFFFF")
|
| 283 |
+
.attr("stroke-opacity", 0.56)
|
| 284 |
+
.attr("stroke-width", 0.8)
|
| 285 |
+
.attr("d", area);
|
| 286 |
+
|
| 287 |
+
g.append("line")
|
| 288 |
+
.attr("x1", 0)
|
| 289 |
+
.attr("x2", chartWidth)
|
| 290 |
+
.attr("y1", chartHeight)
|
| 291 |
+
.attr("y2", chartHeight)
|
| 292 |
+
.attr("stroke", "#8DB4CA")
|
| 293 |
+
.attr("stroke-width", 1);
|
| 294 |
+
|
| 295 |
+
g.selectAll("text.y-tick")
|
| 296 |
+
.data(yTicks)
|
| 297 |
+
.enter()
|
| 298 |
+
.append("text")
|
| 299 |
+
.attr("class", "y-tick")
|
| 300 |
+
.attr("x", -10)
|
| 301 |
+
.attr("y", d => yScale(d))
|
| 302 |
+
.attr("text-anchor", "end")
|
| 303 |
+
.attr("dominant-baseline", "middle")
|
| 304 |
+
.attr("fill", axisColor)
|
| 305 |
+
.style("font-family", fontFamily)
|
| 306 |
+
.style("font-size", `${fontSize(11)}px`)
|
| 307 |
+
.text(d => formatValue(d));
|
| 308 |
+
|
| 309 |
+
const xTicks = dates.length <= 6 ? dates : xScale.ticks(Math.min(6, dates.length));
|
| 310 |
+
g.selectAll("text.x-tick")
|
| 311 |
+
.data(xTicks)
|
| 312 |
+
.enter()
|
| 313 |
+
.append("text")
|
| 314 |
+
.attr("class", "x-tick")
|
| 315 |
+
.attr("x", d => xScale(d))
|
| 316 |
+
.attr("y", chartHeight + 24)
|
| 317 |
+
.attr("text-anchor", "middle")
|
| 318 |
+
.attr("fill", axisColor)
|
| 319 |
+
.style("font-family", fontFamily)
|
| 320 |
+
.style("font-size", `${fontSize(12)}px`)
|
| 321 |
+
.style("font-weight", 500)
|
| 322 |
+
.text(d => formatYear(d));
|
| 323 |
+
|
| 324 |
+
g.append("text")
|
| 325 |
+
.attr("class", "y-axis-title")
|
| 326 |
+
.attr("transform", "rotate(-90)")
|
| 327 |
+
.attr("y", -54)
|
| 328 |
+
.attr("x", -chartHeight / 2)
|
| 329 |
+
.attr("text-anchor", "middle")
|
| 330 |
+
.attr("fill", axisColor)
|
| 331 |
+
.style("font-family", fontFamily)
|
| 332 |
+
.style("font-size", `${fontSize(12)}px`)
|
| 333 |
+
.style("font-weight", 700)
|
| 334 |
+
.text(cleanLabel(yColumn.name || yField));
|
| 335 |
+
|
| 336 |
+
g.append("text")
|
| 337 |
+
.attr("class", "x-axis-title")
|
| 338 |
+
.attr("x", chartWidth / 2)
|
| 339 |
+
.attr("y", chartHeight + 46)
|
| 340 |
+
.attr("text-anchor", "middle")
|
| 341 |
+
.attr("fill", mutedText)
|
| 342 |
+
.style("font-family", fontFamily)
|
| 343 |
+
.style("font-size", `${fontSize(10)}px`)
|
| 344 |
+
.style("font-weight", 600)
|
| 345 |
+
.text(cleanLabel(xColumn.name || xField));
|
| 346 |
+
|
| 347 |
+
const finalIndex = stackData.length - 1;
|
| 348 |
+
const endpointRows = stackedData.map((layer, index) => {
|
| 349 |
+
const point = layer[finalIndex];
|
| 350 |
+
return {
|
| 351 |
+
group: layer.key,
|
| 352 |
+
index,
|
| 353 |
+
anchorY: (yScale(point[0]) + yScale(point[1])) / 2,
|
| 354 |
+
value: stackData[finalIndex][layer.key] || 0
|
| 355 |
+
};
|
| 356 |
+
}).sort((a, b) => a.anchorY - b.anchorY);
|
| 357 |
+
|
| 358 |
+
const minLabelGap = 56;
|
| 359 |
+
endpointRows.forEach((row, index) => {
|
| 360 |
+
const minY = index === 0 ? 18 : endpointRows[index - 1].packedY + minLabelGap;
|
| 361 |
+
row.packedY = Math.max(row.anchorY, minY);
|
| 362 |
+
});
|
| 363 |
+
const overflow = endpointRows.length
|
| 364 |
+
? endpointRows[endpointRows.length - 1].packedY - (chartHeight - 22)
|
| 365 |
+
: 0;
|
| 366 |
+
if (overflow > 0) {
|
| 367 |
+
for (let i = endpointRows.length - 1; i >= 0; i--) {
|
| 368 |
+
const maxY = i === endpointRows.length - 1 ? chartHeight - 22 : endpointRows[i + 1].packedY - minLabelGap;
|
| 369 |
+
endpointRows[i].packedY = Math.min(endpointRows[i].packedY - overflow, maxY);
|
| 370 |
+
}
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
const endpointByGroup = new Map(endpointRows.map(row => [row.group, row]));
|
| 374 |
+
const endpointLayer = g.append("g")
|
| 375 |
+
.attr("class", "endpoint-label-layer")
|
| 376 |
+
.attr("data-role", "endpoint-series-labels")
|
| 377 |
+
.attr("data-relationship", "attached-centerline-aligned-to-final-year-band-midpoints")
|
| 378 |
+
.attr("data-alignment-basis", "final-year-band-midpoint-plus-collision-aware-vertical-packing");
|
| 379 |
+
|
| 380 |
+
const iconX = chartWidth + 24;
|
| 381 |
+
const textX = chartWidth + 56;
|
| 382 |
+
const endpointGroups = endpointLayer.selectAll("g.endpoint-label")
|
| 383 |
+
.data(stackedData.map(layer => endpointByGroup.get(layer.key)).filter(Boolean))
|
| 384 |
+
.enter()
|
| 385 |
+
.append("g")
|
| 386 |
+
.attr("class", "endpoint-label")
|
| 387 |
+
.attr("data-series", d => d.group)
|
| 388 |
+
.attr("data-anchor-y", d => d.anchorY.toFixed(2))
|
| 389 |
+
.attr("data-packed-y", d => d.packedY.toFixed(2));
|
| 390 |
+
|
| 391 |
+
endpointGroups.append("line")
|
| 392 |
+
.attr("x1", chartWidth - 1)
|
| 393 |
+
.attr("x2", iconX - 24)
|
| 394 |
+
.attr("y1", d => d.anchorY)
|
| 395 |
+
.attr("y2", d => d.packedY)
|
| 396 |
+
.attr("stroke", "#81AFC7")
|
| 397 |
+
.attr("stroke-width", 0.9)
|
| 398 |
+
.attr("stroke-opacity", 0.62);
|
| 399 |
+
|
| 400 |
+
endpointGroups.append("circle")
|
| 401 |
+
.attr("class", "endpoint-series-icon-slot")
|
| 402 |
+
.attr("cx", iconX)
|
| 403 |
+
.attr("cy", d => d.packedY)
|
| 404 |
+
.attr("r", 23)
|
| 405 |
+
.attr("fill", "#FFFFFF")
|
| 406 |
+
.attr("stroke", "#D7E8F2")
|
| 407 |
+
.attr("stroke-width", 1.2)
|
| 408 |
+
.attr("data-asset-slot", "endpoint-series-icon")
|
| 409 |
+
.attr("data-slot-policy", "reserved-semantic-slot-no-raster")
|
| 410 |
+
.attr("data-asset-source-policy", "none-in-source-data")
|
| 411 |
+
.attr("data-anchor", "final-year-stacked-area-band-midpoint")
|
| 412 |
+
.attr("data-bbox-policy", "circle-r23-centered-on-packed-endpoint-label")
|
| 413 |
+
.attr("data-collision-rule", "packed with endpoint labels inside right gutter")
|
| 414 |
+
.attr("data-no-embedded-raster", "true")
|
| 415 |
+
.attr("data-no-copied-reference-art", "true");
|
| 416 |
+
|
| 417 |
+
endpointGroups.append("circle")
|
| 418 |
+
.attr("class", "endpoint-series-icon-slot-inner")
|
| 419 |
+
.attr("cx", iconX)
|
| 420 |
+
.attr("cy", d => d.packedY)
|
| 421 |
+
.attr("r", 15)
|
| 422 |
+
.attr("fill", "none")
|
| 423 |
+
.attr("stroke", "#A9CFE2")
|
| 424 |
+
.attr("stroke-width", 1)
|
| 425 |
+
.attr("stroke-dasharray", "3 4")
|
| 426 |
+
.attr("opacity", 0.9)
|
| 427 |
+
.attr("data-asset-placeholder", "neutral-circular-slot")
|
| 428 |
+
.attr("data-no-embedded-raster", "true")
|
| 429 |
+
.attr("data-no-copied-reference-art", "true");
|
| 430 |
+
|
| 431 |
+
endpointGroups.append("text")
|
| 432 |
+
.attr("x", textX)
|
| 433 |
+
.attr("y", d => d.packedY - 3)
|
| 434 |
+
.attr("text-anchor", "start")
|
| 435 |
+
.attr("fill", textColor)
|
| 436 |
+
.style("font-family", fontFamily)
|
| 437 |
+
.style("font-size", `${fontSize(10.5)}px`)
|
| 438 |
+
.style("font-weight", 700)
|
| 439 |
+
.each(function(d) {
|
| 440 |
+
const label = truncate(d.group, 32);
|
| 441 |
+
const words = label.split(/\s+/);
|
| 442 |
+
const first = words.slice(0, Math.ceil(words.length / 2)).join(" ");
|
| 443 |
+
const second = words.slice(Math.ceil(words.length / 2)).join(" ");
|
| 444 |
+
const text = d3.select(this);
|
| 445 |
+
text.append("tspan").attr("x", textX).attr("dy", 0).text(first);
|
| 446 |
+
if (second) text.append("tspan").attr("x", textX).attr("dy", 12).text(second);
|
| 447 |
+
});
|
| 448 |
+
|
| 449 |
+
return svg.node();
|
| 450 |
+
}
|
modules/chart_engine/template/d3-js/area_chart/stacked_area_chart_02.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Stacked Area Chart",
|
| 5 |
+
"chart_name": "stacked_area_chart_02",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[2, 10], [0, "inf"], [2, 6]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
const jsonData = data;
|
| 27 |
+
const chartData = jsonData.data.data;
|
| 28 |
+
const variables = jsonData.variables;
|
| 29 |
+
const typography = jsonData.typography;
|
| 30 |
+
const colors = jsonData.colors || {};
|
| 31 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 32 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 33 |
+
const images = jsonData.images || {};
|
| 34 |
+
|
| 35 |
+
d3.select(containerSelector).html("");
|
| 36 |
+
|
| 37 |
+
// 获取字段名
|
| 38 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 39 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 40 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 41 |
+
|
| 42 |
+
const parseTemporalValue = value => {
|
| 43 |
+
if (value instanceof Date) return value.getTime();
|
| 44 |
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
| 45 |
+
const text = String(value ?? "").trim();
|
| 46 |
+
if (/^\d{4}$/.test(text)) return +text;
|
| 47 |
+
const parsed = Date.parse(text);
|
| 48 |
+
return Number.isNaN(parsed) ? null : parsed;
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const isTemporalX = (dataColumns[0] && dataColumns[0].data_type === "temporal")
|
| 52 |
+
|| chartData.some(d => parseTemporalValue(d[xField]) !== null);
|
| 53 |
+
|
| 54 |
+
// Keep larger average groups lower in the stack, matching the selected reference.
|
| 55 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))]
|
| 56 |
+
.sort((a, b) => {
|
| 57 |
+
const avgA = d3.mean(chartData.filter(d => d[groupField] === a), d => +d[yField]);
|
| 58 |
+
const avgB = d3.mean(chartData.filter(d => d[groupField] === b), d => +d[yField]);
|
| 59 |
+
return avgB - avgA;
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
// 找出所有组都有数据的x值
|
| 63 |
+
const xValuesByGroup = {};
|
| 64 |
+
groups.forEach(group => {
|
| 65 |
+
xValuesByGroup[group] = new Set(
|
| 66 |
+
chartData.filter(d => d[groupField] === group).map(d => d[xField])
|
| 67 |
+
);
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
// 找出所有组共有的x值
|
| 71 |
+
let commonXValues = [...xValuesByGroup[groups[0]]];
|
| 72 |
+
for (let i = 1; i < groups.length; i++) {
|
| 73 |
+
commonXValues = commonXValues.filter(x => xValuesByGroup[groups[i]].has(x));
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// Temporal fields must render chronologically by default.
|
| 77 |
+
commonXValues.sort((a, b) => {
|
| 78 |
+
if (isTemporalX) {
|
| 79 |
+
const ta = parseTemporalValue(a);
|
| 80 |
+
const tb = parseTemporalValue(b);
|
| 81 |
+
if (ta !== null && tb !== null && ta !== tb) return ta - tb;
|
| 82 |
+
}
|
| 83 |
+
const aIndex = chartData.findIndex(d => d[xField] === a);
|
| 84 |
+
const bIndex = chartData.findIndex(d => d[xField] === b);
|
| 85 |
+
return aIndex - bIndex;
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
// 过滤数据,只保留所有组都有的x值
|
| 89 |
+
const filteredData = chartData.filter(d => commonXValues.includes(d[xField]));
|
| 90 |
+
|
| 91 |
+
// 设置尺寸和边距
|
| 92 |
+
const width = variables.width;
|
| 93 |
+
const height = variables.height;
|
| 94 |
+
const margin = { top: 44, right: 22, bottom: 62, left: 76 };
|
| 95 |
+
|
| 96 |
+
// 创建SVG
|
| 97 |
+
const svg = d3.select(containerSelector)
|
| 98 |
+
.append("svg")
|
| 99 |
+
.attr("width", "100%")
|
| 100 |
+
.attr("height", height)
|
| 101 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 102 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 103 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 104 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 105 |
+
|
| 106 |
+
const defs = svg.append("defs");
|
| 107 |
+
const shadow = defs.append("filter")
|
| 108 |
+
.attr("id", "stacked-area-02-legend-shadow")
|
| 109 |
+
.attr("x", "-15%")
|
| 110 |
+
.attr("y", "-70%")
|
| 111 |
+
.attr("width", "130%")
|
| 112 |
+
.attr("height", "240%");
|
| 113 |
+
shadow.append("feDropShadow")
|
| 114 |
+
.attr("dx", 0)
|
| 115 |
+
.attr("dy", 6)
|
| 116 |
+
.attr("stdDeviation", 7)
|
| 117 |
+
.attr("flood-color", "#56708c")
|
| 118 |
+
.attr("flood-opacity", 0.22);
|
| 119 |
+
|
| 120 |
+
// 创建图表区域
|
| 121 |
+
const chartWidth = width - margin.left - margin.right;
|
| 122 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 123 |
+
|
| 124 |
+
const g = svg.append("g")
|
| 125 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 126 |
+
|
| 127 |
+
// 创建x轴比例尺 - 使用分类比例尺
|
| 128 |
+
const xScale = d3.scaleBand()
|
| 129 |
+
.domain(commonXValues)
|
| 130 |
+
.range([0, chartWidth])
|
| 131 |
+
.padding(0.28);
|
| 132 |
+
|
| 133 |
+
// 为堆叠数据准备
|
| 134 |
+
const groupedData = d3.group(filteredData, d => d[xField]);
|
| 135 |
+
|
| 136 |
+
// 转换为堆叠格式
|
| 137 |
+
const stackData = Array.from(groupedData, ([key, values]) => {
|
| 138 |
+
const obj = { category: key };
|
| 139 |
+
values.forEach(v => {
|
| 140 |
+
obj[v[groupField]] = v[yField];
|
| 141 |
+
});
|
| 142 |
+
return obj;
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
// 确保所有组都有值(如果某些类别缺少某组的数据,填充为0)
|
| 146 |
+
stackData.forEach(d => {
|
| 147 |
+
groups.forEach(group => {
|
| 148 |
+
if (d[group] === undefined) {
|
| 149 |
+
d[group] = 0;
|
| 150 |
+
}
|
| 151 |
+
});
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
// 按照原始x值顺序排序
|
| 155 |
+
stackData.sort((a, b) => {
|
| 156 |
+
return commonXValues.indexOf(a.category) - commonXValues.indexOf(b.category);
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
// 计算每个类别的总和,并转换为百分比
|
| 160 |
+
stackData.forEach(d => {
|
| 161 |
+
const total = groups.reduce((sum, group) => sum + d[group], 0);
|
| 162 |
+
groups.forEach(group => {
|
| 163 |
+
d[group + "_original"] = d[group]; // 保存原始值
|
| 164 |
+
d[group] = (d[group] / total) * 100; // 转换为百分比
|
| 165 |
+
});
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
// 创建堆叠生成器
|
| 169 |
+
const stack = d3.stack()
|
| 170 |
+
.keys(groups)
|
| 171 |
+
.order(d3.stackOrderNone)
|
| 172 |
+
.offset(d3.stackOffsetNone);
|
| 173 |
+
|
| 174 |
+
// 生成堆叠数据
|
| 175 |
+
const stackedData = stack(stackData);
|
| 176 |
+
|
| 177 |
+
// 创建y轴比例尺 (固定为0-100%)
|
| 178 |
+
const yScale = d3.scaleLinear()
|
| 179 |
+
.domain([0, 100])
|
| 180 |
+
.range([chartHeight, 0]);
|
| 181 |
+
|
| 182 |
+
// 添加Y轴网格线
|
| 183 |
+
const yTicks = [0, 20, 40, 60, 80, 100];
|
| 184 |
+
yTicks.forEach(tick => {
|
| 185 |
+
g.append("line")
|
| 186 |
+
.attr("x1", 0)
|
| 187 |
+
.attr("y1", yScale(tick))
|
| 188 |
+
.attr("x2", chartWidth)
|
| 189 |
+
.attr("y2", yScale(tick))
|
| 190 |
+
.attr("stroke", "#cfd8e3")
|
| 191 |
+
.attr("stroke-width", 1)
|
| 192 |
+
.attr("stroke-dasharray", "4,4")
|
| 193 |
+
.attr("opacity", 0.88);
|
| 194 |
+
|
| 195 |
+
g.append("text")
|
| 196 |
+
.attr("x", -10)
|
| 197 |
+
.attr("y", yScale(tick))
|
| 198 |
+
.attr("text-anchor", "end")
|
| 199 |
+
.attr("dominant-baseline", "middle")
|
| 200 |
+
.attr("fill", "#26384f")
|
| 201 |
+
.attr("font-weight", "700")
|
| 202 |
+
.style("font-size", "14px")
|
| 203 |
+
.text(tick + "%");
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
g.append("text")
|
| 207 |
+
.attr("class", "y-axis-title")
|
| 208 |
+
.attr("x", -52)
|
| 209 |
+
.attr("y", chartHeight / 2)
|
| 210 |
+
.attr("text-anchor", "middle")
|
| 211 |
+
.attr("transform", `rotate(-90, -52, ${chartHeight / 2})`)
|
| 212 |
+
.attr("fill", "#21334f")
|
| 213 |
+
.attr("font-weight", "700")
|
| 214 |
+
.style("font-family", typography.label?.font_family || "Arial")
|
| 215 |
+
.style("font-size", "16px")
|
| 216 |
+
.text(dataColumns[1]?.name === yField ? yField.replace(/([a-z])([A-Z])/g, "$1 $2") : yField);
|
| 217 |
+
|
| 218 |
+
// 添加X轴刻度和标签
|
| 219 |
+
commonXValues.forEach(category => {
|
| 220 |
+
g.append("text")
|
| 221 |
+
.attr("x", xScale(category) + xScale.bandwidth() / 2)
|
| 222 |
+
.attr("y", chartHeight + 25)
|
| 223 |
+
.attr("text-anchor", "middle")
|
| 224 |
+
.style("font-family", "Arial")
|
| 225 |
+
.style("font-size", "14px")
|
| 226 |
+
.style("font-weight", "700")
|
| 227 |
+
.style("fill", "#26384f")
|
| 228 |
+
.text(category);
|
| 229 |
+
});
|
| 230 |
+
|
| 231 |
+
// 添加X轴线
|
| 232 |
+
g.append("line")
|
| 233 |
+
.attr("x1", 0)
|
| 234 |
+
.attr("y1", chartHeight)
|
| 235 |
+
.attr("x2", chartWidth)
|
| 236 |
+
.attr("y2", chartHeight)
|
| 237 |
+
.attr("stroke", "#6d7d91")
|
| 238 |
+
.attr("stroke-width", 1.2);
|
| 239 |
+
|
| 240 |
+
const markLayer = g.append("g").attr("class", "stacked-column-layer");
|
| 241 |
+
|
| 242 |
+
commonXValues.forEach((category, categoryIndex) => {
|
| 243 |
+
const barData = stackData.find(d => d.category === category);
|
| 244 |
+
if (!barData) return;
|
| 245 |
+
|
| 246 |
+
const barX = xScale(category);
|
| 247 |
+
const barWidth = xScale.bandwidth();
|
| 248 |
+
const clipId = `stacked-area-02-rounded-column-${categoryIndex}`;
|
| 249 |
+
defs.append("clipPath")
|
| 250 |
+
.attr("id", clipId)
|
| 251 |
+
.append("rect")
|
| 252 |
+
.attr("x", barX)
|
| 253 |
+
.attr("y", 0)
|
| 254 |
+
.attr("width", barWidth)
|
| 255 |
+
.attr("height", chartHeight)
|
| 256 |
+
.attr("rx", Math.min(10, barWidth * 0.18))
|
| 257 |
+
.attr("ry", Math.min(10, barWidth * 0.18));
|
| 258 |
+
|
| 259 |
+
const barGroup = markLayer.append("g")
|
| 260 |
+
.attr("class", "stacked-column")
|
| 261 |
+
.attr("data-x-value", category)
|
| 262 |
+
.attr("data-mark-endcap", "rounded-outer-corners-flat-internal-joins")
|
| 263 |
+
.attr("clip-path", `url(#${clipId})`);
|
| 264 |
+
|
| 265 |
+
let cumulativeHeight = 0;
|
| 266 |
+
|
| 267 |
+
groups.forEach((group, groupIndex) => {
|
| 268 |
+
const value = barData[group];
|
| 269 |
+
const height = (value / 100) * chartHeight;
|
| 270 |
+
const y = chartHeight - cumulativeHeight - height;
|
| 271 |
+
|
| 272 |
+
const color = colorResolver.field(group, groupIndex, { palette: "category10" }).value;
|
| 273 |
+
const gradientId = `stacked-area-02-gradient-${categoryIndex}-${groupIndex}`;
|
| 274 |
+
const gradient = defs.append("linearGradient")
|
| 275 |
+
.attr("id", gradientId)
|
| 276 |
+
.attr("x1", "0%")
|
| 277 |
+
.attr("y1", "0%")
|
| 278 |
+
.attr("x2", "100%")
|
| 279 |
+
.attr("y2", "100%");
|
| 280 |
+
gradient.append("stop")
|
| 281 |
+
.attr("offset", "0%")
|
| 282 |
+
.attr("stop-color", chartUtils.color.scaleRgb(color, 1.08));
|
| 283 |
+
gradient.append("stop")
|
| 284 |
+
.attr("offset", "100%")
|
| 285 |
+
.attr("stop-color", chartUtils.color.scaleRgb(color, 0.92));
|
| 286 |
+
|
| 287 |
+
barGroup.append("rect")
|
| 288 |
+
.attr("class", "stacked-segment")
|
| 289 |
+
.attr("data-series", group)
|
| 290 |
+
.attr("data-value", barData[group + "_original"])
|
| 291 |
+
.attr("data-percent", value)
|
| 292 |
+
.attr("data-stacked-join", groupIndex === 0 ? "baseline-rounded-by-column-clip" : "flat-internal-join")
|
| 293 |
+
.attr("x", barX)
|
| 294 |
+
.attr("y", y)
|
| 295 |
+
.attr("width", barWidth)
|
| 296 |
+
.attr("height", height)
|
| 297 |
+
.attr("fill", `url(#${gradientId})`);
|
| 298 |
+
|
| 299 |
+
const minHeightForLabel = 34;
|
| 300 |
+
|
| 301 |
+
if (height >= minHeightForLabel) {
|
| 302 |
+
const labelX = barX + barWidth / 2;
|
| 303 |
+
const labelY = y + height / 2;
|
| 304 |
+
const originalValue = barData[group + "_original"];
|
| 305 |
+
|
| 306 |
+
g.append("text")
|
| 307 |
+
.attr("class", "segment-label-percent")
|
| 308 |
+
.attr("data-series", group)
|
| 309 |
+
.attr("data-x-value", category)
|
| 310 |
+
.attr("x", labelX)
|
| 311 |
+
.attr("y", labelY - 7)
|
| 312 |
+
.attr("text-anchor", "middle")
|
| 313 |
+
.attr("dominant-baseline", "middle")
|
| 314 |
+
.attr("fill", "#fff")
|
| 315 |
+
.attr("font-weight", "800")
|
| 316 |
+
.style("font-size", "15px")
|
| 317 |
+
.style("paint-order", "stroke")
|
| 318 |
+
.style("stroke", "rgba(0,0,0,0.16)")
|
| 319 |
+
.style("stroke-width", "1px")
|
| 320 |
+
.style("stroke-linejoin", "round")
|
| 321 |
+
.text(chartUtils.format.percent(value).text);
|
| 322 |
+
|
| 323 |
+
g.append("text")
|
| 324 |
+
.attr("class", "segment-label-raw")
|
| 325 |
+
.attr("data-series", group)
|
| 326 |
+
.attr("data-x-value", category)
|
| 327 |
+
.attr("x", labelX)
|
| 328 |
+
.attr("y", labelY + 8)
|
| 329 |
+
.attr("text-anchor", "middle")
|
| 330 |
+
.attr("dominant-baseline", "middle")
|
| 331 |
+
.attr("fill", "rgba(255,255,255,0.92)")
|
| 332 |
+
.attr("font-weight", "700")
|
| 333 |
+
.style("font-size", "12px")
|
| 334 |
+
.style("paint-order", "stroke")
|
| 335 |
+
.style("stroke", "rgba(0,0,0,0.18)")
|
| 336 |
+
.style("stroke-width", "0.8px")
|
| 337 |
+
.style("stroke-linejoin", "round")
|
| 338 |
+
.text(`(${chartUtils.format.fixed(originalValue).text})`);
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
cumulativeHeight += height;
|
| 342 |
+
});
|
| 343 |
+
});
|
| 344 |
+
|
| 345 |
+
const legendGroup = g.append("g")
|
| 346 |
+
.attr("class", "legend-pill")
|
| 347 |
+
.attr("data-coordinate-relationship", "independent-floating-card")
|
| 348 |
+
.attr("data-anchor", "between-subtitle-and-plot")
|
| 349 |
+
.attr("data-collision-rule", "avoid-title-subtitle-and-plot");
|
| 350 |
+
|
| 351 |
+
const titleWidth = chartUtils.text.measure(null, groupField, { fontSize: 16, fontWeight: "bold" }).width;
|
| 352 |
+
const titleMargin = 15;
|
| 353 |
+
|
| 354 |
+
const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
|
| 355 |
+
x: titleWidth + titleMargin,
|
| 356 |
+
y: 0,
|
| 357 |
+
fontSize: 14,
|
| 358 |
+
fontWeight: "bold",
|
| 359 |
+
align: "left",
|
| 360 |
+
maxWidth: chartWidth - titleWidth - titleMargin,
|
| 361 |
+
shape: "rect",
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
legendGroup.append("text")
|
| 365 |
+
.attr("class", "legend-title")
|
| 366 |
+
.attr("x", 0)
|
| 367 |
+
.attr("y", legendSize.height / 2)
|
| 368 |
+
.attr("dominant-baseline", "middle")
|
| 369 |
+
.attr("fill", "#1e2b3e")
|
| 370 |
+
.style("font-size", "16px")
|
| 371 |
+
.style("font-weight", "800")
|
| 372 |
+
.text(groupField);
|
| 373 |
+
|
| 374 |
+
const legendBBox = legendGroup.node().getBBox();
|
| 375 |
+
const padX = 18;
|
| 376 |
+
const padY = 10;
|
| 377 |
+
legendGroup.insert("rect", ":first-child")
|
| 378 |
+
.attr("class", "legend-pill-bg")
|
| 379 |
+
.attr("x", legendBBox.x - padX)
|
| 380 |
+
.attr("y", legendBBox.y - padY)
|
| 381 |
+
.attr("width", legendBBox.width + padX * 2)
|
| 382 |
+
.attr("height", legendBBox.height + padY * 2)
|
| 383 |
+
.attr("rx", 18)
|
| 384 |
+
.attr("ry", 18)
|
| 385 |
+
.attr("fill", "#ffffff")
|
| 386 |
+
.attr("stroke", "rgba(100,130,160,0.14)")
|
| 387 |
+
.attr("filter", "url(#stacked-area-02-legend-shadow)")
|
| 388 |
+
.attr("data-legend-relationship", "independent-floating-card");
|
| 389 |
+
|
| 390 |
+
const legendTotalWidth = legendBBox.width + padX * 2;
|
| 391 |
+
legendGroup.attr("transform", `translate(${(chartWidth - legendTotalWidth) / 2 - legendBBox.x + padX}, ${-42 - legendSize.height/2})`);
|
| 392 |
+
|
| 393 |
+
return svg.node();
|
| 394 |
+
}
|