Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
modules/chart_engine/template/d3-js/type29_small_multiples_line_graphs/small_multiples_line_graphs_plain_chart_01.js
ADDED
|
@@ -0,0 +1,1017 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "small multiples of line graphs",
|
| 5 |
+
"chart_name": "small_multiples_line_graphs_plain_chart_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y", "group"],
|
| 8 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 9 |
+
"required_fields_range": [[5, 30], ["-inf", "inf"], [2, 8]],
|
| 10 |
+
"required_fields_icons": [],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": ["group"],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": ["stroke", "spacing"],
|
| 15 |
+
"min_height": 1000,
|
| 16 |
+
"min_width": 760,
|
| 17 |
+
"has_title": 1,
|
| 18 |
+
"background": "light",
|
| 19 |
+
"icon_mark": "none",
|
| 20 |
+
"icon_label": "none",
|
| 21 |
+
"has_x_axis": "yes",
|
| 22 |
+
"has_y_axis": "yes"
|
| 23 |
+
}
|
| 24 |
+
REQUIREMENTS_END
|
| 25 |
+
*/
|
| 26 |
+
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
const jsonData = data || {};
|
| 29 |
+
const chartData = (jsonData.data && jsonData.data.data) || [];
|
| 30 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 31 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 32 |
+
const chartUtilsFormatSample = chartUtils.format.autoText;
|
| 33 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 34 |
+
const chartUtilsStandard = {
|
| 35 |
+
schema: chartUtils.schema,
|
| 36 |
+
format: chartUtils.format,
|
| 37 |
+
text: chartUtils.text,
|
| 38 |
+
color: chartUtils.color,
|
| 39 |
+
legendLayout: chartUtils.legend.layout,
|
| 40 |
+
legendDraw: chartUtils.legend.draw,
|
| 41 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 42 |
+
};
|
| 43 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 44 |
+
x: { fallbackIndex: 0 },
|
| 45 |
+
y: { fallbackIndex: 1 },
|
| 46 |
+
y2: { fallbackIndex: 2 },
|
| 47 |
+
y3: { fallbackIndex: 3 },
|
| 48 |
+
size: { fallbackIndex: 2 },
|
| 49 |
+
group: { fallbackIndex: 2 },
|
| 50 |
+
group2: { fallbackIndex: 3 },
|
| 51 |
+
group3: { fallbackIndex: 4 }
|
| 52 |
+
});
|
| 53 |
+
const variables = jsonData.variables || {};
|
| 54 |
+
const typography = jsonData.typography || {
|
| 55 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 56 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 57 |
+
title: { font_family: "Georgia", font_size: "16px", font_weight: "bold" }
|
| 58 |
+
};
|
| 59 |
+
const sourceColors = jsonData.colors || {};
|
| 60 |
+
const colorField = sourceColors.field || {};
|
| 61 |
+
const colorOther = sourceColors.other || {};
|
| 62 |
+
|
| 63 |
+
d3.select(containerSelector).html("");
|
| 64 |
+
|
| 65 |
+
const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
|
| 66 |
+
const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
|
| 67 |
+
const groupColumn = chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || {};
|
| 68 |
+
const xField = xColumn.name || "date";
|
| 69 |
+
const yField = yColumn.name || "value";
|
| 70 |
+
const groupField = groupColumn.name || "group";
|
| 71 |
+
const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
|
| 72 |
+
|
| 73 |
+
const width = Math.max(760, Number(variables.width) || 760);
|
| 74 |
+
const navy = "#111a42";
|
| 75 |
+
const accentBlue = colorOther.primary || "#1287c9";
|
| 76 |
+
const mutedText = "#4a5568";
|
| 77 |
+
const axisText = "#26334f";
|
| 78 |
+
const canvasColor = "#f7fbff";
|
| 79 |
+
const cardFill = "#ffffff";
|
| 80 |
+
const linePalette = ["#1287c9", "#d79b00", "#7444c6", "#329546", "#e65a45", "#2763c9", "#089a9c", "#af5b28"];
|
| 81 |
+
const headerPalette = ["#eaf7ff", "#fff7dc", "#f4ecff", "#eef8ee", "#fff0ec", "#edf2ff", "#e9faf8", "#fff3e8"];
|
| 82 |
+
const labelTypography = typography.label || {};
|
| 83 |
+
const titleTypography = typography.title || labelTypography;
|
| 84 |
+
const valueTypography = typography.annotation || labelTypography;
|
| 85 |
+
const labelFamily = labelTypography.font_family || "Arial";
|
| 86 |
+
const titleFamily = titleTypography.font_family || "Georgia";
|
| 87 |
+
const valueFamily = valueTypography.font_family || labelFamily;
|
| 88 |
+
const labelWeight = labelTypography.font_weight || "normal";
|
| 89 |
+
const titleWeight = titleTypography.font_weight || "700";
|
| 90 |
+
const valueWeight = valueTypography.font_weight || "600";
|
| 91 |
+
const baseLabelSize = parseFloat(labelTypography.font_size) || 11;
|
| 92 |
+
const baseValueSize = parseFloat(valueTypography.font_size) || 11;
|
| 93 |
+
|
| 94 |
+
function rgba(color, opacity) {
|
| 95 |
+
const c = d3.rgb(color);
|
| 96 |
+
return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function readableName(value) {
|
| 100 |
+
return String(value || "")
|
| 101 |
+
.replace(/_/g, " ")
|
| 102 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 103 |
+
.replace(/\s+/g, " ")
|
| 104 |
+
.trim();
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function cleanText(value) {
|
| 108 |
+
return String(value || "")
|
| 109 |
+
.replace(/\*\*/g, "")
|
| 110 |
+
.replace(/\s+/g, " ")
|
| 111 |
+
.trim();
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function parseTemporalDate(value) {
|
| 115 |
+
if (value instanceof Date && Number.isFinite(value.getTime())) return value;
|
| 116 |
+
const text = String(value ?? "").trim();
|
| 117 |
+
if (!text) return null;
|
| 118 |
+
|
| 119 |
+
let match = text.match(/^(-?\d{4})$/);
|
| 120 |
+
if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
|
| 121 |
+
|
| 122 |
+
match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
|
| 123 |
+
if (match) return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
|
| 124 |
+
|
| 125 |
+
match = text.match(/^Q([1-4])\s+(-?\d{4})$/i) || text.match(/^(-?\d{4})\s+Q([1-4])$/i);
|
| 126 |
+
if (match) {
|
| 127 |
+
const year = match[1].length === 4 ? Number(match[1]) : Number(match[2]);
|
| 128 |
+
const quarter = match[1].length === 4 ? Number(match[2]) : Number(match[1]);
|
| 129 |
+
return new Date(Date.UTC(year, (quarter - 1) * 3, 1));
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
const season = text.match(/^(spring|summer|fall|autumn|winter)\s+(-?\d{4})$/i);
|
| 133 |
+
if (season) {
|
| 134 |
+
const monthBySeason = { spring: 2, summer: 5, fall: 8, autumn: 8, winter: 11 };
|
| 135 |
+
return new Date(Date.UTC(Number(season[2]), monthBySeason[season[1].toLowerCase()], 1));
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
const parsed = new Date(text);
|
| 139 |
+
if (Number.isFinite(parsed.getTime())) {
|
| 140 |
+
return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()));
|
| 141 |
+
}
|
| 142 |
+
return null;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
function formatNumber(value) {
|
| 146 |
+
const numericValue = Number(value);
|
| 147 |
+
if (!Number.isFinite(numericValue)) return String(value ?? "");
|
| 148 |
+
const absValue = Math.abs(numericValue);
|
| 149 |
+
if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
|
| 150 |
+
if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
|
| 151 |
+
if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}k`;
|
| 152 |
+
return d3.format(",.4~g")(numericValue);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
function formatWithUnit(value, unit) {
|
| 156 |
+
const formatted = formatNumber(value);
|
| 157 |
+
if (!unit) return formatted;
|
| 158 |
+
if (unit === "$") return `$${formatted}`;
|
| 159 |
+
if (unit === "%") return `${formatted}%`;
|
| 160 |
+
return `${formatted} ${unit}`;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function wrapText(text, maxChars, maxLines) {
|
| 164 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 165 |
+
if (!words.length) return [String(text || "")];
|
| 166 |
+
const lines = [];
|
| 167 |
+
let current = "";
|
| 168 |
+
words.forEach(word => {
|
| 169 |
+
if (lines.length >= maxLines) return;
|
| 170 |
+
const next = current ? `${current} ${word}` : word;
|
| 171 |
+
if (next.length <= maxChars) {
|
| 172 |
+
current = next;
|
| 173 |
+
return;
|
| 174 |
+
}
|
| 175 |
+
if (current) lines.push(current);
|
| 176 |
+
if (lines.length >= maxLines) return;
|
| 177 |
+
current = word;
|
| 178 |
+
});
|
| 179 |
+
if (current && lines.length < maxLines) lines.push(current);
|
| 180 |
+
return lines.length ? lines : [String(text || "")];
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function selectEvenTicks(values, maxTicks) {
|
| 184 |
+
if (values.length <= maxTicks) return values;
|
| 185 |
+
const selected = [];
|
| 186 |
+
for (let i = 0; i < maxTicks; i += 1) {
|
| 187 |
+
const index = Math.round(i * (values.length - 1) / (maxTicks - 1));
|
| 188 |
+
const value = values[index];
|
| 189 |
+
if (!selected.includes(value)) selected.push(value);
|
| 190 |
+
}
|
| 191 |
+
return selected;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
function splitTitle(title, maxChars) {
|
| 195 |
+
const clean = cleanText(title);
|
| 196 |
+
const words = clean.split(/\s+/).filter(Boolean);
|
| 197 |
+
if (!words.length) return ["", ""];
|
| 198 |
+
if (clean.length <= maxChars || words.length === 1) return [clean, ""];
|
| 199 |
+
|
| 200 |
+
const prepositions = new Set(["per", "in", "with", "of", "by", "from", "across", "for", "after", "before"]);
|
| 201 |
+
let semanticSplit = -1;
|
| 202 |
+
let bestDistance = Infinity;
|
| 203 |
+
const midpoint = clean.length / 2;
|
| 204 |
+
for (let i = 2; i < words.length - 1; i += 1) {
|
| 205 |
+
const first = words.slice(0, i).join(" ");
|
| 206 |
+
const secondFirst = words[i].toLowerCase();
|
| 207 |
+
const second = words.slice(i).join(" ");
|
| 208 |
+
if (!prepositions.has(secondFirst) || second.length < 9 || first.length > maxChars + 8) continue;
|
| 209 |
+
const distance = Math.abs(first.length - midpoint);
|
| 210 |
+
if (distance < bestDistance) {
|
| 211 |
+
semanticSplit = i;
|
| 212 |
+
bestDistance = distance;
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
if (semanticSplit > 0) return [words.slice(0, semanticSplit).join(" "), words.slice(semanticSplit).join(" ")];
|
| 216 |
+
|
| 217 |
+
let best = null;
|
| 218 |
+
let bestScore = Infinity;
|
| 219 |
+
for (let i = 1; i < words.length; i += 1) {
|
| 220 |
+
const first = words.slice(0, i).join(" ");
|
| 221 |
+
const second = words.slice(i).join(" ");
|
| 222 |
+
const longest = Math.max(first.length, second.length);
|
| 223 |
+
const overrun = Math.max(0, longest - maxChars);
|
| 224 |
+
const balance = Math.abs(first.length - second.length);
|
| 225 |
+
const tinySecondLinePenalty = second.length < 9 ? 24 : 0;
|
| 226 |
+
const score = balance + overrun * 5 + tinySecondLinePenalty;
|
| 227 |
+
if (score < bestScore) {
|
| 228 |
+
best = [first, second];
|
| 229 |
+
bestScore = score;
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
return best || [clean, ""];
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
function groupInitial(group) {
|
| 236 |
+
const words = String(group || "").trim().split(/\s+/).filter(Boolean);
|
| 237 |
+
if (!words.length) return "";
|
| 238 |
+
if (words.length === 1) return words[0].slice(0, 1).toUpperCase();
|
| 239 |
+
return words.slice(0, 2).map(word => word.slice(0, 1).toUpperCase()).join("");
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
function fitFont(text, desiredSize, maxWidth, minSize) {
|
| 243 |
+
const estimated = String(text || "").length * desiredSize * 0.56;
|
| 244 |
+
if (estimated <= maxWidth) return desiredSize;
|
| 245 |
+
return Math.max(minSize, Math.floor(desiredSize * maxWidth / Math.max(1, estimated)));
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
function semanticKey(group) {
|
| 249 |
+
const text = String(group || "").toLowerCase();
|
| 250 |
+
if (/\bcar\b|auto|vehicle|driving/.test(text)) return "car";
|
| 251 |
+
if (/transit|bus|rail|train|metro/.test(text)) return "transit";
|
| 252 |
+
if (/bicycle|bike|cycling/.test(text)) return "bicycle";
|
| 253 |
+
if (/walk|foot|pedestrian/.test(text)) return "walking";
|
| 254 |
+
return "";
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
function semanticColor(group, index) {
|
| 258 |
+
const key = semanticKey(group);
|
| 259 |
+
const semantic = {
|
| 260 |
+
car: "#1086c8",
|
| 261 |
+
transit: "#d99a00",
|
| 262 |
+
bicycle: "#7742c2",
|
| 263 |
+
walking: "#3c9a43"
|
| 264 |
+
};
|
| 265 |
+
if (semantic[key]) return semantic[key];
|
| 266 |
+
if (colorField[group]) return colorField[group];
|
| 267 |
+
return linePalette[index % linePalette.length];
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
function drawTransportIcon(parent, kind, color, size, initial) {
|
| 271 |
+
const s = size / 40;
|
| 272 |
+
const g = parent.append("g")
|
| 273 |
+
.attr("class", `reference-icon-mark reference-icon-${kind || "generic"}`)
|
| 274 |
+
.attr("transform", `scale(${s})`);
|
| 275 |
+
|
| 276 |
+
if (kind === "car") {
|
| 277 |
+
g.append("path")
|
| 278 |
+
.attr("d", "M -14 5 L -10 -5 C -9 -8 -6 -10 -2 -10 L 6 -10 C 9 -10 12 -8 13 -5 L 16 5 Z")
|
| 279 |
+
.attr("fill", "none").attr("stroke", color).attr("stroke-width", 3).attr("stroke-linejoin", "round");
|
| 280 |
+
g.append("line").attr("x1", -7).attr("x2", 9).attr("y1", -2).attr("y2", -2).attr("stroke", color).attr("stroke-width", 3).attr("stroke-linecap", "round");
|
| 281 |
+
[-9, 10].forEach(x => g.append("circle").attr("cx", x).attr("cy", 7).attr("r", 4).attr("fill", "none").attr("stroke", color).attr("stroke-width", 3));
|
| 282 |
+
return;
|
| 283 |
+
}
|
| 284 |
+
if (kind === "transit") {
|
| 285 |
+
g.append("rect").attr("x", -12).attr("y", -13).attr("width", 24).attr("height", 27).attr("rx", 4)
|
| 286 |
+
.attr("fill", "none").attr("stroke", color).attr("stroke-width", 3);
|
| 287 |
+
g.append("line").attr("x1", -7).attr("x2", 7).attr("y1", -3).attr("y2", -3).attr("stroke", color).attr("stroke-width", 3).attr("stroke-linecap", "round");
|
| 288 |
+
g.append("line").attr("x1", -8).attr("x2", 8).attr("y1", 12).attr("y2", 12).attr("stroke", color).attr("stroke-width", 3).attr("stroke-linecap", "round");
|
| 289 |
+
[-7, 7].forEach(x => g.append("circle").attr("cx", x).attr("cy", 6).attr("r", 2.3).attr("fill", color));
|
| 290 |
+
return;
|
| 291 |
+
}
|
| 292 |
+
if (kind === "bicycle") {
|
| 293 |
+
[-10, 10].forEach(x => g.append("circle").attr("cx", x).attr("cy", 8).attr("r", 7).attr("fill", "none").attr("stroke", color).attr("stroke-width", 3));
|
| 294 |
+
g.append("path").attr("d", "M -10 8 L -2 -4 L 5 8 L -10 8 M -2 -4 L 6 -4 L 10 8")
|
| 295 |
+
.attr("fill", "none").attr("stroke", color).attr("stroke-width", 3).attr("stroke-linecap", "round").attr("stroke-linejoin", "round");
|
| 296 |
+
g.append("line").attr("x1", -2).attr("x2", -4).attr("y1", -4).attr("y2", -10).attr("stroke", color).attr("stroke-width", 3).attr("stroke-linecap", "round");
|
| 297 |
+
return;
|
| 298 |
+
}
|
| 299 |
+
if (kind === "walking") {
|
| 300 |
+
g.append("circle").attr("cx", 0).attr("cy", -13).attr("r", 3.2).attr("fill", color);
|
| 301 |
+
g.append("path").attr("d", "M 0 -8 L -3 1 L -8 9 M -2 0 L 7 2 M 1 -5 L 6 -11 M 1 0 L 7 10")
|
| 302 |
+
.attr("fill", "none").attr("stroke", color).attr("stroke-width", 3.4).attr("stroke-linecap", "round").attr("stroke-linejoin", "round");
|
| 303 |
+
return;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
g.append("polyline")
|
| 307 |
+
.attr("points", "-13,8 -6,2 -1,5 5,-5 13,-10")
|
| 308 |
+
.attr("fill", "none")
|
| 309 |
+
.attr("stroke", color)
|
| 310 |
+
.attr("stroke-width", 3.2)
|
| 311 |
+
.attr("stroke-linecap", "round")
|
| 312 |
+
.attr("stroke-linejoin", "round");
|
| 313 |
+
g.append("polyline")
|
| 314 |
+
.attr("points", "6,-10 13,-10 13,-3")
|
| 315 |
+
.attr("fill", "none")
|
| 316 |
+
.attr("stroke", color)
|
| 317 |
+
.attr("stroke-width", 3.2)
|
| 318 |
+
.attr("stroke-linecap", "round")
|
| 319 |
+
.attr("stroke-linejoin", "round");
|
| 320 |
+
if (initial) {
|
| 321 |
+
g.append("text")
|
| 322 |
+
.attr("x", 0)
|
| 323 |
+
.attr("y", 18)
|
| 324 |
+
.attr("text-anchor", "middle")
|
| 325 |
+
.style("font-family", labelFamily)
|
| 326 |
+
.style("font-size", "9px")
|
| 327 |
+
.style("font-weight", 800)
|
| 328 |
+
.style("fill", color)
|
| 329 |
+
.text(initial.slice(0, 2));
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
function drawIconSlot(parent, group, color, x, y, radius) {
|
| 334 |
+
const kind = semanticKey(group);
|
| 335 |
+
const slot = parent.append("g")
|
| 336 |
+
.attr("class", "reference-card-icon-slot")
|
| 337 |
+
.attr("data-group", group)
|
| 338 |
+
.attr("data-icon-kind", kind || "generic")
|
| 339 |
+
.attr("transform", `translate(${x}, ${y})`);
|
| 340 |
+
slot.append("circle").attr("r", radius).attr("fill", color);
|
| 341 |
+
slot.append("circle").attr("r", radius - 3).attr("fill", "none").attr("stroke", "#ffffff").attr("stroke-opacity", 0.35).attr("stroke-width", 1.2);
|
| 342 |
+
const mark = slot.append("g").attr("transform", "translate(0, 0)");
|
| 343 |
+
if (kind) {
|
| 344 |
+
drawTransportIcon(mark, kind, "#ffffff", radius * 1.45, "");
|
| 345 |
+
} else {
|
| 346 |
+
mark.append("text")
|
| 347 |
+
.attr("class", "reference-card-icon-text")
|
| 348 |
+
.attr("x", 0)
|
| 349 |
+
.attr("y", 7)
|
| 350 |
+
.attr("text-anchor", "middle")
|
| 351 |
+
.style("font-family", labelFamily)
|
| 352 |
+
.style("font-size", `${Math.max(13, Math.min(18, radius * 0.78))}px`)
|
| 353 |
+
.style("font-weight", 800)
|
| 354 |
+
.style("fill", "#ffffff")
|
| 355 |
+
.text(groupInitial(group));
|
| 356 |
+
}
|
| 357 |
+
return slot;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
function drawHeroDivider(svg, centerX, y) {
|
| 361 |
+
const hero = svg.append("g")
|
| 362 |
+
.attr("class", "reference-hero-icon-slot")
|
| 363 |
+
.attr("data-slot", "title-divider")
|
| 364 |
+
.attr("transform", `translate(${centerX}, ${y})`);
|
| 365 |
+
hero.append("line")
|
| 366 |
+
.attr("class", "reference-hero-rule")
|
| 367 |
+
.attr("x1", -122)
|
| 368 |
+
.attr("x2", -30)
|
| 369 |
+
.attr("y1", 0)
|
| 370 |
+
.attr("y2", 0)
|
| 371 |
+
.attr("stroke", accentBlue)
|
| 372 |
+
.attr("stroke-width", 2)
|
| 373 |
+
.attr("stroke-opacity", 0.72);
|
| 374 |
+
hero.append("line")
|
| 375 |
+
.attr("class", "reference-hero-rule")
|
| 376 |
+
.attr("x1", 30)
|
| 377 |
+
.attr("x2", 122)
|
| 378 |
+
.attr("y1", 0)
|
| 379 |
+
.attr("y2", 0)
|
| 380 |
+
.attr("stroke", accentBlue)
|
| 381 |
+
.attr("stroke-width", 2)
|
| 382 |
+
.attr("stroke-opacity", 0.72);
|
| 383 |
+
drawTransportIcon(hero, "car", accentBlue, 34, "");
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
function drawTrendArrow(parent, delta, color, x, y, size) {
|
| 387 |
+
const g = parent.append("g")
|
| 388 |
+
.attr("class", "reference-trend-arrow-icon-slot")
|
| 389 |
+
.attr("transform", `translate(${x}, ${y})`);
|
| 390 |
+
g.append("circle").attr("r", size / 2).attr("fill", color);
|
| 391 |
+
const path = delta < 0 ? "M -7 -5 L 0 2 L 6 -4 M 0 2 L 0 -7" : "M -7 5 L 0 -2 L 6 4 M 0 -2 L 0 7";
|
| 392 |
+
g.append("path")
|
| 393 |
+
.attr("d", path)
|
| 394 |
+
.attr("transform", "rotate(45)")
|
| 395 |
+
.attr("fill", "none")
|
| 396 |
+
.attr("stroke", "#ffffff")
|
| 397 |
+
.attr("stroke-width", 3)
|
| 398 |
+
.attr("stroke-linecap", "round")
|
| 399 |
+
.attr("stroke-linejoin", "round");
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
function drawFooterDecoration(parent, x, y, widthValue, heightValue, color) {
|
| 403 |
+
const decor = parent.append("g")
|
| 404 |
+
.attr("class", "reference-footer-decorative-image-slot")
|
| 405 |
+
.attr("data-slot", "footer-city-motion")
|
| 406 |
+
.attr("transform", `translate(${x}, ${y})`);
|
| 407 |
+
decor.append("line")
|
| 408 |
+
.attr("x1", 0)
|
| 409 |
+
.attr("x2", widthValue)
|
| 410 |
+
.attr("y1", heightValue - 14)
|
| 411 |
+
.attr("y2", heightValue - 14)
|
| 412 |
+
.attr("stroke", rgba(color, 0.32))
|
| 413 |
+
.attr("stroke-width", 2);
|
| 414 |
+
const buildings = [
|
| 415 |
+
[8, 25, 20, 48], [34, 10, 16, 63], [58, 31, 18, 42],
|
| 416 |
+
[84, 18, 22, 55], [114, 29, 16, 44], [136, 8, 24, 65]
|
| 417 |
+
];
|
| 418 |
+
buildings.forEach(([bx, by, bw, bh], i) => {
|
| 419 |
+
decor.append("rect")
|
| 420 |
+
.attr("x", bx)
|
| 421 |
+
.attr("y", by)
|
| 422 |
+
.attr("width", bw)
|
| 423 |
+
.attr("height", bh)
|
| 424 |
+
.attr("rx", 2)
|
| 425 |
+
.attr("fill", rgba(color, i % 2 ? 0.12 : 0.18));
|
| 426 |
+
[0, 1, 2].forEach(row => {
|
| 427 |
+
decor.append("line")
|
| 428 |
+
.attr("x1", bx + 4)
|
| 429 |
+
.attr("x2", bx + bw - 4)
|
| 430 |
+
.attr("y1", by + 8 + row * 11)
|
| 431 |
+
.attr("y2", by + 8 + row * 11)
|
| 432 |
+
.attr("stroke", rgba(color, 0.18))
|
| 433 |
+
.attr("stroke-width", 1);
|
| 434 |
+
});
|
| 435 |
+
});
|
| 436 |
+
const car = decor.append("g")
|
| 437 |
+
.attr("class", "reference-footer-car-slot")
|
| 438 |
+
.attr("transform", `translate(${widthValue - 78}, ${heightValue - 23})`);
|
| 439 |
+
car.append("path")
|
| 440 |
+
.attr("d", "M 0 6 L 8 -8 C 10 -12 15 -14 23 -14 L 47 -14 C 55 -14 61 -10 64 -3 L 70 6 Z")
|
| 441 |
+
.attr("fill", color);
|
| 442 |
+
car.append("rect").attr("x", 6).attr("y", 0).attr("width", 62).attr("height", 14).attr("rx", 5).attr("fill", color);
|
| 443 |
+
[-1, 58].forEach(cx => {
|
| 444 |
+
car.append("circle").attr("cx", cx + 12).attr("cy", 15).attr("r", 8).attr("fill", "#243044");
|
| 445 |
+
car.append("circle").attr("cx", cx + 12).attr("cy", 15).attr("r", 4).attr("fill", "#d9edfa");
|
| 446 |
+
});
|
| 447 |
+
car.append("path").attr("d", "M 24 -10 L 36 -10 L 42 0 L 17 0 Z").attr("fill", "#bfe6fb");
|
| 448 |
+
[0, 1, 2].forEach(i => {
|
| 449 |
+
decor.append("line")
|
| 450 |
+
.attr("x1", widthValue - 150 - i * 18)
|
| 451 |
+
.attr("x2", widthValue - 98 - i * 8)
|
| 452 |
+
.attr("y1", heightValue - 34 + i * 10)
|
| 453 |
+
.attr("y2", heightValue - 34 + i * 10)
|
| 454 |
+
.attr("stroke", rgba(color, 0.35))
|
| 455 |
+
.attr("stroke-width", 2)
|
| 456 |
+
.attr("stroke-linecap", "round");
|
| 457 |
+
});
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
function percentChange(firstValue, lastValue) {
|
| 461 |
+
if (!Number.isFinite(firstValue) || !Number.isFinite(lastValue)) return null;
|
| 462 |
+
if (firstValue === 0) return null;
|
| 463 |
+
return (lastValue - firstValue) / Math.abs(firstValue) * 100;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
const parsedRows = chartData.map((d, index) => {
|
| 467 |
+
const date = parseTemporalDate(d[xField]);
|
| 468 |
+
const value = Number(d[yField]);
|
| 469 |
+
const group = String(d[groupField] ?? "");
|
| 470 |
+
return {
|
| 471 |
+
...d,
|
| 472 |
+
__index: index,
|
| 473 |
+
__date: date,
|
| 474 |
+
__time: date ? date.getTime() : NaN,
|
| 475 |
+
__xLabel: String(d[xField] ?? ""),
|
| 476 |
+
__value: value,
|
| 477 |
+
__group: group
|
| 478 |
+
};
|
| 479 |
+
}).filter(d => d.__date && Number.isFinite(d.__time) && Number.isFinite(d.__value) && d.__group !== "");
|
| 480 |
+
|
| 481 |
+
if (!parsedRows.length) return;
|
| 482 |
+
|
| 483 |
+
const groups = [];
|
| 484 |
+
parsedRows.forEach(d => {
|
| 485 |
+
if (!groups.includes(d.__group)) groups.push(d.__group);
|
| 486 |
+
});
|
| 487 |
+
if (!groups.length) return;
|
| 488 |
+
|
| 489 |
+
const xLabelByTime = new Map();
|
| 490 |
+
parsedRows.forEach(d => {
|
| 491 |
+
if (!xLabelByTime.has(d.__time)) xLabelByTime.set(d.__time, d.__xLabel);
|
| 492 |
+
});
|
| 493 |
+
|
| 494 |
+
const groupSeries = groups.map(group => {
|
| 495 |
+
const byTime = new Map();
|
| 496 |
+
parsedRows.filter(d => d.__group === group).forEach(d => {
|
| 497 |
+
if (!byTime.has(d.__time)) {
|
| 498 |
+
byTime.set(d.__time, { date: d.__date, time: d.__time, label: d.__xLabel, valueSum: 0, count: 0 });
|
| 499 |
+
}
|
| 500 |
+
const entry = byTime.get(d.__time);
|
| 501 |
+
entry.valueSum += d.__value;
|
| 502 |
+
entry.count += 1;
|
| 503 |
+
});
|
| 504 |
+
const values = Array.from(byTime.values())
|
| 505 |
+
.map(d => ({ date: d.date, time: d.time, label: d.label, value: d.valueSum / d.count }))
|
| 506 |
+
.sort((a, b) => a.time - b.time);
|
| 507 |
+
return { group, values };
|
| 508 |
+
}).filter(d => d.values.length);
|
| 509 |
+
|
| 510 |
+
if (!groupSeries.length) return;
|
| 511 |
+
|
| 512 |
+
const xValues = Array.from(new Set(parsedRows.map(d => d.__time)))
|
| 513 |
+
.sort((a, b) => a - b)
|
| 514 |
+
.map(time => new Date(time));
|
| 515 |
+
const allValues = groupSeries.flatMap(series => series.values.map(d => d.value));
|
| 516 |
+
let yMin = d3.min(allValues);
|
| 517 |
+
let yMax = d3.max(allValues);
|
| 518 |
+
if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) return;
|
| 519 |
+
const rawYMin = yMin;
|
| 520 |
+
const rawYMax = yMax;
|
| 521 |
+
const scaleText = `${yField} ${yColumn.description || ""} ${yUnit}`.toLowerCase();
|
| 522 |
+
const boundedScore = rawYMin >= 0 && rawYMax <= 10 && /(rating|score|index|scale|rate)/.test(scaleText);
|
| 523 |
+
if (boundedScore) {
|
| 524 |
+
yMin = Math.max(0, Math.floor(rawYMin));
|
| 525 |
+
yMax = Math.min(100, Math.ceil(rawYMax));
|
| 526 |
+
if (rawYMax <= 10) yMax = Math.min(10, yMax);
|
| 527 |
+
if (yMax - yMin < 4) {
|
| 528 |
+
const mid = (yMin + yMax) / 2;
|
| 529 |
+
yMin = Math.max(0, Math.floor(mid - 2));
|
| 530 |
+
yMax = Math.ceil(mid + 2);
|
| 531 |
+
}
|
| 532 |
+
} else if (yMin === yMax) {
|
| 533 |
+
const pad = Math.max(1, Math.abs(yMin) * 0.1);
|
| 534 |
+
yMin -= pad;
|
| 535 |
+
yMax += pad;
|
| 536 |
+
} else {
|
| 537 |
+
const pad = (yMax - yMin) * 0.1;
|
| 538 |
+
yMin -= pad;
|
| 539 |
+
yMax += pad;
|
| 540 |
+
if (rawYMin >= 0) yMin = Math.max(0, yMin);
|
| 541 |
+
if (rawYMax <= 0) yMax = Math.min(0, yMax);
|
| 542 |
+
if (yMin > 0 && (yMax - yMin) / Math.max(1, Math.abs(yMax)) > 0.55) yMin = 0;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
const cols = groupSeries.length <= 1 ? 1 : 2;
|
| 546 |
+
const rows = Math.ceil(groupSeries.length / cols);
|
| 547 |
+
const sidePad = 44;
|
| 548 |
+
const gapX = variables.has_spacing === false ? 20 : 28;
|
| 549 |
+
const gapY = variables.has_spacing === false ? 24 : 30;
|
| 550 |
+
const cardWidth = (width - sidePad * 2 - gapX * (cols - 1)) / cols;
|
| 551 |
+
const cardHeight = Math.max(340, Math.min(380, cardWidth * 1.02));
|
| 552 |
+
const titleText = cleanText(jsonData.titles?.main_title || jsonData.metadata?.title || jsonData.title || `${readableName(yField)} by ${readableName(groupField)}`);
|
| 553 |
+
const subtitleText = cleanText(jsonData.titles?.sub_title || jsonData.metadata?.description || jsonData.metadata?.main_insight || yColumn.description || `${readableName(yField)} across ${readableName(groupField)} over time.`);
|
| 554 |
+
const titleParts = splitTitle(titleText || `${readableName(yField)} by ${readableName(groupField)}`, Math.max(22, Math.floor(width / 21)));
|
| 555 |
+
const titleTopFont = fitFont(titleParts[0], Math.max(24, Math.min(34, width * 0.044)), width - 120, 19);
|
| 556 |
+
const titleMainFont = fitFont(titleParts[1] || titleParts[0], Math.max(38, Math.min(58, width * 0.074)), width - 110, 28);
|
| 557 |
+
const subtitleLines = wrapText(subtitleText, Math.max(38, Math.floor((width - 170) / 10.6)), 3);
|
| 558 |
+
const heroTop = 52;
|
| 559 |
+
const titleTopY = heroTop + 28;
|
| 560 |
+
const titleMainY = titleTopY + titleTopFont + 42;
|
| 561 |
+
const dividerY = titleMainY + 36;
|
| 562 |
+
const subtitleY = dividerY + 50;
|
| 563 |
+
const cardTop = subtitleY + Math.max(56, subtitleLines.length * 28 + 40);
|
| 564 |
+
const footerH = 116;
|
| 565 |
+
const footerY = cardTop + rows * cardHeight + Math.max(0, rows - 1) * gapY + 34;
|
| 566 |
+
const height = Math.ceil(footerY + footerH + 34);
|
| 567 |
+
const plot = { left: 52, right: 26, top: 120, bottom: 88 };
|
| 568 |
+
const innerWidth = Math.max(160, cardWidth - plot.left - plot.right);
|
| 569 |
+
const innerHeight = Math.max(130, cardHeight - plot.top - plot.bottom);
|
| 570 |
+
const tickCount = innerWidth < 210 ? 3 : 4;
|
| 571 |
+
const xTicks = selectEvenTicks(xValues, tickCount);
|
| 572 |
+
|
| 573 |
+
const xScale = d3.scaleUtc().domain(d3.extent(xValues)).range([0, innerWidth]);
|
| 574 |
+
const yScale = d3.scaleLinear().domain([yMin, yMax]).range([innerHeight, 0]);
|
| 575 |
+
const yTicks = yScale.ticks(innerHeight < 145 ? 4 : 5);
|
| 576 |
+
|
| 577 |
+
const colorScale = d3.scaleOrdinal()
|
| 578 |
+
.domain(groups)
|
| 579 |
+
.range(groups.map((group, index) => semanticColor(group, index)));
|
| 580 |
+
|
| 581 |
+
function formatXTick(date) {
|
| 582 |
+
const label = xLabelByTime.get(date.getTime());
|
| 583 |
+
if (label) return label;
|
| 584 |
+
return d3.utcFormat("%Y")(date);
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
const scaleNote = rawYMin >= 0
|
| 588 |
+
? "All panels use a shared y-scale starting at zero where the source values allow."
|
| 589 |
+
: "All panels use a shared y-scale from the source values.";
|
| 590 |
+
|
| 591 |
+
const line = d3.line()
|
| 592 |
+
.defined(d => Number.isFinite(d.value))
|
| 593 |
+
.x(d => xScale(d.date))
|
| 594 |
+
.y(d => yScale(d.value))
|
| 595 |
+
.curve(d3.curveMonotoneX);
|
| 596 |
+
|
| 597 |
+
const strongestSeries = groupSeries.reduce((best, series) => {
|
| 598 |
+
const first = series.values[0];
|
| 599 |
+
const last = series.values[series.values.length - 1];
|
| 600 |
+
const delta = first && last ? Math.abs(last.value - first.value) : 0;
|
| 601 |
+
if (!best || delta > best.delta) return { series, delta };
|
| 602 |
+
return best;
|
| 603 |
+
}, null)?.series || groupSeries[0];
|
| 604 |
+
const strongestFirst = strongestSeries.values[0];
|
| 605 |
+
const strongestLast = strongestSeries.values[strongestSeries.values.length - 1];
|
| 606 |
+
const strongestDelta = strongestFirst && strongestLast ? strongestLast.value - strongestFirst.value : 0;
|
| 607 |
+
const strongestDirection = strongestDelta < 0 ? "declined the most" : strongestDelta > 0 ? "changed upward the most" : "remained the most stable";
|
| 608 |
+
const takeaway = `${readableName(strongestSeries.group)} ${strongestDirection} from ${strongestFirst?.label || ""} to ${strongestLast?.label || ""}.`;
|
| 609 |
+
|
| 610 |
+
const svg = d3.select(containerSelector)
|
| 611 |
+
.append("svg")
|
| 612 |
+
.attr("width", width)
|
| 613 |
+
.attr("height", height)
|
| 614 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 615 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 616 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 617 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 618 |
+
|
| 619 |
+
const defs = svg.append("defs");
|
| 620 |
+
const shadow = defs.append("filter")
|
| 621 |
+
.attr("id", "small_multiples_line_01_card_shadow")
|
| 622 |
+
.attr("x", "-15%")
|
| 623 |
+
.attr("y", "-13%")
|
| 624 |
+
.attr("width", "130%")
|
| 625 |
+
.attr("height", "132%");
|
| 626 |
+
shadow.append("feDropShadow")
|
| 627 |
+
.attr("dx", 0)
|
| 628 |
+
.attr("dy", 5)
|
| 629 |
+
.attr("stdDeviation", 6)
|
| 630 |
+
.attr("flood-color", "#15345a")
|
| 631 |
+
.attr("flood-opacity", 0.12);
|
| 632 |
+
|
| 633 |
+
const cardGradient = defs.append("linearGradient")
|
| 634 |
+
.attr("id", "small_multiples_line_01_card_soft_gradient")
|
| 635 |
+
.attr("x1", "0%").attr("y1", "0%").attr("x2", "100%").attr("y2", "100%");
|
| 636 |
+
cardGradient.append("stop").attr("offset", "0%").attr("stop-color", "#ffffff");
|
| 637 |
+
cardGradient.append("stop").attr("offset", "100%").attr("stop-color", "#f8fbff");
|
| 638 |
+
|
| 639 |
+
svg.append("rect")
|
| 640 |
+
.attr("class", "chart-canvas reference-layout-canvas")
|
| 641 |
+
.attr("x", 0)
|
| 642 |
+
.attr("y", 0)
|
| 643 |
+
.attr("width", width)
|
| 644 |
+
.attr("height", height)
|
| 645 |
+
.attr("rx", 16)
|
| 646 |
+
.attr("ry", 16)
|
| 647 |
+
.attr("fill", canvasColor);
|
| 648 |
+
|
| 649 |
+
svg.append("text")
|
| 650 |
+
.attr("class", "reference-title-kicker")
|
| 651 |
+
.attr("x", width / 2)
|
| 652 |
+
.attr("y", titleTopY)
|
| 653 |
+
.attr("text-anchor", "middle")
|
| 654 |
+
.style("font-family", titleFamily)
|
| 655 |
+
.style("font-size", `${titleTopFont}px`)
|
| 656 |
+
.style("font-weight", 800)
|
| 657 |
+
.style("letter-spacing", "0px")
|
| 658 |
+
.style("fill", navy)
|
| 659 |
+
.text(titleParts[0]);
|
| 660 |
+
|
| 661 |
+
if (titleParts[1]) {
|
| 662 |
+
svg.append("text")
|
| 663 |
+
.attr("class", "reference-title-main")
|
| 664 |
+
.attr("x", width / 2)
|
| 665 |
+
.attr("y", titleMainY)
|
| 666 |
+
.attr("text-anchor", "middle")
|
| 667 |
+
.style("font-family", titleFamily)
|
| 668 |
+
.style("font-size", `${titleMainFont}px`)
|
| 669 |
+
.style("font-weight", titleWeight)
|
| 670 |
+
.style("letter-spacing", "0px")
|
| 671 |
+
.style("fill", accentBlue)
|
| 672 |
+
.text(titleParts[1]);
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
drawHeroDivider(svg, width / 2, dividerY);
|
| 676 |
+
|
| 677 |
+
const subtitle = svg.append("text")
|
| 678 |
+
.attr("class", "reference-subtitle")
|
| 679 |
+
.attr("x", width / 2)
|
| 680 |
+
.attr("y", subtitleY)
|
| 681 |
+
.attr("text-anchor", "middle")
|
| 682 |
+
.style("font-family", labelFamily)
|
| 683 |
+
.style("font-size", `${Math.max(17, Math.min(23, baseLabelSize * 1.7))}px`)
|
| 684 |
+
.style("font-weight", 400)
|
| 685 |
+
.style("font-style", "italic")
|
| 686 |
+
.style("fill", mutedText);
|
| 687 |
+
subtitleLines.forEach((lineText, lineIndex) => {
|
| 688 |
+
subtitle.append("tspan")
|
| 689 |
+
.attr("x", width / 2)
|
| 690 |
+
.attr("dy", lineIndex === 0 ? 0 : 28)
|
| 691 |
+
.text(lineText);
|
| 692 |
+
});
|
| 693 |
+
|
| 694 |
+
groupSeries.forEach((series, index) => {
|
| 695 |
+
const row = Math.floor(index / cols);
|
| 696 |
+
const col = index % cols;
|
| 697 |
+
const panelX = cols === 1
|
| 698 |
+
? (width - cardWidth) / 2
|
| 699 |
+
: sidePad + col * (cardWidth + gapX);
|
| 700 |
+
const panelY = cardTop + row * (cardHeight + gapY);
|
| 701 |
+
const seriesColor = colorScale(series.group);
|
| 702 |
+
const headerFill = headerPalette[index % headerPalette.length];
|
| 703 |
+
const panel = svg.append("g")
|
| 704 |
+
.attr("class", "subplot reference-card")
|
| 705 |
+
.attr("data-group", series.group)
|
| 706 |
+
.attr("data-row", row)
|
| 707 |
+
.attr("data-col", col)
|
| 708 |
+
.attr("transform", `translate(${panelX}, ${panelY})`);
|
| 709 |
+
|
| 710 |
+
panel.append("rect")
|
| 711 |
+
.attr("class", "subplot-background subplot-card reference-card-shell")
|
| 712 |
+
.attr("width", cardWidth)
|
| 713 |
+
.attr("height", cardHeight)
|
| 714 |
+
.attr("rx", 18)
|
| 715 |
+
.attr("ry", 18)
|
| 716 |
+
.attr("fill", "url(#small_multiples_line_01_card_soft_gradient)")
|
| 717 |
+
.attr("stroke", rgba(seriesColor, 0.2))
|
| 718 |
+
.attr("stroke-width", 1.2)
|
| 719 |
+
.style("filter", "url(#small_multiples_line_01_card_shadow)");
|
| 720 |
+
|
| 721 |
+
panel.append("rect")
|
| 722 |
+
.attr("class", "reference-card-color-wash")
|
| 723 |
+
.attr("x", 1)
|
| 724 |
+
.attr("y", 1)
|
| 725 |
+
.attr("width", cardWidth - 2)
|
| 726 |
+
.attr("height", cardHeight - 2)
|
| 727 |
+
.attr("rx", 17)
|
| 728 |
+
.attr("ry", 17)
|
| 729 |
+
.attr("fill", headerFill)
|
| 730 |
+
.attr("opacity", 0.48);
|
| 731 |
+
|
| 732 |
+
drawIconSlot(panel, series.group, seriesColor, 46, 48, 28);
|
| 733 |
+
|
| 734 |
+
const titleLines = wrapText(readableName(series.group), Math.max(11, Math.floor((cardWidth - 110) / 8.2)), 2);
|
| 735 |
+
const title = panel.append("text")
|
| 736 |
+
.attr("class", "label group-label reference-card-title")
|
| 737 |
+
.attr("data-group", series.group)
|
| 738 |
+
.attr("x", 88)
|
| 739 |
+
.attr("y", 42)
|
| 740 |
+
.attr("text-anchor", "start")
|
| 741 |
+
.style("font-family", titleFamily)
|
| 742 |
+
.style("font-size", `${Math.max(18, Math.min(26, baseLabelSize * 2.0))}px`)
|
| 743 |
+
.style("font-weight", 800)
|
| 744 |
+
.style("fill", navy);
|
| 745 |
+
titleLines.forEach((lineText, lineIndex) => {
|
| 746 |
+
title.append("tspan")
|
| 747 |
+
.attr("x", 88)
|
| 748 |
+
.attr("dy", lineIndex === 0 ? 0 : 25)
|
| 749 |
+
.text(lineText);
|
| 750 |
+
});
|
| 751 |
+
|
| 752 |
+
panel.append("line")
|
| 753 |
+
.attr("class", "reference-series-key")
|
| 754 |
+
.attr("x1", 88)
|
| 755 |
+
.attr("x2", 122)
|
| 756 |
+
.attr("y1", 78)
|
| 757 |
+
.attr("y2", 78)
|
| 758 |
+
.attr("stroke", seriesColor)
|
| 759 |
+
.attr("stroke-width", 3)
|
| 760 |
+
.attr("stroke-linecap", "round");
|
| 761 |
+
const unitLabel = `${readableName(yField)}${yUnit ? ` (${yUnit})` : ""}`;
|
| 762 |
+
const unitText = panel.append("text")
|
| 763 |
+
.attr("class", "reference-unit-label")
|
| 764 |
+
.attr("x", 132)
|
| 765 |
+
.attr("y", 82)
|
| 766 |
+
.style("font-family", labelFamily)
|
| 767 |
+
.style("font-size", `${Math.max(10, Math.min(13, baseLabelSize * 1.05))}px`)
|
| 768 |
+
.style("font-weight", 400)
|
| 769 |
+
.style("fill", mutedText);
|
| 770 |
+
wrapText(unitLabel, Math.max(20, Math.floor((cardWidth - 142) / 6.1)), 2).forEach((lineText, lineIndex) => {
|
| 771 |
+
unitText.append("tspan")
|
| 772 |
+
.attr("x", 132)
|
| 773 |
+
.attr("dy", lineIndex === 0 ? 0 : 14)
|
| 774 |
+
.text(lineText);
|
| 775 |
+
});
|
| 776 |
+
|
| 777 |
+
const chart = panel.append("g")
|
| 778 |
+
.attr("class", "panel-plot reference-card-plot")
|
| 779 |
+
.attr("transform", `translate(${plot.left}, ${plot.top})`);
|
| 780 |
+
|
| 781 |
+
yTicks.forEach(tick => {
|
| 782 |
+
chart.append("line")
|
| 783 |
+
.attr("class", "gridline")
|
| 784 |
+
.attr("x1", 0)
|
| 785 |
+
.attr("x2", innerWidth)
|
| 786 |
+
.attr("y1", yScale(tick))
|
| 787 |
+
.attr("y2", yScale(tick))
|
| 788 |
+
.attr("stroke", "#d7dde7")
|
| 789 |
+
.attr("stroke-dasharray", "5 4")
|
| 790 |
+
.attr("stroke-width", 1);
|
| 791 |
+
chart.append("text")
|
| 792 |
+
.attr("class", "axis-tick y-axis-tick")
|
| 793 |
+
.attr("x", -9)
|
| 794 |
+
.attr("y", yScale(tick))
|
| 795 |
+
.attr("text-anchor", "end")
|
| 796 |
+
.attr("dominant-baseline", "middle")
|
| 797 |
+
.style("font-family", labelFamily)
|
| 798 |
+
.style("font-size", `${Math.max(10, Math.min(baseLabelSize, 12))}px`)
|
| 799 |
+
.style("font-weight", labelWeight)
|
| 800 |
+
.style("fill", axisText)
|
| 801 |
+
.text(formatNumber(tick));
|
| 802 |
+
});
|
| 803 |
+
|
| 804 |
+
chart.append("line")
|
| 805 |
+
.attr("class", "axis-line x-axis-line")
|
| 806 |
+
.attr("x1", 0)
|
| 807 |
+
.attr("x2", innerWidth)
|
| 808 |
+
.attr("y1", innerHeight)
|
| 809 |
+
.attr("y2", innerHeight)
|
| 810 |
+
.attr("stroke", "#23324c")
|
| 811 |
+
.attr("stroke-width", 1.2);
|
| 812 |
+
|
| 813 |
+
xTicks.forEach(tick => {
|
| 814 |
+
chart.append("line")
|
| 815 |
+
.attr("class", "axis-tick-line")
|
| 816 |
+
.attr("x1", xScale(tick))
|
| 817 |
+
.attr("x2", xScale(tick))
|
| 818 |
+
.attr("y1", innerHeight)
|
| 819 |
+
.attr("y2", innerHeight + 5)
|
| 820 |
+
.attr("stroke", "#23324c")
|
| 821 |
+
.attr("stroke-width", 1);
|
| 822 |
+
chart.append("text")
|
| 823 |
+
.attr("class", "axis-tick x-axis-tick")
|
| 824 |
+
.attr("x", xScale(tick))
|
| 825 |
+
.attr("y", innerHeight + 24)
|
| 826 |
+
.attr("text-anchor", "middle")
|
| 827 |
+
.style("font-family", labelFamily)
|
| 828 |
+
.style("font-size", `${Math.max(10, Math.min(baseLabelSize, 12))}px`)
|
| 829 |
+
.style("font-weight", labelWeight)
|
| 830 |
+
.style("fill", axisText)
|
| 831 |
+
.text(formatXTick(tick));
|
| 832 |
+
});
|
| 833 |
+
|
| 834 |
+
chart.append("path")
|
| 835 |
+
.datum(series.values)
|
| 836 |
+
.attr("class", "line mark")
|
| 837 |
+
.attr("data-group", series.group)
|
| 838 |
+
.attr("data-count", series.values.length)
|
| 839 |
+
.attr("data-latest-value", series.values[series.values.length - 1]?.value ?? "")
|
| 840 |
+
.attr("fill", "none")
|
| 841 |
+
.attr("stroke", seriesColor)
|
| 842 |
+
.attr("stroke-width", variables.has_stroke === false ? 3.0 : 3.8)
|
| 843 |
+
.attr("stroke-linecap", "round")
|
| 844 |
+
.attr("stroke-linejoin", "round")
|
| 845 |
+
.attr("d", line);
|
| 846 |
+
|
| 847 |
+
chart.selectAll(".point")
|
| 848 |
+
.data(series.values)
|
| 849 |
+
.enter()
|
| 850 |
+
.append("circle")
|
| 851 |
+
.attr("class", "point mark")
|
| 852 |
+
.attr("data-group", series.group)
|
| 853 |
+
.attr("data-time", d => d.time)
|
| 854 |
+
.attr("data-label", d => d.label)
|
| 855 |
+
.attr("data-value", d => d.value)
|
| 856 |
+
.attr("cx", d => xScale(d.date))
|
| 857 |
+
.attr("cy", d => yScale(d.value))
|
| 858 |
+
.attr("r", 4.6)
|
| 859 |
+
.attr("fill", cardFill)
|
| 860 |
+
.attr("stroke", seriesColor)
|
| 861 |
+
.attr("stroke-width", 2.4);
|
| 862 |
+
|
| 863 |
+
const firstPoint = series.values[0];
|
| 864 |
+
const lastPoint = series.values[series.values.length - 1];
|
| 865 |
+
const delta = lastPoint && firstPoint ? lastPoint.value - firstPoint.value : 0;
|
| 866 |
+
const latestText = lastPoint ? formatWithUnit(lastPoint.value, yUnit) : "";
|
| 867 |
+
if (lastPoint) {
|
| 868 |
+
const latestFontSize = Math.max(12, Math.min(baseValueSize * 1.25, 16));
|
| 869 |
+
const pillWidth = Math.max(54, latestText.length * latestFontSize * 0.62 + 18);
|
| 870 |
+
const labelX = Math.max(pillWidth / 2, Math.min(innerWidth - pillWidth / 2, xScale(lastPoint.date)));
|
| 871 |
+
const labelY = Math.max(16, Math.min(innerHeight - 20, yScale(lastPoint.value) - 18));
|
| 872 |
+
chart.append("rect")
|
| 873 |
+
.attr("class", "latest-value-pill reference-latest-value-pill")
|
| 874 |
+
.attr("data-group", series.group)
|
| 875 |
+
.attr("x", labelX - pillWidth / 2)
|
| 876 |
+
.attr("y", labelY - latestFontSize)
|
| 877 |
+
.attr("width", pillWidth)
|
| 878 |
+
.attr("height", latestFontSize + 10)
|
| 879 |
+
.attr("rx", 8)
|
| 880 |
+
.attr("ry", 8)
|
| 881 |
+
.attr("fill", seriesColor);
|
| 882 |
+
chart.append("path")
|
| 883 |
+
.attr("class", "reference-latest-value-pointer")
|
| 884 |
+
.attr("d", `M ${labelX - 5},${labelY + 9} L ${labelX + 5},${labelY + 9} L ${labelX},${labelY + 17} Z`)
|
| 885 |
+
.attr("fill", seriesColor);
|
| 886 |
+
chart.append("text")
|
| 887 |
+
.attr("class", "value latest-value reference-latest-value")
|
| 888 |
+
.attr("data-group", series.group)
|
| 889 |
+
.attr("data-time", lastPoint.time)
|
| 890 |
+
.attr("data-label", lastPoint.label)
|
| 891 |
+
.attr("data-value", lastPoint.value)
|
| 892 |
+
.attr("x", labelX)
|
| 893 |
+
.attr("y", labelY)
|
| 894 |
+
.attr("text-anchor", "middle")
|
| 895 |
+
.style("font-family", valueFamily)
|
| 896 |
+
.style("font-size", `${latestFontSize}px`)
|
| 897 |
+
.style("font-weight", 800)
|
| 898 |
+
.style("fill", "#ffffff")
|
| 899 |
+
.text(latestText);
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
const percent = firstPoint && lastPoint ? percentChange(firstPoint.value, lastPoint.value) : null;
|
| 903 |
+
const trendText = percent === null
|
| 904 |
+
? `${delta >= 0 ? "+" : "-"}${formatNumber(Math.abs(delta))}`
|
| 905 |
+
: `${percent >= 0 ? "+" : ""}${d3.format(".0f")(percent)}%`;
|
| 906 |
+
const trendX = cardWidth - 142;
|
| 907 |
+
const trendY = cardHeight - 76;
|
| 908 |
+
panel.append("rect")
|
| 909 |
+
.attr("class", "reference-trend-badge")
|
| 910 |
+
.attr("data-group", series.group)
|
| 911 |
+
.attr("x", trendX)
|
| 912 |
+
.attr("y", trendY)
|
| 913 |
+
.attr("width", 118)
|
| 914 |
+
.attr("height", 52)
|
| 915 |
+
.attr("rx", 10)
|
| 916 |
+
.attr("ry", 10)
|
| 917 |
+
.attr("fill", rgba(seriesColor, 0.12))
|
| 918 |
+
.attr("stroke", rgba(seriesColor, 0.26))
|
| 919 |
+
.attr("stroke-width", 1);
|
| 920 |
+
drawTrendArrow(panel, delta, seriesColor, trendX + 26, trendY + 25, 29);
|
| 921 |
+
panel.append("text")
|
| 922 |
+
.attr("class", "reference-trend-percent")
|
| 923 |
+
.attr("data-group", series.group)
|
| 924 |
+
.attr("data-first-value", firstPoint?.value ?? "")
|
| 925 |
+
.attr("data-last-value", lastPoint?.value ?? "")
|
| 926 |
+
.attr("x", trendX + 49)
|
| 927 |
+
.attr("y", trendY + 23)
|
| 928 |
+
.style("font-family", valueFamily)
|
| 929 |
+
.style("font-size", `${Math.max(17, Math.min(baseValueSize * 1.9, 22))}px`)
|
| 930 |
+
.style("font-weight", 800)
|
| 931 |
+
.style("fill", seriesColor)
|
| 932 |
+
.text(trendText);
|
| 933 |
+
panel.append("text")
|
| 934 |
+
.attr("class", "reference-trend-period")
|
| 935 |
+
.attr("x", trendX + 49)
|
| 936 |
+
.attr("y", trendY + 42)
|
| 937 |
+
.style("font-family", labelFamily)
|
| 938 |
+
.style("font-size", `${Math.max(10, Math.min(baseLabelSize, 12))}px`)
|
| 939 |
+
.style("font-weight", labelWeight)
|
| 940 |
+
.style("fill", navy)
|
| 941 |
+
.text(`${firstPoint?.label || ""}-${lastPoint?.label || ""}`);
|
| 942 |
+
});
|
| 943 |
+
|
| 944 |
+
const footer = svg.append("g")
|
| 945 |
+
.attr("class", "reference-footer-note")
|
| 946 |
+
.attr("transform", `translate(${sidePad}, ${footerY})`);
|
| 947 |
+
footer.append("rect")
|
| 948 |
+
.attr("class", "reference-footer-box")
|
| 949 |
+
.attr("x", 0)
|
| 950 |
+
.attr("y", 0)
|
| 951 |
+
.attr("width", width - sidePad * 2)
|
| 952 |
+
.attr("height", footerH)
|
| 953 |
+
.attr("rx", 14)
|
| 954 |
+
.attr("ry", 14)
|
| 955 |
+
.attr("fill", "#ffffff")
|
| 956 |
+
.attr("stroke", rgba(accentBlue, 0.28))
|
| 957 |
+
.attr("stroke-width", 1.2);
|
| 958 |
+
|
| 959 |
+
const footerIcon = footer.append("g")
|
| 960 |
+
.attr("class", "reference-footer-icon-slot")
|
| 961 |
+
.attr("data-slot", "takeaway-chart-icon")
|
| 962 |
+
.attr("transform", `translate(48, ${footerH / 2})`);
|
| 963 |
+
footerIcon.append("circle").attr("r", 35).attr("fill", accentBlue);
|
| 964 |
+
[0, 1, 2].forEach(i => {
|
| 965 |
+
footerIcon.append("rect")
|
| 966 |
+
.attr("x", -16 + i * 12)
|
| 967 |
+
.attr("y", 12 - i * 9)
|
| 968 |
+
.attr("width", 8)
|
| 969 |
+
.attr("height", 16 + i * 9)
|
| 970 |
+
.attr("fill", "#ffffff")
|
| 971 |
+
.attr("opacity", 0.94);
|
| 972 |
+
});
|
| 973 |
+
footerIcon.append("path")
|
| 974 |
+
.attr("d", "M -18 0 L -3 -12 L 7 -4 L 20 -22")
|
| 975 |
+
.attr("fill", "none")
|
| 976 |
+
.attr("stroke", "#ffffff")
|
| 977 |
+
.attr("stroke-width", 4)
|
| 978 |
+
.attr("stroke-linecap", "round")
|
| 979 |
+
.attr("stroke-linejoin", "round");
|
| 980 |
+
footerIcon.append("path")
|
| 981 |
+
.attr("d", "M 20 -22 L 10 -20 L 20 -22 L 19 -12")
|
| 982 |
+
.attr("fill", "none")
|
| 983 |
+
.attr("stroke", "#ffffff")
|
| 984 |
+
.attr("stroke-width", 4)
|
| 985 |
+
.attr("stroke-linecap", "round")
|
| 986 |
+
.attr("stroke-linejoin", "round");
|
| 987 |
+
|
| 988 |
+
footer.append("text")
|
| 989 |
+
.attr("class", "reference-footer-title")
|
| 990 |
+
.attr("x", 98)
|
| 991 |
+
.attr("y", 36)
|
| 992 |
+
.style("font-family", titleFamily)
|
| 993 |
+
.style("font-size", `${Math.max(18, Math.min(24, baseLabelSize * 2))}px`)
|
| 994 |
+
.style("font-weight", 800)
|
| 995 |
+
.style("fill", navy)
|
| 996 |
+
.text("Key Takeaway");
|
| 997 |
+
|
| 998 |
+
const footerLines = wrapText(`${takeaway} ${scaleNote}`, Math.max(38, Math.floor((width - 360) / 7.5)), 3);
|
| 999 |
+
const footerText = footer.append("text")
|
| 1000 |
+
.attr("class", "scale-note reference-scale-note")
|
| 1001 |
+
.attr("x", 98)
|
| 1002 |
+
.attr("y", 62)
|
| 1003 |
+
.style("font-family", labelFamily)
|
| 1004 |
+
.style("font-size", `${Math.max(12, Math.min(baseLabelSize * 1.18, 15))}px`)
|
| 1005 |
+
.style("font-weight", 400)
|
| 1006 |
+
.style("fill", mutedText);
|
| 1007 |
+
footerLines.forEach((lineText, lineIndex) => {
|
| 1008 |
+
footerText.append("tspan")
|
| 1009 |
+
.attr("x", 98)
|
| 1010 |
+
.attr("dy", lineIndex === 0 ? 0 : 19)
|
| 1011 |
+
.text(lineText);
|
| 1012 |
+
});
|
| 1013 |
+
|
| 1014 |
+
drawFooterDecoration(footer, width - sidePad * 2 - 242, 18, 216, 82, accentBlue);
|
| 1015 |
+
|
| 1016 |
+
return svg.node();
|
| 1017 |
+
}
|