Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
modules/chart_engine/template/d3-js/type30_small_multiples_spline_graphs/small_multiples_line_graphs_plain_chart_01.js
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "small multiples of spline graphs",
|
| 5 |
+
"chart_name": "small_multiples_spline_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": 600,
|
| 16 |
+
"min_width": 800,
|
| 17 |
+
"background": "light",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "none",
|
| 20 |
+
"has_x_axis": "yes",
|
| 21 |
+
"has_y_axis": "yes"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data || {};
|
| 28 |
+
const chartData = (jsonData.data && jsonData.data.data) || [];
|
| 29 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 30 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 31 |
+
const chartUtilsFormatSample = chartUtils.format.autoText;
|
| 32 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 33 |
+
const chartUtilsStandard = {
|
| 34 |
+
schema: chartUtils.schema,
|
| 35 |
+
format: chartUtils.format,
|
| 36 |
+
text: chartUtils.text,
|
| 37 |
+
color: chartUtils.color,
|
| 38 |
+
legendLayout: chartUtils.legend.layout,
|
| 39 |
+
legendDraw: chartUtils.legend.draw,
|
| 40 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 41 |
+
};
|
| 42 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 43 |
+
x: { fallbackIndex: 0 },
|
| 44 |
+
y: { fallbackIndex: 1 },
|
| 45 |
+
y2: { fallbackIndex: 2 },
|
| 46 |
+
y3: { fallbackIndex: 3 },
|
| 47 |
+
size: { fallbackIndex: 2 },
|
| 48 |
+
group: { fallbackIndex: 2 },
|
| 49 |
+
group2: { fallbackIndex: 3 },
|
| 50 |
+
group3: { fallbackIndex: 4 }
|
| 51 |
+
});
|
| 52 |
+
const variables = jsonData.variables || {};
|
| 53 |
+
const typography = jsonData.typography || {
|
| 54 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 55 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 56 |
+
title: { font_family: "Arial", font_size: "14px", font_weight: "bold" }
|
| 57 |
+
};
|
| 58 |
+
const sourceColors = jsonData.colors || {};
|
| 59 |
+
const colorField = sourceColors.field || {};
|
| 60 |
+
const colorOther = sourceColors.other || {};
|
| 61 |
+
|
| 62 |
+
d3.select(containerSelector).html("");
|
| 63 |
+
|
| 64 |
+
const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
|
| 65 |
+
const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
|
| 66 |
+
const groupColumn = chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || {};
|
| 67 |
+
const xField = xColumn.name || "date";
|
| 68 |
+
const yField = yColumn.name || "value";
|
| 69 |
+
const groupField = groupColumn.name || "group";
|
| 70 |
+
const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
|
| 71 |
+
|
| 72 |
+
const width = Number(variables.width) || 800;
|
| 73 |
+
const height = Number(variables.height) || 620;
|
| 74 |
+
const textColor = sourceColors.text_color || "#243041";
|
| 75 |
+
const primaryColor = colorOther.primary || "#2f7db7";
|
| 76 |
+
const secondaryColor = colorOther.secondary || "#6d9b45";
|
| 77 |
+
const canvasColor = "#f6fbf7";
|
| 78 |
+
const panelColor = "#fffef8";
|
| 79 |
+
const cardColor = "#ffffff";
|
| 80 |
+
const splinePalette = ["#f19a22", "#6fa843", "#2f7db7", "#d5533d", "#7f69b1", "#2d9c9c", "#c97a9b", "#5a6f8f"];
|
| 81 |
+
const labelTypography = typography.label || {};
|
| 82 |
+
const titleTypography = typography.title || labelTypography;
|
| 83 |
+
const valueTypography = typography.annotation || labelTypography;
|
| 84 |
+
const labelFamily = labelTypography.font_family || "Arial";
|
| 85 |
+
const titleFamily = titleTypography.font_family || labelFamily;
|
| 86 |
+
const valueFamily = valueTypography.font_family || labelFamily;
|
| 87 |
+
const labelWeight = labelTypography.font_weight || "normal";
|
| 88 |
+
const titleWeight = titleTypography.font_weight || "700";
|
| 89 |
+
const valueWeight = valueTypography.font_weight || "600";
|
| 90 |
+
const baseLabelSize = parseFloat(labelTypography.font_size) || 11;
|
| 91 |
+
const baseTitleSize = parseFloat(titleTypography.font_size) || 13;
|
| 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 parseTemporalDate(value) {
|
| 100 |
+
if (value instanceof Date && Number.isFinite(value.getTime())) return value;
|
| 101 |
+
const text = String(value ?? "").trim();
|
| 102 |
+
if (!text) return null;
|
| 103 |
+
|
| 104 |
+
let match = text.match(/^(-?\d{4})$/);
|
| 105 |
+
if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
|
| 106 |
+
|
| 107 |
+
match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
|
| 108 |
+
if (match) {
|
| 109 |
+
return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
match = text.match(/^Q([1-4])\s+(-?\d{4})$/i) || text.match(/^(-?\d{4})\s+Q([1-4])$/i);
|
| 113 |
+
if (match) {
|
| 114 |
+
const year = match[1].length === 4 ? Number(match[1]) : Number(match[2]);
|
| 115 |
+
const quarter = match[1].length === 4 ? Number(match[2]) : Number(match[1]);
|
| 116 |
+
return new Date(Date.UTC(year, (quarter - 1) * 3, 1));
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const season = text.match(/^(spring|summer|fall|autumn|winter)\s+(-?\d{4})$/i);
|
| 120 |
+
if (season) {
|
| 121 |
+
const monthBySeason = { spring: 2, summer: 5, fall: 8, autumn: 8, winter: 11 };
|
| 122 |
+
return new Date(Date.UTC(Number(season[2]), monthBySeason[season[1].toLowerCase()], 1));
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
const parsed = new Date(text);
|
| 126 |
+
if (Number.isFinite(parsed.getTime())) {
|
| 127 |
+
return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()));
|
| 128 |
+
}
|
| 129 |
+
return null;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function formatNumber(value) {
|
| 133 |
+
const numericValue = Number(value);
|
| 134 |
+
if (!Number.isFinite(numericValue)) return String(value ?? "");
|
| 135 |
+
const absValue = Math.abs(numericValue);
|
| 136 |
+
if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
|
| 137 |
+
if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
|
| 138 |
+
if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}K`;
|
| 139 |
+
return d3.format(",.4~g")(numericValue);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function formatWithUnit(value, unit) {
|
| 143 |
+
const formatted = formatNumber(value);
|
| 144 |
+
if (!unit) return formatted;
|
| 145 |
+
if (unit === "$") return `$${formatted}`;
|
| 146 |
+
if (unit === "%") return `${formatted}%`;
|
| 147 |
+
return `${formatted} ${unit}`;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function wrapText(text, maxChars) {
|
| 151 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 152 |
+
if (!words.length) return [String(text || "")];
|
| 153 |
+
const lines = [];
|
| 154 |
+
let current = "";
|
| 155 |
+
words.forEach(word => {
|
| 156 |
+
const next = current ? `${current} ${word}` : word;
|
| 157 |
+
if (next.length <= maxChars) {
|
| 158 |
+
current = next;
|
| 159 |
+
return;
|
| 160 |
+
}
|
| 161 |
+
if (current) lines.push(current);
|
| 162 |
+
if (word.length <= maxChars) {
|
| 163 |
+
current = word;
|
| 164 |
+
return;
|
| 165 |
+
}
|
| 166 |
+
let chunk = "";
|
| 167 |
+
for (const char of word) {
|
| 168 |
+
if ((chunk + char).length > maxChars) {
|
| 169 |
+
lines.push(`${chunk}-`);
|
| 170 |
+
chunk = char;
|
| 171 |
+
} else {
|
| 172 |
+
chunk += char;
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
current = chunk;
|
| 176 |
+
});
|
| 177 |
+
if (current) lines.push(current);
|
| 178 |
+
return lines.length ? lines : [String(text || "")];
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
function selectEvenTicks(values, maxTicks) {
|
| 182 |
+
if (values.length <= maxTicks) return values;
|
| 183 |
+
const selected = [];
|
| 184 |
+
for (let i = 0; i < maxTicks; i += 1) {
|
| 185 |
+
const index = Math.round(i * (values.length - 1) / (maxTicks - 1));
|
| 186 |
+
const value = values[index];
|
| 187 |
+
if (!selected.includes(value)) selected.push(value);
|
| 188 |
+
}
|
| 189 |
+
return selected;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
const parsedRows = chartData.map((d, index) => {
|
| 193 |
+
const date = parseTemporalDate(d[xField]);
|
| 194 |
+
const value = Number(d[yField]);
|
| 195 |
+
const group = String(d[groupField] ?? "");
|
| 196 |
+
return {
|
| 197 |
+
...d,
|
| 198 |
+
__index: index,
|
| 199 |
+
__date: date,
|
| 200 |
+
__time: date ? date.getTime() : NaN,
|
| 201 |
+
__xLabel: String(d[xField] ?? ""),
|
| 202 |
+
__value: value,
|
| 203 |
+
__group: group
|
| 204 |
+
};
|
| 205 |
+
}).filter(d => d.__date && Number.isFinite(d.__time) && Number.isFinite(d.__value) && d.__group !== "");
|
| 206 |
+
|
| 207 |
+
if (!parsedRows.length) return;
|
| 208 |
+
|
| 209 |
+
const groups = [];
|
| 210 |
+
parsedRows.forEach(d => {
|
| 211 |
+
if (!groups.includes(d.__group)) groups.push(d.__group);
|
| 212 |
+
});
|
| 213 |
+
if (!groups.length) return;
|
| 214 |
+
|
| 215 |
+
const xLabelByTime = new Map();
|
| 216 |
+
parsedRows.forEach(d => {
|
| 217 |
+
if (!xLabelByTime.has(d.__time)) xLabelByTime.set(d.__time, d.__xLabel);
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
const groupSeries = groups.map(group => {
|
| 221 |
+
const byTime = new Map();
|
| 222 |
+
parsedRows.filter(d => d.__group === group).forEach(d => {
|
| 223 |
+
if (!byTime.has(d.__time)) {
|
| 224 |
+
byTime.set(d.__time, {
|
| 225 |
+
date: d.__date,
|
| 226 |
+
time: d.__time,
|
| 227 |
+
label: d.__xLabel,
|
| 228 |
+
valueSum: 0,
|
| 229 |
+
count: 0
|
| 230 |
+
});
|
| 231 |
+
}
|
| 232 |
+
const entry = byTime.get(d.__time);
|
| 233 |
+
entry.valueSum += d.__value;
|
| 234 |
+
entry.count += 1;
|
| 235 |
+
});
|
| 236 |
+
const values = Array.from(byTime.values())
|
| 237 |
+
.map(d => ({
|
| 238 |
+
date: d.date,
|
| 239 |
+
time: d.time,
|
| 240 |
+
label: d.label,
|
| 241 |
+
value: d.valueSum / d.count
|
| 242 |
+
}))
|
| 243 |
+
.sort((a, b) => a.time - b.time);
|
| 244 |
+
return { group, values };
|
| 245 |
+
}).filter(d => d.values.length);
|
| 246 |
+
|
| 247 |
+
if (!groupSeries.length) return;
|
| 248 |
+
|
| 249 |
+
const xValues = Array.from(new Set(parsedRows.map(d => d.__time)))
|
| 250 |
+
.sort((a, b) => a - b)
|
| 251 |
+
.map(time => new Date(time));
|
| 252 |
+
const allValues = groupSeries.flatMap(series => series.values.map(d => d.value));
|
| 253 |
+
let yMin = d3.min(allValues);
|
| 254 |
+
let yMax = d3.max(allValues);
|
| 255 |
+
if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) return;
|
| 256 |
+
const rawYMin = yMin;
|
| 257 |
+
const rawYMax = yMax;
|
| 258 |
+
if (yMin === yMax) {
|
| 259 |
+
const pad = Math.max(1, Math.abs(yMin) * 0.1);
|
| 260 |
+
yMin -= pad;
|
| 261 |
+
yMax += pad;
|
| 262 |
+
} else {
|
| 263 |
+
const pad = (yMax - yMin) * 0.1;
|
| 264 |
+
yMin -= pad;
|
| 265 |
+
yMax += pad;
|
| 266 |
+
}
|
| 267 |
+
if (rawYMin >= 0) yMin = Math.max(0, yMin);
|
| 268 |
+
if (rawYMax <= 0) yMax = Math.min(0, yMax);
|
| 269 |
+
if (yMin > 0 && (yMax - yMin) / Math.max(1, Math.abs(yMax)) > 0.55) yMin = 0;
|
| 270 |
+
|
| 271 |
+
const cols = groupSeries.length <= 2 ? groupSeries.length : (groupSeries.length <= 4 ? 2 : 3);
|
| 272 |
+
const rows = Math.ceil(groupSeries.length / cols);
|
| 273 |
+
const outer = { top: 20, right: 22, bottom: 28, left: 22 };
|
| 274 |
+
const gapX = variables.has_spacing === false ? 14 : 20;
|
| 275 |
+
const gapY = variables.has_spacing === false ? 18 : 24;
|
| 276 |
+
const panelWidth = (width - outer.left - outer.right - gapX * (cols - 1)) / cols;
|
| 277 |
+
const panelHeight = (height - outer.top - outer.bottom - gapY * (rows - 1)) / rows;
|
| 278 |
+
const plot = {
|
| 279 |
+
left: Math.min(50, Math.max(40, panelWidth * 0.18)),
|
| 280 |
+
right: 14,
|
| 281 |
+
top: 52,
|
| 282 |
+
bottom: 36
|
| 283 |
+
};
|
| 284 |
+
const innerWidth = Math.max(80, panelWidth - plot.left - plot.right);
|
| 285 |
+
const innerHeight = Math.max(70, panelHeight - plot.top - plot.bottom);
|
| 286 |
+
const tickCount = innerWidth < 145 ? 2 : (innerWidth < 230 ? 3 : 4);
|
| 287 |
+
const xTicks = selectEvenTicks(xValues, tickCount);
|
| 288 |
+
|
| 289 |
+
const xScale = d3.scaleUtc()
|
| 290 |
+
.domain(d3.extent(xValues))
|
| 291 |
+
.range([0, innerWidth]);
|
| 292 |
+
const yScale = d3.scaleLinear()
|
| 293 |
+
.domain([yMin, yMax])
|
| 294 |
+
.nice(4)
|
| 295 |
+
.range([innerHeight, 0]);
|
| 296 |
+
const yTicks = yScale.ticks(innerHeight < 95 ? 3 : 4);
|
| 297 |
+
const yDomain = yScale.domain();
|
| 298 |
+
const areaBaselineValue = Math.max(yDomain[0], Math.min(0, yDomain[1]));
|
| 299 |
+
const areaBaselineY = yScale(areaBaselineValue);
|
| 300 |
+
|
| 301 |
+
const palette = d3.schemeTableau10 || d3.schemeCategory10;
|
| 302 |
+
const colorScale = d3.scaleOrdinal()
|
| 303 |
+
.domain(groups)
|
| 304 |
+
.range(groups.map((group, index) => {
|
| 305 |
+
if (colorField[group]) return colorField[group];
|
| 306 |
+
return splinePalette[index % splinePalette.length] || palette[index % palette.length] || primaryColor;
|
| 307 |
+
}));
|
| 308 |
+
|
| 309 |
+
function formatXTick(date) {
|
| 310 |
+
const label = xLabelByTime.get(date.getTime());
|
| 311 |
+
if (label) return label;
|
| 312 |
+
return d3.utcFormat("%Y")(date);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
const yScaleNote = rawYMin >= 0
|
| 316 |
+
? "Shared y-scale starts at zero for nonnegative data."
|
| 317 |
+
: "All panels use a shared y-scale from the original values.";
|
| 318 |
+
|
| 319 |
+
const spline = d3.line()
|
| 320 |
+
.defined(d => Number.isFinite(d.value))
|
| 321 |
+
.x(d => xScale(d.date))
|
| 322 |
+
.y(d => yScale(d.value))
|
| 323 |
+
.curve(d3.curveMonotoneX);
|
| 324 |
+
|
| 325 |
+
const area = d3.area()
|
| 326 |
+
.defined(d => Number.isFinite(d.value))
|
| 327 |
+
.x(d => xScale(d.date))
|
| 328 |
+
.y0(areaBaselineY)
|
| 329 |
+
.y1(d => yScale(d.value))
|
| 330 |
+
.curve(d3.curveMonotoneX);
|
| 331 |
+
|
| 332 |
+
const svg = d3.select(containerSelector)
|
| 333 |
+
.append("svg")
|
| 334 |
+
.attr("width", "100%")
|
| 335 |
+
.attr("height", height)
|
| 336 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 337 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 338 |
+
.attr("xmlns", "http://www.w3.org/2000/svg");
|
| 339 |
+
|
| 340 |
+
const defs = svg.append("defs");
|
| 341 |
+
const panelGradient = defs.append("linearGradient")
|
| 342 |
+
.attr("id", "small_multiples_spline_01_panel_gradient")
|
| 343 |
+
.attr("x1", "0%")
|
| 344 |
+
.attr("x2", "0%")
|
| 345 |
+
.attr("y1", "0%")
|
| 346 |
+
.attr("y2", "100%");
|
| 347 |
+
panelGradient.append("stop").attr("offset", "0%").attr("stop-color", "#fffefb");
|
| 348 |
+
panelGradient.append("stop").attr("offset", "100%").attr("stop-color", panelColor);
|
| 349 |
+
const cardShadow = defs.append("filter")
|
| 350 |
+
.attr("id", "small_multiples_spline_01_card_shadow")
|
| 351 |
+
.attr("x", "-12%")
|
| 352 |
+
.attr("y", "-18%")
|
| 353 |
+
.attr("width", "124%")
|
| 354 |
+
.attr("height", "140%");
|
| 355 |
+
cardShadow.append("feDropShadow")
|
| 356 |
+
.attr("dx", 0)
|
| 357 |
+
.attr("dy", 1.4)
|
| 358 |
+
.attr("stdDeviation", 1.5)
|
| 359 |
+
.attr("flood-color", "#456351")
|
| 360 |
+
.attr("flood-opacity", 0.12);
|
| 361 |
+
|
| 362 |
+
svg.append("rect")
|
| 363 |
+
.attr("class", "chart-canvas")
|
| 364 |
+
.attr("x", 0)
|
| 365 |
+
.attr("y", 0)
|
| 366 |
+
.attr("width", width)
|
| 367 |
+
.attr("height", height)
|
| 368 |
+
.attr("fill", canvasColor);
|
| 369 |
+
|
| 370 |
+
svg.append("rect")
|
| 371 |
+
.attr("class", "chart-style-panel")
|
| 372 |
+
.attr("x", 9)
|
| 373 |
+
.attr("y", 9)
|
| 374 |
+
.attr("width", width - 18)
|
| 375 |
+
.attr("height", height - 18)
|
| 376 |
+
.attr("rx", 12)
|
| 377 |
+
.attr("ry", 12)
|
| 378 |
+
.attr("fill", "url(#small_multiples_spline_01_panel_gradient)")
|
| 379 |
+
.attr("stroke", "rgba(111, 168, 67, 0.28)")
|
| 380 |
+
.attr("stroke-width", 1.1);
|
| 381 |
+
|
| 382 |
+
groupSeries.forEach((series, index) => {
|
| 383 |
+
const row = Math.floor(index / cols);
|
| 384 |
+
const col = index % cols;
|
| 385 |
+
const panelX = outer.left + col * (panelWidth + gapX);
|
| 386 |
+
const panelY = outer.top + row * (panelHeight + gapY);
|
| 387 |
+
const panel = svg.append("g")
|
| 388 |
+
.attr("class", "subplot")
|
| 389 |
+
.attr("data-group", series.group)
|
| 390 |
+
.attr("transform", `translate(${panelX}, ${panelY})`);
|
| 391 |
+
|
| 392 |
+
panel.append("rect")
|
| 393 |
+
.attr("class", "subplot-background subplot-card")
|
| 394 |
+
.attr("width", panelWidth)
|
| 395 |
+
.attr("height", panelHeight)
|
| 396 |
+
.attr("rx", 10)
|
| 397 |
+
.attr("ry", 10)
|
| 398 |
+
.attr("fill", cardColor)
|
| 399 |
+
.attr("stroke", rgba(colorScale(series.group), 0.22))
|
| 400 |
+
.attr("stroke-width", 0.9)
|
| 401 |
+
.style("filter", "url(#small_multiples_spline_01_card_shadow)");
|
| 402 |
+
|
| 403 |
+
panel.append("rect")
|
| 404 |
+
.attr("class", "subplot-accent")
|
| 405 |
+
.attr("x", 0)
|
| 406 |
+
.attr("y", 0)
|
| 407 |
+
.attr("width", panelWidth)
|
| 408 |
+
.attr("height", 30)
|
| 409 |
+
.attr("rx", 10)
|
| 410 |
+
.attr("ry", 10)
|
| 411 |
+
.attr("fill", rgba(colorScale(series.group), 0.14))
|
| 412 |
+
.attr("stroke", rgba(colorScale(series.group), 0.36))
|
| 413 |
+
.attr("stroke-width", 0.8);
|
| 414 |
+
|
| 415 |
+
const titleLines = wrapText(series.group, Math.max(9, Math.floor((panelWidth - 20) / 7.2)));
|
| 416 |
+
panel.append("circle")
|
| 417 |
+
.attr("class", "group-swatch")
|
| 418 |
+
.attr("cx", 21)
|
| 419 |
+
.attr("cy", 15)
|
| 420 |
+
.attr("r", 6)
|
| 421 |
+
.attr("fill", colorScale(series.group))
|
| 422 |
+
.attr("opacity", 0.94);
|
| 423 |
+
|
| 424 |
+
const title = panel.append("text")
|
| 425 |
+
.attr("class", "label group-label")
|
| 426 |
+
.attr("data-group", series.group)
|
| 427 |
+
.attr("x", 34)
|
| 428 |
+
.attr("y", 19)
|
| 429 |
+
.attr("text-anchor", "start")
|
| 430 |
+
.style("font-family", titleFamily)
|
| 431 |
+
.style("font-size", `${Math.max(10, Math.min(baseTitleSize, 13))}px`)
|
| 432 |
+
.style("font-weight", titleWeight)
|
| 433 |
+
.style("fill", textColor);
|
| 434 |
+
titleLines.slice(0, 3).forEach((lineText, lineIndex) => {
|
| 435 |
+
title.append("tspan")
|
| 436 |
+
.attr("x", 34)
|
| 437 |
+
.attr("dy", lineIndex === 0 ? 0 : 12)
|
| 438 |
+
.text(lineText);
|
| 439 |
+
});
|
| 440 |
+
|
| 441 |
+
const chart = panel.append("g")
|
| 442 |
+
.attr("class", "panel-plot")
|
| 443 |
+
.attr("transform", `translate(${plot.left}, ${plot.top})`);
|
| 444 |
+
|
| 445 |
+
yTicks.forEach(tick => {
|
| 446 |
+
chart.append("line")
|
| 447 |
+
.attr("class", "gridline")
|
| 448 |
+
.attr("x1", 0)
|
| 449 |
+
.attr("x2", innerWidth)
|
| 450 |
+
.attr("y1", yScale(tick))
|
| 451 |
+
.attr("y2", yScale(tick))
|
| 452 |
+
.attr("stroke", rgba(secondaryColor, 0.16))
|
| 453 |
+
.attr("stroke-width", 1);
|
| 454 |
+
chart.append("text")
|
| 455 |
+
.attr("class", "axis-tick y-axis-tick")
|
| 456 |
+
.attr("x", -7)
|
| 457 |
+
.attr("y", yScale(tick))
|
| 458 |
+
.attr("text-anchor", "end")
|
| 459 |
+
.attr("dominant-baseline", "middle")
|
| 460 |
+
.style("font-family", labelFamily)
|
| 461 |
+
.style("font-size", `${Math.max(8.5, Math.min(baseLabelSize, 10))}px`)
|
| 462 |
+
.style("font-weight", labelWeight)
|
| 463 |
+
.style("fill", rgba(textColor, 0.62))
|
| 464 |
+
.text(formatWithUnit(tick, yUnit));
|
| 465 |
+
});
|
| 466 |
+
|
| 467 |
+
chart.append("line")
|
| 468 |
+
.attr("class", "axis-line x-axis-line")
|
| 469 |
+
.attr("x1", 0)
|
| 470 |
+
.attr("x2", innerWidth)
|
| 471 |
+
.attr("y1", innerHeight)
|
| 472 |
+
.attr("y2", innerHeight)
|
| 473 |
+
.attr("stroke", rgba(textColor, 0.28))
|
| 474 |
+
.attr("stroke-width", 1);
|
| 475 |
+
|
| 476 |
+
xTicks.forEach(tick => {
|
| 477 |
+
chart.append("line")
|
| 478 |
+
.attr("class", "axis-tick-line")
|
| 479 |
+
.attr("x1", xScale(tick))
|
| 480 |
+
.attr("x2", xScale(tick))
|
| 481 |
+
.attr("y1", innerHeight)
|
| 482 |
+
.attr("y2", innerHeight + 4)
|
| 483 |
+
.attr("stroke", rgba(textColor, 0.36))
|
| 484 |
+
.attr("stroke-width", 1);
|
| 485 |
+
chart.append("text")
|
| 486 |
+
.attr("class", "axis-tick x-axis-tick")
|
| 487 |
+
.attr("x", xScale(tick))
|
| 488 |
+
.attr("y", innerHeight + 17)
|
| 489 |
+
.attr("text-anchor", "middle")
|
| 490 |
+
.style("font-family", labelFamily)
|
| 491 |
+
.style("font-size", `${Math.max(8.2, Math.min(baseLabelSize, 9.5))}px`)
|
| 492 |
+
.style("font-weight", labelWeight)
|
| 493 |
+
.style("fill", rgba(textColor, 0.72))
|
| 494 |
+
.text(formatXTick(tick));
|
| 495 |
+
});
|
| 496 |
+
|
| 497 |
+
chart.append("path")
|
| 498 |
+
.datum(series.values)
|
| 499 |
+
.attr("class", "spline-area mark")
|
| 500 |
+
.attr("data-group", series.group)
|
| 501 |
+
.attr("data-count", series.values.length)
|
| 502 |
+
.attr("fill", rgba(colorScale(series.group), 0.16))
|
| 503 |
+
.attr("stroke", "none")
|
| 504 |
+
.attr("d", area);
|
| 505 |
+
|
| 506 |
+
chart.append("path")
|
| 507 |
+
.datum(series.values)
|
| 508 |
+
.attr("class", "spline line mark")
|
| 509 |
+
.attr("data-group", series.group)
|
| 510 |
+
.attr("data-count", series.values.length)
|
| 511 |
+
.attr("data-latest-value", series.values[series.values.length - 1]?.value ?? "")
|
| 512 |
+
.attr("fill", "none")
|
| 513 |
+
.attr("stroke", colorScale(series.group))
|
| 514 |
+
.attr("stroke-width", variables.has_stroke === false ? 2.6 : 3.2)
|
| 515 |
+
.attr("stroke-linecap", "round")
|
| 516 |
+
.attr("stroke-linejoin", "round")
|
| 517 |
+
.attr("d", spline);
|
| 518 |
+
|
| 519 |
+
chart.selectAll(".point")
|
| 520 |
+
.data(series.values)
|
| 521 |
+
.enter()
|
| 522 |
+
.append("circle")
|
| 523 |
+
.attr("class", "point mark")
|
| 524 |
+
.attr("data-group", series.group)
|
| 525 |
+
.attr("data-time", d => d.time)
|
| 526 |
+
.attr("data-label", d => d.label)
|
| 527 |
+
.attr("data-value", d => d.value)
|
| 528 |
+
.attr("cx", d => xScale(d.date))
|
| 529 |
+
.attr("cy", d => yScale(d.value))
|
| 530 |
+
.attr("r", 3)
|
| 531 |
+
.attr("fill", "#ffffff")
|
| 532 |
+
.attr("stroke", colorScale(series.group))
|
| 533 |
+
.attr("stroke-width", 1.7);
|
| 534 |
+
|
| 535 |
+
const lastPoint = series.values[series.values.length - 1];
|
| 536 |
+
if (lastPoint) {
|
| 537 |
+
const latestText = formatWithUnit(lastPoint.value, yUnit);
|
| 538 |
+
const latestFontSize = Math.max(8.5, Math.min(baseValueSize, 10.5));
|
| 539 |
+
const latestY = Math.max(9, Math.min(innerHeight - 5, yScale(lastPoint.value) - 8));
|
| 540 |
+
const pillWidth = Math.max(34, latestText.length * latestFontSize * 0.58 + 12);
|
| 541 |
+
chart.append("rect")
|
| 542 |
+
.attr("class", "latest-value-pill")
|
| 543 |
+
.attr("data-group", series.group)
|
| 544 |
+
.attr("x", innerWidth - pillWidth)
|
| 545 |
+
.attr("y", latestY - latestFontSize + 1)
|
| 546 |
+
.attr("width", pillWidth)
|
| 547 |
+
.attr("height", latestFontSize + 8)
|
| 548 |
+
.attr("rx", 7)
|
| 549 |
+
.attr("ry", 7)
|
| 550 |
+
.attr("fill", "#fffdf8")
|
| 551 |
+
.attr("stroke", rgba(colorScale(series.group), 0.32))
|
| 552 |
+
.attr("stroke-width", 0.9);
|
| 553 |
+
chart.append("text")
|
| 554 |
+
.attr("class", "value latest-value")
|
| 555 |
+
.attr("data-group", series.group)
|
| 556 |
+
.attr("data-time", lastPoint.time)
|
| 557 |
+
.attr("data-label", lastPoint.label)
|
| 558 |
+
.attr("data-value", lastPoint.value)
|
| 559 |
+
.attr("x", innerWidth)
|
| 560 |
+
.attr("y", latestY)
|
| 561 |
+
.attr("text-anchor", "end")
|
| 562 |
+
.style("font-family", valueFamily)
|
| 563 |
+
.style("font-size", `${latestFontSize}px`)
|
| 564 |
+
.style("font-weight", valueWeight)
|
| 565 |
+
.style("fill", colorScale(series.group))
|
| 566 |
+
.text(latestText);
|
| 567 |
+
}
|
| 568 |
+
});
|
| 569 |
+
|
| 570 |
+
svg.append("text")
|
| 571 |
+
.attr("class", "scale-note")
|
| 572 |
+
.attr("x", width / 2)
|
| 573 |
+
.attr("y", height - 13)
|
| 574 |
+
.attr("text-anchor", "middle")
|
| 575 |
+
.style("font-family", valueFamily)
|
| 576 |
+
.style("font-size", `${Math.max(8, Math.min(baseValueSize, 10))}px`)
|
| 577 |
+
.style("font-style", "italic")
|
| 578 |
+
.style("fill", rgba(textColor, 0.58))
|
| 579 |
+
.text(yScaleNote);
|
| 580 |
+
|
| 581 |
+
return svg.node();
|
| 582 |
+
}
|