Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
modules/chart_engine/template/d3-js/type36_range_area_chart/range_area_chart_icons_01.js
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Range Area Chart",
|
| 5 |
+
"chart_name": "range_area_chart_icons_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 chartData = jsonData.data.data;
|
| 29 |
+
const variables = jsonData.variables;
|
| 30 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 31 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 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 yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
|
| 39 |
+
const yDescription = yColumn.description || yField;
|
| 40 |
+
const yUnit = yColumn.unit || "";
|
| 41 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 42 |
+
|
| 43 |
+
const width = variables.width || 600;
|
| 44 |
+
const height = variables.height || 600;
|
| 45 |
+
|
| 46 |
+
const svg = d3.select(containerSelector)
|
| 47 |
+
.append("svg")
|
| 48 |
+
.attr("width", "100%")
|
| 49 |
+
.attr("height", height)
|
| 50 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 51 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 52 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 53 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 54 |
+
.attr("data-no-embedded-raster", "true")
|
| 55 |
+
.attr("data-no-copied-reference-art", "true");
|
| 56 |
+
|
| 57 |
+
const defs = svg.append("defs");
|
| 58 |
+
const shadowId = `range-panel-shadow-${Math.round(width)}-${Math.round(height)}`;
|
| 59 |
+
const shadow = defs.append("filter")
|
| 60 |
+
.attr("id", shadowId)
|
| 61 |
+
.attr("x", "-8%")
|
| 62 |
+
.attr("y", "-8%")
|
| 63 |
+
.attr("width", "116%")
|
| 64 |
+
.attr("height", "116%");
|
| 65 |
+
shadow.append("feDropShadow")
|
| 66 |
+
.attr("dx", 0)
|
| 67 |
+
.attr("dy", 4)
|
| 68 |
+
.attr("stdDeviation", 5)
|
| 69 |
+
.attr("flood-color", "#0b1b2e")
|
| 70 |
+
.attr("flood-opacity", 0.12);
|
| 71 |
+
|
| 72 |
+
const panel = {
|
| 73 |
+
x: width * 0.025,
|
| 74 |
+
y: height * 0.035,
|
| 75 |
+
w: width * 0.95,
|
| 76 |
+
h: height * 0.92
|
| 77 |
+
};
|
| 78 |
+
const rightLaneW = Math.max(78, panel.w * 0.16);
|
| 79 |
+
const plot = {
|
| 80 |
+
x: panel.x + panel.w * 0.075,
|
| 81 |
+
y: panel.y + panel.h * 0.31,
|
| 82 |
+
w: panel.w - panel.w * 0.075 - rightLaneW - panel.w * 0.045,
|
| 83 |
+
h: panel.h * 0.58
|
| 84 |
+
};
|
| 85 |
+
plot.bottom = plot.y + plot.h;
|
| 86 |
+
plot.right = plot.x + plot.w;
|
| 87 |
+
|
| 88 |
+
svg.append("rect")
|
| 89 |
+
.attr("x", panel.x)
|
| 90 |
+
.attr("y", panel.y)
|
| 91 |
+
.attr("width", panel.w)
|
| 92 |
+
.attr("height", panel.h)
|
| 93 |
+
.attr("rx", 16)
|
| 94 |
+
.attr("fill", "#ffffff")
|
| 95 |
+
.attr("stroke", "#d9e4e6")
|
| 96 |
+
.attr("stroke-width", 1)
|
| 97 |
+
.attr("filter", `url(#${shadowId})`);
|
| 98 |
+
|
| 99 |
+
const slot = svg.append("g")
|
| 100 |
+
.attr("class", "asset-slot panel-header-icon-slot")
|
| 101 |
+
.attr("data-asset-slot", "panel-header-icon")
|
| 102 |
+
.attr("data-asset-anchor", "panel-header-left")
|
| 103 |
+
.attr("data-asset-bbox", `${Math.round(panel.x + 21)},${Math.round(panel.y + 27)},52,52`)
|
| 104 |
+
.attr("data-collision-policy", "avoid-panel-title-legend-plot")
|
| 105 |
+
.attr("data-asset-source-policy", "neutral_placeholder")
|
| 106 |
+
.attr("data-no-embedded-raster", "true")
|
| 107 |
+
.attr("data-no-copied-reference-art", "true");
|
| 108 |
+
|
| 109 |
+
slot.append("circle")
|
| 110 |
+
.attr("cx", panel.x + 47)
|
| 111 |
+
.attr("cy", panel.y + 53)
|
| 112 |
+
.attr("r", 26)
|
| 113 |
+
.attr("fill", "#078b8b")
|
| 114 |
+
.attr("opacity", 0.95);
|
| 115 |
+
slot.append("circle")
|
| 116 |
+
.attr("cx", panel.x + 47)
|
| 117 |
+
.attr("cy", panel.y + 53)
|
| 118 |
+
.attr("r", 16)
|
| 119 |
+
.attr("fill", "none")
|
| 120 |
+
.attr("stroke", "#ffffff")
|
| 121 |
+
.attr("stroke-width", 2)
|
| 122 |
+
.attr("stroke-dasharray", "3 4")
|
| 123 |
+
.attr("opacity", 0.85);
|
| 124 |
+
|
| 125 |
+
const titleX = panel.x + 84;
|
| 126 |
+
const panelTitle = `${yDescription}`;
|
| 127 |
+
const years = chartData.map(d => chartUtils.format.parseDate(d[xField]).getFullYear()).filter(d => Number.isFinite(d));
|
| 128 |
+
const yearLabel = years.length ? `${d3.min(years)}-${d3.max(years)}` : "";
|
| 129 |
+
|
| 130 |
+
svg.append("text")
|
| 131 |
+
.attr("x", titleX)
|
| 132 |
+
.attr("y", panel.y + 46)
|
| 133 |
+
.attr("fill", "#071a33")
|
| 134 |
+
.style("font-family", "Arial, sans-serif")
|
| 135 |
+
.style("font-size", `${fontSize(14)}px`)
|
| 136 |
+
.style("font-weight", 800)
|
| 137 |
+
.text(panelTitle.toUpperCase());
|
| 138 |
+
|
| 139 |
+
if (yearLabel) {
|
| 140 |
+
svg.append("text")
|
| 141 |
+
.attr("x", panel.x + panel.w - 116)
|
| 142 |
+
.attr("y", panel.y + 46)
|
| 143 |
+
.attr("fill", "#324255")
|
| 144 |
+
.style("font-family", "Arial, sans-serif")
|
| 145 |
+
.style("font-size", `${fontSize(14)}px`)
|
| 146 |
+
.style("font-weight", 400)
|
| 147 |
+
.text(yearLabel);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
const groupNames = [...new Set(chartData.map(d => d[groupField]))];
|
| 151 |
+
const groupAverages = groupNames.map(group => {
|
| 152 |
+
const groupRows = chartData.filter(d => d[groupField] === group);
|
| 153 |
+
return { group, avg: d3.mean(groupRows, d => +d[yField]) };
|
| 154 |
+
}).sort((a, b) => d3.descending(a.avg, b.avg));
|
| 155 |
+
|
| 156 |
+
const selectedGroups = groupAverages.slice(0, 2).map(d => d.group);
|
| 157 |
+
if (selectedGroups.length < 2) {
|
| 158 |
+
return svg.node();
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
const parseX = value => {
|
| 162 |
+
const parsed = chartUtils.format.parseDate(value);
|
| 163 |
+
if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed;
|
| 164 |
+
if (Number.isFinite(+value)) return new Date(+value, 0, 1);
|
| 165 |
+
return new Date(value);
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
const grouped = selectedGroups.map(group => ({
|
| 169 |
+
group,
|
| 170 |
+
rows: chartData
|
| 171 |
+
.filter(d => d[groupField] === group)
|
| 172 |
+
.map(d => ({ ...d, __x: parseX(d[xField]), __y: +d[yField] }))
|
| 173 |
+
.filter(d => d.__x instanceof Date && !Number.isNaN(d.__x.getTime()) && Number.isFinite(d.__y))
|
| 174 |
+
.sort((a, b) => d3.ascending(a.__x, b.__x))
|
| 175 |
+
}));
|
| 176 |
+
|
| 177 |
+
const allRows = grouped.flatMap(d => d.rows);
|
| 178 |
+
const yMinRaw = d3.min(allRows, d => d.__y);
|
| 179 |
+
const yMaxRaw = d3.max(allRows, d => d.__y);
|
| 180 |
+
const yMin = Math.floor(yMinRaw / 5) * 5;
|
| 181 |
+
const yMax = Math.ceil(yMaxRaw / 5) * 5;
|
| 182 |
+
const yDomain = yMin === yMax ? [yMin - 1, yMax + 1] : [yMin, yMax];
|
| 183 |
+
|
| 184 |
+
const xScale = d3.scaleTime()
|
| 185 |
+
.domain(d3.extent(allRows, d => d.__x))
|
| 186 |
+
.range([plot.x, plot.right]);
|
| 187 |
+
const yScale = d3.scaleLinear()
|
| 188 |
+
.domain(yDomain)
|
| 189 |
+
.nice(5)
|
| 190 |
+
.range([plot.bottom, plot.y]);
|
| 191 |
+
|
| 192 |
+
const warm = "#f2a000";
|
| 193 |
+
const teal = "#078b8b";
|
| 194 |
+
const series = [
|
| 195 |
+
{
|
| 196 |
+
...grouped[0],
|
| 197 |
+
color: warm,
|
| 198 |
+
fill: "#f8d46b",
|
| 199 |
+
labelPosition: "above"
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
...grouped[1],
|
| 203 |
+
color: teal,
|
| 204 |
+
fill: "#88d8d5",
|
| 205 |
+
labelPosition: "above"
|
| 206 |
+
}
|
| 207 |
+
];
|
| 208 |
+
|
| 209 |
+
const legendY = panel.y + 88;
|
| 210 |
+
series.forEach((s, i) => {
|
| 211 |
+
const lx = titleX + i * Math.min(150, panel.w * 0.25);
|
| 212 |
+
svg.append("line")
|
| 213 |
+
.attr("x1", lx)
|
| 214 |
+
.attr("y1", legendY)
|
| 215 |
+
.attr("x2", lx + 28)
|
| 216 |
+
.attr("y2", legendY)
|
| 217 |
+
.attr("stroke", s.color)
|
| 218 |
+
.attr("stroke-width", 5)
|
| 219 |
+
.attr("stroke-linecap", "round");
|
| 220 |
+
svg.append("text")
|
| 221 |
+
.attr("x", lx + 38)
|
| 222 |
+
.attr("y", legendY + 5)
|
| 223 |
+
.attr("fill", "#102238")
|
| 224 |
+
.style("font-family", "Arial, sans-serif")
|
| 225 |
+
.style("font-size", `${fontSize(14)}px`)
|
| 226 |
+
.text(s.group);
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
const yAxisLabel = `${chartUtils.schema.columnField(dataColumns, 1)}${yUnit ? ` (${yUnit})` : ""}`;
|
| 230 |
+
svg.append("text")
|
| 231 |
+
.attr("x", plot.x - 34)
|
| 232 |
+
.attr("y", plot.y - 18)
|
| 233 |
+
.attr("fill", "#102238")
|
| 234 |
+
.style("font-family", "Arial, sans-serif")
|
| 235 |
+
.style("font-size", `${fontSize(12)}px`)
|
| 236 |
+
.text(yAxisLabel);
|
| 237 |
+
|
| 238 |
+
const yTicks = yScale.ticks(5);
|
| 239 |
+
yTicks.forEach(tick => {
|
| 240 |
+
svg.append("line")
|
| 241 |
+
.attr("x1", plot.x)
|
| 242 |
+
.attr("x2", plot.right)
|
| 243 |
+
.attr("y1", yScale(tick))
|
| 244 |
+
.attr("y2", yScale(tick))
|
| 245 |
+
.attr("stroke", "#d8e0e5")
|
| 246 |
+
.attr("stroke-width", 1)
|
| 247 |
+
.attr("stroke-dasharray", "5 5");
|
| 248 |
+
svg.append("text")
|
| 249 |
+
.attr("x", plot.x - 11)
|
| 250 |
+
.attr("y", yScale(tick) + 4)
|
| 251 |
+
.attr("text-anchor", "end")
|
| 252 |
+
.attr("fill", "#102238")
|
| 253 |
+
.style("font-family", "Arial, sans-serif")
|
| 254 |
+
.style("font-size", `${fontSize(12)}px`)
|
| 255 |
+
.text(chartUtils.format.autoText(+tick) + (yUnit === "%" ? "%" : ""));
|
| 256 |
+
});
|
| 257 |
+
|
| 258 |
+
const xTicks = allRows.map(d => d.__x)
|
| 259 |
+
.filter((d, i, arr) => arr.findIndex(v => +v === +d) === i)
|
| 260 |
+
.sort((a, b) => d3.ascending(a, b));
|
| 261 |
+
const maxVisibleXTicks = 7;
|
| 262 |
+
const tickStep = xTicks.length > maxVisibleXTicks
|
| 263 |
+
? Math.ceil((xTicks.length - 1) / (maxVisibleXTicks - 1))
|
| 264 |
+
: 1;
|
| 265 |
+
const visibleXTicks = xTicks.filter((tick, i) =>
|
| 266 |
+
i === 0 || i === xTicks.length - 1 || i % tickStep === 0
|
| 267 |
+
);
|
| 268 |
+
visibleXTicks.forEach(tick => {
|
| 269 |
+
const x = xScale(tick);
|
| 270 |
+
svg.append("line")
|
| 271 |
+
.attr("x1", x)
|
| 272 |
+
.attr("x2", x)
|
| 273 |
+
.attr("y1", plot.y)
|
| 274 |
+
.attr("y2", plot.bottom)
|
| 275 |
+
.attr("stroke", "#e2e8ec")
|
| 276 |
+
.attr("stroke-width", 1)
|
| 277 |
+
.attr("stroke-dasharray", "5 5");
|
| 278 |
+
svg.append("text")
|
| 279 |
+
.attr("x", x)
|
| 280 |
+
.attr("y", plot.bottom + 24)
|
| 281 |
+
.attr("text-anchor", "middle")
|
| 282 |
+
.attr("fill", "#102238")
|
| 283 |
+
.style("font-family", "Arial, sans-serif")
|
| 284 |
+
.style("font-size", `${fontSize(13)}px`)
|
| 285 |
+
.text(tick.getFullYear());
|
| 286 |
+
});
|
| 287 |
+
|
| 288 |
+
svg.append("line")
|
| 289 |
+
.attr("x1", plot.x)
|
| 290 |
+
.attr("x2", plot.right)
|
| 291 |
+
.attr("y1", plot.bottom)
|
| 292 |
+
.attr("y2", plot.bottom)
|
| 293 |
+
.attr("stroke", "#102238")
|
| 294 |
+
.attr("stroke-width", 1.4);
|
| 295 |
+
svg.append("line")
|
| 296 |
+
.attr("x1", plot.x)
|
| 297 |
+
.attr("x2", plot.x)
|
| 298 |
+
.attr("y1", plot.y)
|
| 299 |
+
.attr("y2", plot.bottom)
|
| 300 |
+
.attr("stroke", "#102238")
|
| 301 |
+
.attr("stroke-width", 1.4);
|
| 302 |
+
|
| 303 |
+
const area = d3.area()
|
| 304 |
+
.x(d => xScale(d.__x))
|
| 305 |
+
.y0(plot.bottom)
|
| 306 |
+
.y1(d => yScale(d.__y))
|
| 307 |
+
.curve(d3.curveLinear);
|
| 308 |
+
const line = d3.line()
|
| 309 |
+
.x(d => xScale(d.__x))
|
| 310 |
+
.y(d => yScale(d.__y))
|
| 311 |
+
.curve(d3.curveLinear);
|
| 312 |
+
|
| 313 |
+
series.forEach(s => {
|
| 314 |
+
svg.append("path")
|
| 315 |
+
.datum(s.rows)
|
| 316 |
+
.attr("d", area)
|
| 317 |
+
.attr("fill", s.fill)
|
| 318 |
+
.attr("opacity", 0.28);
|
| 319 |
+
});
|
| 320 |
+
|
| 321 |
+
series.forEach(s => {
|
| 322 |
+
svg.append("path")
|
| 323 |
+
.datum(s.rows)
|
| 324 |
+
.attr("d", line)
|
| 325 |
+
.attr("fill", "none")
|
| 326 |
+
.attr("stroke", s.color)
|
| 327 |
+
.attr("stroke-width", 4)
|
| 328 |
+
.attr("stroke-linecap", "round")
|
| 329 |
+
.attr("stroke-linejoin", "round");
|
| 330 |
+
});
|
| 331 |
+
|
| 332 |
+
const cardW = Math.max(72, rightLaneW * 0.78);
|
| 333 |
+
const cardH = 64;
|
| 334 |
+
const cardX = panel.x + panel.w - cardW - 13;
|
| 335 |
+
const markerR = 7;
|
| 336 |
+
const placedLabelBoxes = [];
|
| 337 |
+
const estimateLabelBox = (label, x, y, anchor) => {
|
| 338 |
+
const w = String(label).length * fontSize(14) * 0.62;
|
| 339 |
+
const h = fontSize(14) + 4;
|
| 340 |
+
const left = anchor === "end" ? x - w : anchor === "start" ? x : x - w / 2;
|
| 341 |
+
return { x0: left - 3, y0: y - h + 1, x1: left + w + 3, y1: y + 4 };
|
| 342 |
+
};
|
| 343 |
+
const boxesOverlap = (a, b) => !(a.x1 < b.x0 || a.x0 > b.x1 || a.y1 < b.y0 || a.y0 > b.y1);
|
| 344 |
+
const shouldHideDenseDuplicate = (rows, i, xPositions) => {
|
| 345 |
+
if (rows.length <= 10 || i === 0 || i === rows.length - 1) return false;
|
| 346 |
+
const value = Math.round(rows[i].__y);
|
| 347 |
+
const prevSame = Math.round(rows[i - 1].__y) === value;
|
| 348 |
+
const nextSame = Math.round(rows[i + 1].__y) === value;
|
| 349 |
+
const closePrev = Math.abs(xPositions[i] - xPositions[i - 1]) < 30;
|
| 350 |
+
const closeNext = Math.abs(xPositions[i + 1] - xPositions[i]) < 30;
|
| 351 |
+
return prevSame && nextSame && (closePrev || closeNext);
|
| 352 |
+
};
|
| 353 |
+
const placeValueLabel = (s, d, i, xPositions) => {
|
| 354 |
+
const label = Math.round(d.__y);
|
| 355 |
+
const x = xPositions[i];
|
| 356 |
+
const y = yScale(d.__y);
|
| 357 |
+
const isLast = i === s.rows.length - 1;
|
| 358 |
+
if (shouldHideDenseDuplicate(s.rows, i, xPositions)) return;
|
| 359 |
+
|
| 360 |
+
const anchor = isLast ? "end" : "middle";
|
| 361 |
+
const labelX = isLast ? x - 10 : x;
|
| 362 |
+
const candidates = [
|
| 363 |
+
Math.max(plot.y + 14, y - 15),
|
| 364 |
+
Math.max(plot.y + 14, y - 29),
|
| 365 |
+
Math.min(plot.bottom - 8, y + 25),
|
| 366 |
+
Math.max(plot.y + 14, y - 43)
|
| 367 |
+
];
|
| 368 |
+
let chosen = null;
|
| 369 |
+
for (const candidateY of candidates) {
|
| 370 |
+
const box = estimateLabelBox(label, labelX, candidateY, anchor);
|
| 371 |
+
const insidePlot = box.x0 >= plot.x - 18 && box.x1 <= cardX - 6 && box.y0 >= plot.y - 4 && box.y1 <= plot.bottom + 2;
|
| 372 |
+
if (insidePlot && !placedLabelBoxes.some(other => boxesOverlap(box, other))) {
|
| 373 |
+
chosen = { y: candidateY, box };
|
| 374 |
+
break;
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
if (!chosen && (i === 0 || isLast || i % 2 === 0)) {
|
| 378 |
+
const fallbackY = candidates[0];
|
| 379 |
+
const box = estimateLabelBox(label, labelX, fallbackY, anchor);
|
| 380 |
+
if (!placedLabelBoxes.some(other => boxesOverlap(box, other))) {
|
| 381 |
+
chosen = { y: fallbackY, box };
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
if (!chosen) return;
|
| 385 |
+
placedLabelBoxes.push(chosen.box);
|
| 386 |
+
|
| 387 |
+
svg.append("text")
|
| 388 |
+
.attr("x", labelX)
|
| 389 |
+
.attr("y", chosen.y)
|
| 390 |
+
.attr("text-anchor", anchor)
|
| 391 |
+
.attr("fill", s.color)
|
| 392 |
+
.style("font-family", "Arial, sans-serif")
|
| 393 |
+
.style("font-size", `${fontSize(14)}px`)
|
| 394 |
+
.style("font-weight", 800)
|
| 395 |
+
.text(label);
|
| 396 |
+
};
|
| 397 |
+
|
| 398 |
+
series.forEach(s => {
|
| 399 |
+
const xPositions = s.rows.map(d => xScale(d.__x));
|
| 400 |
+
s.rows.forEach((d, i) => {
|
| 401 |
+
const x = xScale(d.__x);
|
| 402 |
+
const y = yScale(d.__y);
|
| 403 |
+
svg.append("circle")
|
| 404 |
+
.attr("cx", x)
|
| 405 |
+
.attr("cy", y)
|
| 406 |
+
.attr("r", markerR)
|
| 407 |
+
.attr("fill", s.color)
|
| 408 |
+
.attr("stroke", "#ffffff")
|
| 409 |
+
.attr("stroke-width", 3);
|
| 410 |
+
});
|
| 411 |
+
s.rows.forEach((d, i) => placeValueLabel(s, d, i, xPositions));
|
| 412 |
+
});
|
| 413 |
+
|
| 414 |
+
const endpointLabelLines = label => {
|
| 415 |
+
const normalized = String(label)
|
| 416 |
+
.toUpperCase()
|
| 417 |
+
.replace(/\bMANAGEMENT\b/g, "MGMT")
|
| 418 |
+
.replace(/\bAND\b/g, "&");
|
| 419 |
+
const words = normalized.split(/\s+/).filter(Boolean);
|
| 420 |
+
const lines = [];
|
| 421 |
+
let current = "";
|
| 422 |
+
words.forEach(word => {
|
| 423 |
+
const trial = current ? `${current} ${word}` : word;
|
| 424 |
+
if (current && trial.length > 11) {
|
| 425 |
+
lines.push(current);
|
| 426 |
+
current = word;
|
| 427 |
+
} else {
|
| 428 |
+
current = trial;
|
| 429 |
+
}
|
| 430 |
+
});
|
| 431 |
+
if (current) lines.push(current);
|
| 432 |
+
if (lines.length > 2) {
|
| 433 |
+
return [lines[0], lines.slice(1).join(" ")];
|
| 434 |
+
}
|
| 435 |
+
return lines.length ? lines : [normalized];
|
| 436 |
+
};
|
| 437 |
+
const lastItems = series.map(s => {
|
| 438 |
+
const point = s.rows[s.rows.length - 1];
|
| 439 |
+
return {
|
| 440 |
+
series: s,
|
| 441 |
+
point,
|
| 442 |
+
x: xScale(point.__x),
|
| 443 |
+
y: yScale(point.__y),
|
| 444 |
+
cardY: yScale(point.__y) - cardH / 2
|
| 445 |
+
};
|
| 446 |
+
}).sort((a, b) => d3.ascending(a.y, b.y));
|
| 447 |
+
|
| 448 |
+
const minGap = 18;
|
| 449 |
+
if (lastItems.length === 2 && lastItems[1].cardY < lastItems[0].cardY + cardH + minGap) {
|
| 450 |
+
const mid = (lastItems[0].cardY + lastItems[1].cardY + cardH) / 2;
|
| 451 |
+
lastItems[0].cardY = mid - cardH - minGap / 2;
|
| 452 |
+
lastItems[1].cardY = mid + minGap / 2;
|
| 453 |
+
}
|
| 454 |
+
lastItems.forEach(item => {
|
| 455 |
+
item.cardY = Math.max(panel.y + 104, Math.min(panel.y + panel.h - cardH - 18, item.cardY));
|
| 456 |
+
item.cardCenterY = item.cardY + cardH / 2;
|
| 457 |
+
});
|
| 458 |
+
|
| 459 |
+
lastItems.forEach(item => {
|
| 460 |
+
svg.append("line")
|
| 461 |
+
.attr("x1", item.x + markerR)
|
| 462 |
+
.attr("x2", cardX)
|
| 463 |
+
.attr("y1", item.y)
|
| 464 |
+
.attr("y2", item.cardCenterY)
|
| 465 |
+
.attr("stroke", item.series.color)
|
| 466 |
+
.attr("stroke-width", 2)
|
| 467 |
+
.attr("stroke-linecap", "round");
|
| 468 |
+
|
| 469 |
+
svg.append("rect")
|
| 470 |
+
.attr("x", cardX)
|
| 471 |
+
.attr("y", item.cardY)
|
| 472 |
+
.attr("width", cardW)
|
| 473 |
+
.attr("height", cardH)
|
| 474 |
+
.attr("rx", 10)
|
| 475 |
+
.attr("fill", item.series.color === warm ? "#fff7df" : "#e7fbfa")
|
| 476 |
+
.attr("stroke", item.series.color)
|
| 477 |
+
.attr("stroke-width", 2);
|
| 478 |
+
|
| 479 |
+
const labelLines = endpointLabelLines(item.series.group);
|
| 480 |
+
const labelFont = labelLines.some(line => line.length > 11) ? 7.2 : 8.2;
|
| 481 |
+
const labelGroup = svg.append("text")
|
| 482 |
+
.attr("x", cardX + cardW / 2)
|
| 483 |
+
.attr("y", item.cardY + (labelLines.length > 1 ? 11 : 16))
|
| 484 |
+
.attr("text-anchor", "middle")
|
| 485 |
+
.attr("fill", "#102238")
|
| 486 |
+
.style("font-family", "Arial, sans-serif")
|
| 487 |
+
.style("font-size", `${fontSize(labelFont)}px`)
|
| 488 |
+
.style("font-weight", 800);
|
| 489 |
+
labelLines.forEach((lineText, idx) => {
|
| 490 |
+
labelGroup.append("tspan")
|
| 491 |
+
.attr("x", cardX + cardW / 2)
|
| 492 |
+
.attr("dy", idx === 0 ? 0 : 9)
|
| 493 |
+
.attr("textLength", lineText.length > 12 ? cardW - 10 : null)
|
| 494 |
+
.attr("lengthAdjust", lineText.length > 12 ? "spacingAndGlyphs" : null)
|
| 495 |
+
.text(lineText);
|
| 496 |
+
});
|
| 497 |
+
|
| 498 |
+
svg.append("text")
|
| 499 |
+
.attr("x", cardX + cardW / 2)
|
| 500 |
+
.attr("y", item.cardY + (labelLines.length > 1 ? 43 : 40))
|
| 501 |
+
.attr("text-anchor", "middle")
|
| 502 |
+
.attr("fill", item.series.color)
|
| 503 |
+
.style("font-family", "Arial, sans-serif")
|
| 504 |
+
.style("font-size", `${fontSize(25)}px`)
|
| 505 |
+
.style("font-weight", 900)
|
| 506 |
+
.text(`${Math.round(item.point.__y)}${yUnit === "%" ? "%" : ""}`);
|
| 507 |
+
|
| 508 |
+
svg.append("text")
|
| 509 |
+
.attr("x", cardX + cardW / 2)
|
| 510 |
+
.attr("y", item.cardY + 58)
|
| 511 |
+
.attr("text-anchor", "middle")
|
| 512 |
+
.attr("fill", "#102238")
|
| 513 |
+
.style("font-family", "Arial, sans-serif")
|
| 514 |
+
.style("font-size", `${fontSize(10)}px`)
|
| 515 |
+
.text(`in ${item.point.__x.getFullYear()}`);
|
| 516 |
+
});
|
| 517 |
+
|
| 518 |
+
const topItem = lastItems[0];
|
| 519 |
+
const bottomItem = lastItems[lastItems.length - 1];
|
| 520 |
+
const ratio = bottomItem && topItem ? (topItem.point.__y / bottomItem.point.__y) : null;
|
| 521 |
+
if (ratio && Number.isFinite(ratio)) {
|
| 522 |
+
const guideX = cardX + cardW / 2;
|
| 523 |
+
const guideTop = topItem.cardY + cardH + 7;
|
| 524 |
+
const guideBottom = bottomItem.cardY - 7;
|
| 525 |
+
const badgeR = 24;
|
| 526 |
+
const badgeY = (guideTop + guideBottom) / 2;
|
| 527 |
+
|
| 528 |
+
if (guideBottom - guideTop > badgeR * 2.4) {
|
| 529 |
+
svg.append("line")
|
| 530 |
+
.attr("x1", guideX)
|
| 531 |
+
.attr("x2", guideX)
|
| 532 |
+
.attr("y1", guideTop)
|
| 533 |
+
.attr("y2", guideBottom)
|
| 534 |
+
.attr("stroke", "#87909a")
|
| 535 |
+
.attr("stroke-width", 2)
|
| 536 |
+
.attr("stroke-dasharray", "5 5");
|
| 537 |
+
svg.append("path")
|
| 538 |
+
.attr("d", `M${guideX - 5},${guideTop + 7} L${guideX},${guideTop} L${guideX + 5},${guideTop + 7}`)
|
| 539 |
+
.attr("fill", "none")
|
| 540 |
+
.attr("stroke", "#87909a")
|
| 541 |
+
.attr("stroke-width", 2)
|
| 542 |
+
.attr("stroke-linecap", "round")
|
| 543 |
+
.attr("stroke-linejoin", "round");
|
| 544 |
+
svg.append("path")
|
| 545 |
+
.attr("d", `M${guideX - 5},${guideBottom - 7} L${guideX},${guideBottom} L${guideX + 5},${guideBottom - 7}`)
|
| 546 |
+
.attr("fill", "none")
|
| 547 |
+
.attr("stroke", "#87909a")
|
| 548 |
+
.attr("stroke-width", 2)
|
| 549 |
+
.attr("stroke-linecap", "round")
|
| 550 |
+
.attr("stroke-linejoin", "round");
|
| 551 |
+
svg.append("circle")
|
| 552 |
+
.attr("cx", guideX)
|
| 553 |
+
.attr("cy", badgeY)
|
| 554 |
+
.attr("r", badgeR)
|
| 555 |
+
.attr("fill", "#ffffff")
|
| 556 |
+
.attr("stroke", "#d8e0e5")
|
| 557 |
+
.attr("stroke-width", 2);
|
| 558 |
+
svg.append("text")
|
| 559 |
+
.attr("x", guideX)
|
| 560 |
+
.attr("y", badgeY - 2)
|
| 561 |
+
.attr("text-anchor", "middle")
|
| 562 |
+
.attr("fill", "#102238")
|
| 563 |
+
.style("font-family", "Arial, sans-serif")
|
| 564 |
+
.style("font-size", `${fontSize(20)}px`)
|
| 565 |
+
.style("font-weight", 900)
|
| 566 |
+
.text(ratio.toFixed(1));
|
| 567 |
+
svg.append("text")
|
| 568 |
+
.attr("x", guideX)
|
| 569 |
+
.attr("y", badgeY + 15)
|
| 570 |
+
.attr("text-anchor", "middle")
|
| 571 |
+
.attr("fill", "#102238")
|
| 572 |
+
.style("font-family", "Arial, sans-serif")
|
| 573 |
+
.style("font-size", `${fontSize(10)}px`)
|
| 574 |
+
.style("font-weight", 800)
|
| 575 |
+
.text("RATIO");
|
| 576 |
+
}
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
return svg.node();
|
| 580 |
+
}
|
modules/chart_engine/template/d3-js/type36_range_area_chart/range_area_plain_chart_01.js
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Range Area Chart",
|
| 5 |
+
"chart_name": "range_area_plain_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 clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
| 28 |
+
const estimateTextWidth = (text, size, weight = 500) => String(text).length * size * (weight >= 700 ? 0.62 : 0.56);
|
| 29 |
+
const boxesOverlap = (a, b, pad = 0) => !(
|
| 30 |
+
a.x + a.width + pad < b.x ||
|
| 31 |
+
b.x + b.width + pad < a.x ||
|
| 32 |
+
a.y + a.height + pad < b.y ||
|
| 33 |
+
b.y + b.height + pad < a.y
|
| 34 |
+
);
|
| 35 |
+
const formatValueLabel = (value, digits = 0, suffix = "") => {
|
| 36 |
+
const rounded = Number(value);
|
| 37 |
+
if (!Number.isFinite(rounded)) return "";
|
| 38 |
+
return `${chartUtils.format.autoText(+rounded.toFixed(digits))}${suffix}`;
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
const createXAxisScaleAndTicks = (data, xField, rangeStart = 0, rangeEnd = 100) => {
|
| 42 |
+
const dates = data.map(d => chartUtils.format.parseDate(d[xField]));
|
| 43 |
+
const xExtent = d3.extent(dates);
|
| 44 |
+
const xRange = xExtent[1] - xExtent[0];
|
| 45 |
+
const xPadding = xRange * 0.05;
|
| 46 |
+
|
| 47 |
+
const xScale = d3.scaleTime()
|
| 48 |
+
.domain([new Date(xExtent[0].getTime() - xPadding), new Date(xExtent[1].getTime() + xPadding)])
|
| 49 |
+
.range([rangeStart, rangeEnd]);
|
| 50 |
+
|
| 51 |
+
const timeSpan = xExtent[1] - xExtent[0];
|
| 52 |
+
const yearSpan = timeSpan / (1000 * 60 * 60 * 24 * 365);
|
| 53 |
+
|
| 54 |
+
let timeInterval, formatFunction;
|
| 55 |
+
if (yearSpan > 7) {
|
| 56 |
+
const maxTickCount = Math.max(5, Math.floor((rangeEnd - rangeStart) / 72));
|
| 57 |
+
const rawStep = yearSpan <= 16 ? 2 : Math.max(2, Math.ceil(yearSpan / maxTickCount));
|
| 58 |
+
const niceSteps = [2, 5, 10, 20, 25, 50, 100];
|
| 59 |
+
const step = niceSteps.find(candidate => candidate >= rawStep) || rawStep;
|
| 60 |
+
timeInterval = d3.timeYear.every(step);
|
| 61 |
+
formatFunction = d => chartUtils.format.date(d, { output: "%Y" }).text;
|
| 62 |
+
} else if (yearSpan > 2) {
|
| 63 |
+
timeInterval = d3.timeYear.every(1);
|
| 64 |
+
formatFunction = d => chartUtils.format.date(d, { output: "%Y" }).text;
|
| 65 |
+
} else {
|
| 66 |
+
timeInterval = d3.timeMonth.every(3);
|
| 67 |
+
formatFunction = d => chartUtils.format.date(d, { output: "%Y-%m" }).text;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
const xTicks = xScale.ticks(timeInterval);
|
| 71 |
+
if (xTicks.length > 0 && xTicks[xTicks.length - 1] < xExtent[1]) {
|
| 72 |
+
if (xTicks.length > 7) xTicks.pop();
|
| 73 |
+
xTicks.push(xExtent[1]);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
return { xScale, xTicks, xFormat: formatFunction };
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
// 提取数据
|
| 80 |
+
const jsonData = data;
|
| 81 |
+
const chartData = jsonData.data.data;
|
| 82 |
+
const variables = jsonData.variables;
|
| 83 |
+
const colors = jsonData.colors || {};
|
| 84 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 85 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 86 |
+
const fontSize = chartUtils.text.fontSize;
|
| 87 |
+
|
| 88 |
+
// 清空容器
|
| 89 |
+
d3.select(containerSelector).html("");
|
| 90 |
+
|
| 91 |
+
// 获取字段名
|
| 92 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 93 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 94 |
+
const yDescription = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField }).description;
|
| 95 |
+
const yUnit = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField }).unit || "";
|
| 96 |
+
const valueSuffix = yUnit === "%" ? "%" : (yUnit ? ` ${yUnit}` : "");
|
| 97 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 98 |
+
|
| 99 |
+
// 设置尺寸和边距
|
| 100 |
+
const width = variables.width;
|
| 101 |
+
const height = variables.height;
|
| 102 |
+
const margin = { top: 104, right: 70, bottom: 76, left: 76 };
|
| 103 |
+
|
| 104 |
+
// 创建SVG
|
| 105 |
+
const svg = d3.select(containerSelector)
|
| 106 |
+
.append("svg")
|
| 107 |
+
.attr("width", "100%")
|
| 108 |
+
.attr("height", height)
|
| 109 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 110 |
+
.attr("style", "max-width: 100%; height: auto;");
|
| 111 |
+
|
| 112 |
+
// 创建图表区域
|
| 113 |
+
const chartWidth = width - margin.left - margin.right;
|
| 114 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 115 |
+
|
| 116 |
+
const palette = {
|
| 117 |
+
upper: colorResolver.field(groupField, 0, { fallbackKey: "primary" }).value || "#0A7F97",
|
| 118 |
+
lower: colorResolver.field(groupField, 1, { fallbackKey: "secondary" }).value || "#5FA82D",
|
| 119 |
+
panel: "#F9FCFD",
|
| 120 |
+
panelStroke: "#D8E6EC",
|
| 121 |
+
grid: "#D2DEE3",
|
| 122 |
+
text: "#071833"
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
const defs = svg.append("defs");
|
| 126 |
+
const panelGradientId = `range-area-panel-${Math.random().toString(36).slice(2)}`;
|
| 127 |
+
defs.append("linearGradient")
|
| 128 |
+
.attr("id", panelGradientId)
|
| 129 |
+
.attr("x1", "0%")
|
| 130 |
+
.attr("y1", "0%")
|
| 131 |
+
.attr("x2", "0%")
|
| 132 |
+
.attr("y2", "100%")
|
| 133 |
+
.selectAll("stop")
|
| 134 |
+
.data([
|
| 135 |
+
{ offset: "0%", color: "#FFFFFF", opacity: 0.98 },
|
| 136 |
+
{ offset: "100%", color: "#EFF8FA", opacity: 0.98 }
|
| 137 |
+
])
|
| 138 |
+
.enter()
|
| 139 |
+
.append("stop")
|
| 140 |
+
.attr("offset", d => d.offset)
|
| 141 |
+
.attr("stop-color", d => d.color)
|
| 142 |
+
.attr("stop-opacity", d => d.opacity);
|
| 143 |
+
|
| 144 |
+
const areaGradientId = `range-area-fill-${Math.random().toString(36).slice(2)}`;
|
| 145 |
+
const areaGradient = defs.append("linearGradient")
|
| 146 |
+
.attr("id", areaGradientId)
|
| 147 |
+
.attr("x1", "0%")
|
| 148 |
+
.attr("y1", "0%")
|
| 149 |
+
.attr("x2", "0%")
|
| 150 |
+
.attr("y2", "100%");
|
| 151 |
+
areaGradient.append("stop").attr("offset", "0%").attr("stop-color", "#8AD4E5").attr("stop-opacity", 0.58);
|
| 152 |
+
areaGradient.append("stop").attr("offset", "100%").attr("stop-color", "#BEE7A5").attr("stop-opacity", 0.42);
|
| 153 |
+
|
| 154 |
+
const panelPad = 24;
|
| 155 |
+
const panelX = 18;
|
| 156 |
+
const panelY = 20;
|
| 157 |
+
const panelW = width - panelX * 2;
|
| 158 |
+
const panelH = height - panelY - 20;
|
| 159 |
+
svg.append("rect")
|
| 160 |
+
.attr("class", "range-area-chart-panel")
|
| 161 |
+
.attr("x", panelX)
|
| 162 |
+
.attr("y", panelY)
|
| 163 |
+
.attr("width", panelW)
|
| 164 |
+
.attr("height", panelH)
|
| 165 |
+
.attr("rx", 20)
|
| 166 |
+
.attr("fill", `url(#${panelGradientId})`)
|
| 167 |
+
.attr("stroke", palette.panelStroke)
|
| 168 |
+
.attr("stroke-width", 1.1)
|
| 169 |
+
.attr("filter", "drop-shadow(0 8px 14px rgba(22, 60, 75, 0.12))");
|
| 170 |
+
|
| 171 |
+
const g = svg.append("g")
|
| 172 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 173 |
+
|
| 174 |
+
// 分离两个组的数据
|
| 175 |
+
const groupNames = [...new Set(chartData.map(d => d[groupField]))];
|
| 176 |
+
|
| 177 |
+
// 计算每个组的平均值并排序
|
| 178 |
+
const groupAverages = groupNames.map(group => {
|
| 179 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 180 |
+
const avg = d3.mean(groupData, d => +d[yField]);
|
| 181 |
+
return { group, avg };
|
| 182 |
+
}).sort((a, b) => b.avg - a.avg);
|
| 183 |
+
|
| 184 |
+
// 选择平均值最高和最低的组
|
| 185 |
+
const highestGroup = groupAverages[0].group;
|
| 186 |
+
const lowestGroup = groupAverages[groupAverages.length - 1].group;
|
| 187 |
+
|
| 188 |
+
// 获取这两个组的数据并排序
|
| 189 |
+
const group1Data = chartData.filter(d => d[groupField] === highestGroup)
|
| 190 |
+
.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 191 |
+
const group2Data = chartData.filter(d => d[groupField] === lowestGroup)
|
| 192 |
+
.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 193 |
+
|
| 194 |
+
const selectedGroupNames = [highestGroup, lowestGroup];
|
| 195 |
+
palette.upper = colorResolver.field(selectedGroupNames[0], 0, { fallback: "#087F98" }).value || "#087F98";
|
| 196 |
+
palette.lower = colorResolver.field(selectedGroupNames[1], 1, { fallback: "#5C9F2D" }).value || "#5C9F2D";
|
| 197 |
+
|
| 198 |
+
const { xScale, xTicks, xFormat } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
|
| 199 |
+
|
| 200 |
+
// 创建y轴比例尺
|
| 201 |
+
const yMin = Math.max(0, d3.min(chartData, d => +d[yField]));
|
| 202 |
+
const yMax = d3.max(chartData, d => +d[yField]);
|
| 203 |
+
const yPadding = 0.3 * (yMax - yMin);
|
| 204 |
+
|
| 205 |
+
const yScale = d3.scaleLinear()
|
| 206 |
+
.domain([yMin - yPadding, yMax + yPadding])
|
| 207 |
+
.range([chartHeight, 0]);
|
| 208 |
+
|
| 209 |
+
// 添加水平网格线和y轴刻度
|
| 210 |
+
const yTicks = d3.ticks(yMin - yPadding, yMax + yPadding, 5);
|
| 211 |
+
yTicks.forEach(tick => {
|
| 212 |
+
if (tick > 0) {
|
| 213 |
+
g.append("line")
|
| 214 |
+
.attr("class", "gridline")
|
| 215 |
+
.attr("x1", 0)
|
| 216 |
+
.attr("y1", yScale(tick))
|
| 217 |
+
.attr("x2", chartWidth)
|
| 218 |
+
.attr("y2", yScale(tick))
|
| 219 |
+
.attr("stroke", palette.grid)
|
| 220 |
+
.attr("stroke-dasharray", "3 4")
|
| 221 |
+
.attr("stroke-width", 1);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
g.append("text")
|
| 225 |
+
.attr("class", "value")
|
| 226 |
+
.attr("x", -10)
|
| 227 |
+
.attr("y", yScale(tick))
|
| 228 |
+
.attr("text-anchor", "end")
|
| 229 |
+
.attr("dominant-baseline", "middle")
|
| 230 |
+
.style("font-family", "Arial, Helvetica, sans-serif")
|
| 231 |
+
.style("font-size", `${fontSize(12)}px`)
|
| 232 |
+
.style("fill", "#14233B")
|
| 233 |
+
.text(chartUtils.format.autoText(+(tick)));
|
| 234 |
+
});
|
| 235 |
+
|
| 236 |
+
// 添加x轴刻度标签
|
| 237 |
+
xTicks.forEach(tick => {
|
| 238 |
+
g.append("text")
|
| 239 |
+
.attr("class", "value")
|
| 240 |
+
.attr("x", xScale(tick))
|
| 241 |
+
.attr("y", chartHeight + 15)
|
| 242 |
+
.attr("text-anchor", "middle")
|
| 243 |
+
.style("font-family", "Arial, Helvetica, sans-serif")
|
| 244 |
+
.style("font-size", `${fontSize(12)}px`)
|
| 245 |
+
.style("fill", "#14233B")
|
| 246 |
+
.text(xFormat(tick));
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
+
// 添加坐标轴
|
| 250 |
+
g.append("line")
|
| 251 |
+
.attr("class", "axis")
|
| 252 |
+
.attr("x1", 0)
|
| 253 |
+
.attr("y1", chartHeight)
|
| 254 |
+
.attr("x2", chartWidth)
|
| 255 |
+
.attr("y2", chartHeight)
|
| 256 |
+
.attr("stroke", "#60727D")
|
| 257 |
+
.attr("stroke-width", 1.2);
|
| 258 |
+
|
| 259 |
+
g.append("line")
|
| 260 |
+
.attr("class", "axis")
|
| 261 |
+
.attr("x1", 0)
|
| 262 |
+
.attr("y1", yScale(d3.max(yTicks)))
|
| 263 |
+
.attr("x2", 0)
|
| 264 |
+
.attr("y2", chartHeight)
|
| 265 |
+
.attr("stroke", "#60727D")
|
| 266 |
+
.attr("stroke-width", 1.2);
|
| 267 |
+
|
| 268 |
+
// 添加y轴标题
|
| 269 |
+
g.append("text")
|
| 270 |
+
.attr("class", "text")
|
| 271 |
+
.attr("x", 0)
|
| 272 |
+
.attr("y", -58)
|
| 273 |
+
.attr("text-anchor", "start")
|
| 274 |
+
.style("font-family", "Arial, Helvetica, sans-serif")
|
| 275 |
+
.style("font-size", `${fontSize(15)}px`)
|
| 276 |
+
.style("font-weight", "800")
|
| 277 |
+
.style("fill", palette.text)
|
| 278 |
+
.text(`${yDescription} ${d3.min(chartData, d => chartUtils.format.parseDate(d[xField]).getFullYear())}-${d3.max(chartData, d => chartUtils.format.parseDate(d[xField]).getFullYear())}`);
|
| 279 |
+
|
| 280 |
+
g.append("text")
|
| 281 |
+
.attr("class", "text")
|
| 282 |
+
.attr("x", 0)
|
| 283 |
+
.attr("y", -30)
|
| 284 |
+
.attr("text-anchor", "start")
|
| 285 |
+
.style("font-family", "Arial, Helvetica, sans-serif")
|
| 286 |
+
.style("font-size", `${fontSize(12)}px`)
|
| 287 |
+
.style("font-weight", "600")
|
| 288 |
+
.style("fill", "#14233B")
|
| 289 |
+
.text(yUnit || yField);
|
| 290 |
+
|
| 291 |
+
const legendFontSize = fontSize(12);
|
| 292 |
+
const legendItems = selectedGroupNames.map((group, idx) => ({
|
| 293 |
+
group,
|
| 294 |
+
color: idx === 0 ? palette.upper : palette.lower,
|
| 295 |
+
width: 36 + estimateTextWidth(group, legendFontSize, 500) + 22
|
| 296 |
+
}));
|
| 297 |
+
const legendTotalWidth = d3.sum(legendItems, d => d.width);
|
| 298 |
+
const legend = g.append("g")
|
| 299 |
+
.attr("class", "legend")
|
| 300 |
+
.attr("transform", `translate(${clamp(chartWidth - legendTotalWidth, 0, chartWidth)}, ${-22})`);
|
| 301 |
+
let legendCursor = 0;
|
| 302 |
+
legendItems.forEach((item) => {
|
| 303 |
+
const itemX = legendCursor;
|
| 304 |
+
const group = item.group;
|
| 305 |
+
const color = item.color;
|
| 306 |
+
legend.append("line")
|
| 307 |
+
.attr("x1", itemX)
|
| 308 |
+
.attr("x2", itemX + 28)
|
| 309 |
+
.attr("y1", 7)
|
| 310 |
+
.attr("y2", 7)
|
| 311 |
+
.attr("stroke", color)
|
| 312 |
+
.attr("stroke-width", 3)
|
| 313 |
+
.attr("stroke-linecap", "round");
|
| 314 |
+
legend.append("circle")
|
| 315 |
+
.attr("cx", itemX + 14)
|
| 316 |
+
.attr("cy", 7)
|
| 317 |
+
.attr("r", 5)
|
| 318 |
+
.attr("fill", color)
|
| 319 |
+
.attr("stroke", "#FFFFFF")
|
| 320 |
+
.attr("stroke-width", 1.6);
|
| 321 |
+
legend.append("text")
|
| 322 |
+
.attr("x", itemX + 36)
|
| 323 |
+
.attr("y", 11)
|
| 324 |
+
.style("font-family", "Arial, Helvetica, sans-serif")
|
| 325 |
+
.style("font-size", `${legendFontSize}px`)
|
| 326 |
+
.style("fill", "#14233B")
|
| 327 |
+
.text(group);
|
| 328 |
+
legendCursor += item.width;
|
| 329 |
+
});
|
| 330 |
+
|
| 331 |
+
// 创建线条和面积生成器
|
| 332 |
+
const line = d3.line()
|
| 333 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 334 |
+
.y(d => yScale(+d[yField]));
|
| 335 |
+
|
| 336 |
+
const area = d3.area()
|
| 337 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 338 |
+
.y0(d => yScale(d.group2Value))
|
| 339 |
+
.y1(d => yScale(d.group1Value));
|
| 340 |
+
|
| 341 |
+
// 创建合并的数据集用于面积图
|
| 342 |
+
const areaData = [];
|
| 343 |
+
|
| 344 |
+
// 找到共同的时间点
|
| 345 |
+
const commonDates = group1Data.map(d => d[xField]).filter(date =>
|
| 346 |
+
group2Data.some(d2 => d2[xField] === date)
|
| 347 |
+
);
|
| 348 |
+
|
| 349 |
+
// 按时间排序构建面积数据
|
| 350 |
+
commonDates.sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b)).forEach(date => {
|
| 351 |
+
const group1Item = group1Data.find(d => d[xField] === date);
|
| 352 |
+
const group2Item = group2Data.find(d => d[xField] === date);
|
| 353 |
+
|
| 354 |
+
if (group1Item && group2Item) {
|
| 355 |
+
areaData.push({
|
| 356 |
+
[xField]: date,
|
| 357 |
+
group1Value: group1Item[yField],
|
| 358 |
+
group2Value: group2Item[yField]
|
| 359 |
+
});
|
| 360 |
+
}
|
| 361 |
+
});
|
| 362 |
+
|
| 363 |
+
// 绘制面积
|
| 364 |
+
g.append("path")
|
| 365 |
+
.attr("class", "mark")
|
| 366 |
+
.datum(areaData)
|
| 367 |
+
.attr("fill", `url(#${areaGradientId})`)
|
| 368 |
+
.attr("opacity", 0.95)
|
| 369 |
+
.attr("d", area);
|
| 370 |
+
|
| 371 |
+
// 绘制线条1(高值组)
|
| 372 |
+
g.append("path")
|
| 373 |
+
.attr("class", "mark")
|
| 374 |
+
.datum(group1Data)
|
| 375 |
+
.attr("fill", "none")
|
| 376 |
+
.attr("stroke", palette.upper)
|
| 377 |
+
.attr("stroke-width", 4)
|
| 378 |
+
.attr("stroke-linejoin", "round")
|
| 379 |
+
.attr("stroke-linecap", "round")
|
| 380 |
+
.attr("d", line);
|
| 381 |
+
|
| 382 |
+
// 绘制线条2(低值组)
|
| 383 |
+
g.append("path")
|
| 384 |
+
.attr("class", "mark")
|
| 385 |
+
.datum(group2Data)
|
| 386 |
+
.attr("fill", "none")
|
| 387 |
+
.attr("stroke", palette.lower)
|
| 388 |
+
.attr("stroke-width", 4)
|
| 389 |
+
.attr("stroke-linejoin", "round")
|
| 390 |
+
.attr("stroke-linecap", "round")
|
| 391 |
+
.attr("d", line);
|
| 392 |
+
|
| 393 |
+
const drawMarkers = (series, color) => {
|
| 394 |
+
g.selectAll(`.range-marker-${color.replace(/[^a-zA-Z0-9]/g, "")}`)
|
| 395 |
+
.data(series)
|
| 396 |
+
.enter()
|
| 397 |
+
.append("circle")
|
| 398 |
+
.attr("class", "mark range-area-point")
|
| 399 |
+
.attr("cx", d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 400 |
+
.attr("cy", d => yScale(+d[yField]))
|
| 401 |
+
.attr("r", 4.9)
|
| 402 |
+
.attr("fill", color)
|
| 403 |
+
.attr("stroke", "#FFFFFF")
|
| 404 |
+
.attr("stroke-width", 2);
|
| 405 |
+
};
|
| 406 |
+
drawMarkers(group1Data, palette.upper);
|
| 407 |
+
drawMarkers(group2Data, palette.lower);
|
| 408 |
+
|
| 409 |
+
// 添加组标签到线的左侧
|
| 410 |
+
const firstPoint1 = group1Data[0];
|
| 411 |
+
const firstPoint2 = group2Data[0];
|
| 412 |
+
const lastPoint1 = group1Data[group1Data.length - 1];
|
| 413 |
+
const lastPoint2 = group2Data[group2Data.length - 1];
|
| 414 |
+
|
| 415 |
+
const labelBoxes = [];
|
| 416 |
+
const endpointDigits = point => yUnit === "%" ? 0 : (+point[yField] < 10 ? 1 : 0);
|
| 417 |
+
const endpointDisplayedValue = point => +(+point[yField]).toFixed(endpointDigits(point));
|
| 418 |
+
const drawValuePill = (point, color, side, preferredDy = 0) => {
|
| 419 |
+
const digits = endpointDigits(point);
|
| 420 |
+
const text = formatValueLabel(+point[yField], digits, valueSuffix);
|
| 421 |
+
const fs = fontSize(14);
|
| 422 |
+
const padX = 8;
|
| 423 |
+
const pillW = Math.max(38, estimateTextWidth(text, fs, 800) + padX * 2);
|
| 424 |
+
const pillH = 25;
|
| 425 |
+
const pointX = xScale(chartUtils.format.parseDate(point[xField]));
|
| 426 |
+
const pointY = yScale(+point[yField]);
|
| 427 |
+
let x = side === "left" ? pointX - pillW - 8 : pointX + 8;
|
| 428 |
+
let y = pointY - pillH / 2 + preferredDy;
|
| 429 |
+
x = clamp(x, 2, chartWidth - pillW - 2);
|
| 430 |
+
y = clamp(y, -8, chartHeight - pillH - 2);
|
| 431 |
+
const box = { x, y, width: pillW, height: pillH };
|
| 432 |
+
for (let i = 0; i < labelBoxes.length; i++) {
|
| 433 |
+
if (boxesOverlap(box, labelBoxes[i], 4)) {
|
| 434 |
+
y = clamp(y + (preferredDy >= 0 ? pillH + 4 : -pillH - 4), -8, chartHeight - pillH - 2);
|
| 435 |
+
box.y = y;
|
| 436 |
+
}
|
| 437 |
+
}
|
| 438 |
+
labelBoxes.push(box);
|
| 439 |
+
const pill = g.append("g")
|
| 440 |
+
.attr("class", "range-area-endpoint-pill")
|
| 441 |
+
.attr("transform", `translate(${x},${y})`)
|
| 442 |
+
.attr("data-anchor", `${side}-endpoint-point`)
|
| 443 |
+
.attr("data-collision-rule", "clamped-to-panel-and-staggered-from-neighbor-endpoint-pills");
|
| 444 |
+
pill.append("rect")
|
| 445 |
+
.attr("width", pillW)
|
| 446 |
+
.attr("height", pillH)
|
| 447 |
+
.attr("rx", 7)
|
| 448 |
+
.attr("fill", color)
|
| 449 |
+
.attr("stroke", "#FFFFFF")
|
| 450 |
+
.attr("stroke-width", 1.4);
|
| 451 |
+
pill.append("text")
|
| 452 |
+
.attr("x", pillW / 2)
|
| 453 |
+
.attr("y", pillH / 2 + 5)
|
| 454 |
+
.attr("text-anchor", "middle")
|
| 455 |
+
.style("font-family", "Arial, Helvetica, sans-serif")
|
| 456 |
+
.style("font-size", `${fs}px`)
|
| 457 |
+
.style("font-weight", "800")
|
| 458 |
+
.style("fill", "#FFFFFF")
|
| 459 |
+
.text(text);
|
| 460 |
+
};
|
| 461 |
+
drawValuePill(firstPoint1, palette.upper, "left", -10);
|
| 462 |
+
drawValuePill(firstPoint2, palette.lower, "left", 12);
|
| 463 |
+
drawValuePill(lastPoint1, palette.upper, "right", -6);
|
| 464 |
+
drawValuePill(lastPoint2, palette.lower, "right", 8);
|
| 465 |
+
|
| 466 |
+
const lineBoxes = areaData.flatMap(d => {
|
| 467 |
+
const x = xScale(chartUtils.format.parseDate(d[xField]));
|
| 468 |
+
return [
|
| 469 |
+
{ x: x - 9, y: yScale(+d.group1Value) - 9, width: 18, height: 18 },
|
| 470 |
+
{ x: x - 9, y: yScale(+d.group2Value) - 9, width: 18, height: 18 }
|
| 471 |
+
];
|
| 472 |
+
});
|
| 473 |
+
const rawFinalGap = Math.abs(+lastPoint1[yField] - +lastPoint2[yField]);
|
| 474 |
+
const finalGap = yUnit === "%"
|
| 475 |
+
? Math.abs(endpointDisplayedValue(lastPoint1) - endpointDisplayedValue(lastPoint2))
|
| 476 |
+
: rawFinalGap;
|
| 477 |
+
const gapDigits = yUnit === "%" ? (finalGap >= 10 ? 0 : 1) : (finalGap < 10 ? 1 : 0);
|
| 478 |
+
const gapUnit = yUnit === "%" ? "percentage" : (yUnit || "units");
|
| 479 |
+
const higherText = yUnit === "%" ? "points higher" : "higher";
|
| 480 |
+
const gapText = `${chartUtils.format.autoText(+finalGap.toFixed(gapDigits))} ${gapUnit}`;
|
| 481 |
+
const annotationCandidates = [
|
| 482 |
+
{ x: chartWidth - 186, y: chartHeight - 136 },
|
| 483 |
+
{ x: chartWidth - 186, y: 76 },
|
| 484 |
+
{ x: 96, y: chartHeight - 136 },
|
| 485 |
+
{ x: 96, y: 76 }
|
| 486 |
+
];
|
| 487 |
+
const annotationBox = { width: 164, height: 92 };
|
| 488 |
+
const occupied = labelBoxes.concat(lineBoxes, [
|
| 489 |
+
{ x: chartWidth - 210, y: -70, width: 210, height: 34 },
|
| 490 |
+
{ x: 0, y: -72, width: 260, height: 52 }
|
| 491 |
+
]);
|
| 492 |
+
const candidate = annotationCandidates.find(c => {
|
| 493 |
+
const box = { x: c.x, y: c.y, width: annotationBox.width, height: annotationBox.height };
|
| 494 |
+
return !occupied.some(o => boxesOverlap(box, o, 8));
|
| 495 |
+
});
|
| 496 |
+
if (candidate) {
|
| 497 |
+
const card = g.append("g")
|
| 498 |
+
.attr("class", "range-area-gap-annotation")
|
| 499 |
+
.attr("data-annotation", "final-year-gap")
|
| 500 |
+
.attr("data-anchor", "collision-aware-plot-relative-safe-area")
|
| 501 |
+
.attr("data-collision-rule", "tries lower-right upper-right lower-left upper-left; avoids lines markers endpoint labels legend and chart title")
|
| 502 |
+
.attr("transform", `translate(${candidate.x},${candidate.y})`);
|
| 503 |
+
card.append("rect")
|
| 504 |
+
.attr("width", annotationBox.width)
|
| 505 |
+
.attr("height", annotationBox.height)
|
| 506 |
+
.attr("rx", 14)
|
| 507 |
+
.attr("fill", "#FFFFFF")
|
| 508 |
+
.attr("stroke", "#C9DCE4")
|
| 509 |
+
.attr("stroke-width", 1.1)
|
| 510 |
+
.attr("filter", "drop-shadow(0 5px 10px rgba(16, 52, 70, 0.14))");
|
| 511 |
+
const iconSlot = card.append("g")
|
| 512 |
+
.attr("class", "reserved-asset-slot range-area-gap-icon-slot")
|
| 513 |
+
.attr("data-asset-slot", "range-area-gap-annotation-icon")
|
| 514 |
+
.attr("data-slot-policy", "neutral_placeholder_or_image2_asset")
|
| 515 |
+
.attr("data-asset-source-policy", "neutral_placeholder")
|
| 516 |
+
.attr("data-anchor", "annotation-card-left")
|
| 517 |
+
.attr("data-bbox", "14,18,42,42")
|
| 518 |
+
.attr("data-collision-rule", "must remain inside annotation card and avoid annotation text")
|
| 519 |
+
.attr("data-no-embedded-raster", "true")
|
| 520 |
+
.attr("data-no-copied-reference-art", "true")
|
| 521 |
+
.attr("transform", "translate(15,20)");
|
| 522 |
+
iconSlot.append("circle")
|
| 523 |
+
.attr("cx", 21)
|
| 524 |
+
.attr("cy", 21)
|
| 525 |
+
.attr("r", 20)
|
| 526 |
+
.attr("fill", "#0B85A0")
|
| 527 |
+
.attr("opacity", 0.92);
|
| 528 |
+
iconSlot.append("circle")
|
| 529 |
+
.attr("cx", 21)
|
| 530 |
+
.attr("cy", 21)
|
| 531 |
+
.attr("r", 9)
|
| 532 |
+
.attr("fill", "none")
|
| 533 |
+
.attr("stroke", "#FFFFFF")
|
| 534 |
+
.attr("stroke-width", 2)
|
| 535 |
+
.attr("stroke-dasharray", "4 3")
|
| 536 |
+
.attr("opacity", 0.8);
|
| 537 |
+
card.append("text")
|
| 538 |
+
.attr("x", 70)
|
| 539 |
+
.attr("y", 30)
|
| 540 |
+
.style("font-family", "Arial, Helvetica, sans-serif")
|
| 541 |
+
.style("font-size", `${fontSize(11)}px`)
|
| 542 |
+
.style("fill", "#263448")
|
| 543 |
+
.text(`In ${chartUtils.format.parseDate(lastPoint1[xField]).getFullYear()},`);
|
| 544 |
+
card.append("text")
|
| 545 |
+
.attr("x", 70)
|
| 546 |
+
.attr("y", 51)
|
| 547 |
+
.style("font-family", "Arial, Helvetica, sans-serif")
|
| 548 |
+
.style("font-size", `${fontSize(15)}px`)
|
| 549 |
+
.style("font-weight", "800")
|
| 550 |
+
.style("fill", palette.upper)
|
| 551 |
+
.text(gapText);
|
| 552 |
+
card.append("text")
|
| 553 |
+
.attr("x", 70)
|
| 554 |
+
.attr("y", 70)
|
| 555 |
+
.style("font-family", "Arial, Helvetica, sans-serif")
|
| 556 |
+
.style("font-size", `${fontSize(11)}px`)
|
| 557 |
+
.style("fill", "#263448")
|
| 558 |
+
.text(higherText);
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
return svg.node();
|
| 564 |
+
}
|