Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
modules/chart_engine/template/d3-js/type31_small_multiples_of_stepped_line_graphs/small_multiple_step_line_graph_icons_01.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Small Multiple Step Line Graph",
|
| 5 |
+
"chart_name": "small_multiple_step_line_graph_icons_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[3, 30], ["-inf", "inf"], [2, 6]],
|
| 9 |
+
"required_fields_icons": ["group"],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 400,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "side",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, dataJSON) {
|
| 26 |
+
const jsonData = dataJSON || {};
|
| 27 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 28 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 29 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 30 |
+
const chartUtilsStandard = {
|
| 31 |
+
schema: chartUtils.schema,
|
| 32 |
+
format: chartUtils.format,
|
| 33 |
+
text: chartUtils.text,
|
| 34 |
+
color: chartUtils.color,
|
| 35 |
+
legendLayout: chartUtils.legend.layout,
|
| 36 |
+
legendDraw: chartUtils.legend.draw,
|
| 37 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 38 |
+
};
|
| 39 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 40 |
+
x: { fallbackIndex: 0 },
|
| 41 |
+
y: { fallbackIndex: 1 },
|
| 42 |
+
y2: { fallbackIndex: 2 },
|
| 43 |
+
y3: { fallbackIndex: 3 },
|
| 44 |
+
size: { fallbackIndex: 2 },
|
| 45 |
+
group: { fallbackIndex: 2 },
|
| 46 |
+
group2: { fallbackIndex: 3 },
|
| 47 |
+
group3: { fallbackIndex: 4 }
|
| 48 |
+
});
|
| 49 |
+
const dataBlock = jsonData.data || {};
|
| 50 |
+
const sourceRows = dataBlock.data || [];
|
| 51 |
+
const columns = dataBlock.columns || [];
|
| 52 |
+
const variables = jsonData.variables || {};
|
| 53 |
+
const typography = jsonData.typography || {};
|
| 54 |
+
const sourceColors = jsonData.colors || {};
|
| 55 |
+
|
| 56 |
+
d3.select(containerSelector).html("");
|
| 57 |
+
|
| 58 |
+
const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
|
| 59 |
+
const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
|
| 60 |
+
const groupColumn = chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || {};
|
| 61 |
+
const xField = xColumn.name;
|
| 62 |
+
const yField = yColumn.name;
|
| 63 |
+
const groupField = groupColumn.name;
|
| 64 |
+
const yUnit = yColumn.unit === "none" ? "" : (yColumn.unit || "");
|
| 65 |
+
|
| 66 |
+
const width = Math.max(560, Number(variables.width) || 800);
|
| 67 |
+
const baseHeight = Math.max(420, Number(variables.height) || 560);
|
| 68 |
+
const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
|
| 69 |
+
const annotationFamily = typography.annotation?.font_family || fontFamily;
|
| 70 |
+
const textColor = sourceColors.text_color || "#1f2937";
|
| 71 |
+
const mutedText = "#64748b";
|
| 72 |
+
const gridColor = "rgba(100, 116, 139, 0.22)";
|
| 73 |
+
const palette = sourceColors.available_colors?.length
|
| 74 |
+
? sourceColors.available_colors
|
| 75 |
+
: ["#2563eb", "#f97316", "#10b981", "#8b5cf6", "#ef4444", "#0f766e"];
|
| 76 |
+
const labelSize = Math.max(8.5, Math.min(11, parseFloat(typography.label?.font_size) || 10));
|
| 77 |
+
const valueSize = Math.max(8, Math.min(10, parseFloat(typography.annotation?.font_size) || 9));
|
| 78 |
+
const labelWeight = typography.label?.font_weight || "700";
|
| 79 |
+
const valueWeight = typography.annotation?.font_weight || "650";
|
| 80 |
+
|
| 81 |
+
const svg = d3.select(containerSelector)
|
| 82 |
+
.append("svg")
|
| 83 |
+
.attr("width", "100%")
|
| 84 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 85 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 86 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 87 |
+
.attr("class", "small-multiple-step-line-graph");
|
| 88 |
+
|
| 89 |
+
function message(text) {
|
| 90 |
+
svg.attr("height", baseHeight).attr("viewBox", `0 0 ${width} ${baseHeight}`);
|
| 91 |
+
svg.append("text")
|
| 92 |
+
.attr("x", width / 2)
|
| 93 |
+
.attr("y", baseHeight / 2)
|
| 94 |
+
.attr("text-anchor", "middle")
|
| 95 |
+
.attr("fill", textColor)
|
| 96 |
+
.style("font-family", fontFamily)
|
| 97 |
+
.style("font-size", "16px")
|
| 98 |
+
.text(text);
|
| 99 |
+
return svg.node();
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
if (!xField || !yField || !groupField) return message("Missing small multiple step line fields");
|
| 103 |
+
|
| 104 |
+
function readableLabel(value) {
|
| 105 |
+
return String(value ?? "")
|
| 106 |
+
.replace(/_/g, " ")
|
| 107 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 108 |
+
.replace(/\s+/g, " ")
|
| 109 |
+
.trim();
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function parseDateLocal(value) {
|
| 113 |
+
if (value instanceof Date) return value;
|
| 114 |
+
if (typeof value === "number" && Number.isFinite(value)) return new Date(value, 0, 1);
|
| 115 |
+
const text = String(value ?? "").trim();
|
| 116 |
+
let match = text.match(/^(-?\d{4})$/);
|
| 117 |
+
if (match) return new Date(Number(match[1]), 0, 1);
|
| 118 |
+
match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
|
| 119 |
+
if (match) return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1));
|
| 120 |
+
match = text.match(/^Q([1-4])\s+(-?\d{4})$/i);
|
| 121 |
+
if (match) return new Date(Number(match[2]), (Number(match[1]) - 1) * 3, 1);
|
| 122 |
+
match = text.match(/^(-?\d{4})\s+Q([1-4])$/i);
|
| 123 |
+
if (match) return new Date(Number(match[1]), (Number(match[2]) - 1) * 3, 1);
|
| 124 |
+
const parsed = new Date(text);
|
| 125 |
+
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
function compactNumber(value) {
|
| 129 |
+
const numeric = Number(value);
|
| 130 |
+
const abs = Math.abs(numeric);
|
| 131 |
+
if (abs >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
|
| 132 |
+
if (abs >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
|
| 133 |
+
if (abs >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
|
| 134 |
+
return d3.format(",.4~g")(numeric);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function formatLocalValue(value, unit = "") {
|
| 138 |
+
const numeric = Number(value);
|
| 139 |
+
const formatted = compactNumber(numeric);
|
| 140 |
+
if (!unit) return formatted;
|
| 141 |
+
if (unit === "%") return `${formatted}%`;
|
| 142 |
+
if (unit.startsWith("$")) {
|
| 143 |
+
const sign = numeric < 0 ? "-" : "";
|
| 144 |
+
return `${sign}$${compactNumber(Math.abs(numeric))}${unit.slice(1)}`;
|
| 145 |
+
}
|
| 146 |
+
if (unit.length <= 3 && /^[^A-Za-z0-9]+$/.test(unit)) return `${unit}${formatted}`;
|
| 147 |
+
return `${formatted} ${unit}`;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function truncateText(text, maxChars) {
|
| 151 |
+
if (text.length <= maxChars) return text;
|
| 152 |
+
return `${text.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
const rows = sourceRows.map((row, index) => {
|
| 156 |
+
const date = chartUtils.format.parseDate(row[xField]);
|
| 157 |
+
const value = Number(row[yField]);
|
| 158 |
+
const group = readableLabel(row[groupField]);
|
| 159 |
+
if (!date || !Number.isFinite(date.getTime()) || !Number.isFinite(value) || !group) return null;
|
| 160 |
+
return {
|
| 161 |
+
rawX: row[xField],
|
| 162 |
+
date,
|
| 163 |
+
value,
|
| 164 |
+
group,
|
| 165 |
+
rawGroup: row[groupField],
|
| 166 |
+
index
|
| 167 |
+
};
|
| 168 |
+
}).filter(Boolean);
|
| 169 |
+
if (!rows.length) return message("No valid data for small multiple step lines");
|
| 170 |
+
|
| 171 |
+
const groupMap = d3.group(rows, d => d.group);
|
| 172 |
+
const groups = Array.from(groupMap, ([group, values]) => {
|
| 173 |
+
values.sort((a, b) => d3.ascending(a.date, b.date) || d3.ascending(a.index, b.index));
|
| 174 |
+
return {
|
| 175 |
+
group,
|
| 176 |
+
values,
|
| 177 |
+
firstIndex: d3.min(values, d => d.index),
|
| 178 |
+
lastValue: values[values.length - 1]?.value ?? 0
|
| 179 |
+
};
|
| 180 |
+
}).sort((a, b) => d3.descending(a.lastValue, b.lastValue) || d3.ascending(a.firstIndex, b.firstIndex));
|
| 181 |
+
|
| 182 |
+
const cols = groups.length === 1 ? 1 : 2;
|
| 183 |
+
const panelRows = Math.ceil(groups.length / cols);
|
| 184 |
+
const outer = { top: 34, right: 28, bottom: 52, left: 42 };
|
| 185 |
+
const gapX = 42;
|
| 186 |
+
const gapY = 44;
|
| 187 |
+
const minPanelHeight = groups.length <= 2 ? 220 : 150;
|
| 188 |
+
const height = Math.max(420, outer.top + outer.bottom + panelRows * minPanelHeight + (panelRows - 1) * gapY);
|
| 189 |
+
svg.attr("height", height).attr("viewBox", `0 0 ${width} ${height}`);
|
| 190 |
+
|
| 191 |
+
const panelWidth = (width - outer.left - outer.right - gapX * (cols - 1)) / cols;
|
| 192 |
+
const panelHeight = (height - outer.top - outer.bottom - gapY * (panelRows - 1)) / panelRows;
|
| 193 |
+
const inner = { top: 24, right: 16, bottom: 30, left: 42 };
|
| 194 |
+
const innerWidth = Math.max(120, panelWidth - inner.left - inner.right);
|
| 195 |
+
const innerHeight = Math.max(80, panelHeight - inner.top - inner.bottom);
|
| 196 |
+
|
| 197 |
+
const xExtent = d3.extent(rows, d => d.date);
|
| 198 |
+
const xScaleBase = d3.scaleTime()
|
| 199 |
+
.domain(xExtent[0].getTime() === xExtent[1].getTime()
|
| 200 |
+
? [d3.timeYear.offset(xExtent[0], -1), d3.timeYear.offset(xExtent[1], 1)]
|
| 201 |
+
: xExtent)
|
| 202 |
+
.range([0, innerWidth]);
|
| 203 |
+
|
| 204 |
+
const spanDays = (xScaleBase.domain()[1] - xScaleBase.domain()[0]) / 86400000;
|
| 205 |
+
const xTicks = spanDays > 365 * 10
|
| 206 |
+
? xScaleBase.ticks(d3.timeYear.every(5))
|
| 207 |
+
: (spanDays > 365 * 4 ? xScaleBase.ticks(d3.timeYear.every(2)) : xScaleBase.ticks(Math.min(4, Math.max(2, Math.floor(innerWidth / 80)))));
|
| 208 |
+
const xFormat = spanDays > 365 * 2 ? d3.timeFormat("%Y") : d3.timeFormat("%b %Y");
|
| 209 |
+
|
| 210 |
+
const colorFor = (group, index) => sourceColors.field?.[group]
|
| 211 |
+
|| sourceColors.field?.[groups[index]?.values[0]?.rawGroup]
|
| 212 |
+
|| palette[index % palette.length];
|
| 213 |
+
|
| 214 |
+
groups.forEach((entry, index) => {
|
| 215 |
+
const row = Math.floor(index / cols);
|
| 216 |
+
const col = index % cols;
|
| 217 |
+
const panelX = outer.left + col * (panelWidth + gapX);
|
| 218 |
+
const panelY = outer.top + row * (panelHeight + gapY);
|
| 219 |
+
const color = colorFor(entry.group, index);
|
| 220 |
+
|
| 221 |
+
const panel = svg.append("g")
|
| 222 |
+
.attr("class", "small-multiple-panel")
|
| 223 |
+
.attr("data-group", entry.group)
|
| 224 |
+
.attr("transform", `translate(${panelX}, ${panelY})`);
|
| 225 |
+
|
| 226 |
+
panel.append("rect")
|
| 227 |
+
.attr("class", "panel-background")
|
| 228 |
+
.attr("x", 0)
|
| 229 |
+
.attr("y", 0)
|
| 230 |
+
.attr("width", panelWidth)
|
| 231 |
+
.attr("height", panelHeight)
|
| 232 |
+
.attr("rx", 4)
|
| 233 |
+
.attr("fill", "rgba(255, 255, 255, 0.20)")
|
| 234 |
+
.attr("stroke", "rgba(100, 116, 139, 0.18)");
|
| 235 |
+
|
| 236 |
+
panel.append("text")
|
| 237 |
+
.attr("class", "panel-title")
|
| 238 |
+
.attr("x", inner.left)
|
| 239 |
+
.attr("y", 14)
|
| 240 |
+
.attr("fill", textColor)
|
| 241 |
+
.style("font-family", fontFamily)
|
| 242 |
+
.style("font-size", `${labelSize}px`)
|
| 243 |
+
.style("font-weight", labelWeight)
|
| 244 |
+
.text(truncateText(entry.group, Math.max(16, Math.floor(panelWidth / 8))));
|
| 245 |
+
|
| 246 |
+
const g = panel.append("g")
|
| 247 |
+
.attr("class", "panel-plot")
|
| 248 |
+
.attr("transform", `translate(${inner.left}, ${inner.top})`);
|
| 249 |
+
|
| 250 |
+
const minY = d3.min(entry.values, d => d.value) ?? 0;
|
| 251 |
+
const maxY = d3.max(entry.values, d => d.value) ?? 0;
|
| 252 |
+
const spanY = Math.max(1, maxY - minY);
|
| 253 |
+
const yScale = d3.scaleLinear()
|
| 254 |
+
.domain([Math.min(0, minY - spanY * 0.12), maxY + spanY * 0.12])
|
| 255 |
+
.nice(3)
|
| 256 |
+
.range([innerHeight, 0]);
|
| 257 |
+
|
| 258 |
+
const yTicks = yScale.ticks(3);
|
| 259 |
+
yTicks.forEach(tick => {
|
| 260 |
+
const y = yScale(tick);
|
| 261 |
+
g.append("line")
|
| 262 |
+
.attr("class", "gridline y-axis-gridline")
|
| 263 |
+
.attr("x1", 0)
|
| 264 |
+
.attr("x2", innerWidth)
|
| 265 |
+
.attr("y1", y)
|
| 266 |
+
.attr("y2", y)
|
| 267 |
+
.attr("stroke", gridColor);
|
| 268 |
+
g.append("text")
|
| 269 |
+
.attr("class", "y-axis-tick")
|
| 270 |
+
.attr("x", -6)
|
| 271 |
+
.attr("y", y)
|
| 272 |
+
.attr("text-anchor", "end")
|
| 273 |
+
.attr("dominant-baseline", "middle")
|
| 274 |
+
.attr("fill", mutedText)
|
| 275 |
+
.style("font-family", annotationFamily)
|
| 276 |
+
.style("font-size", `${valueSize}px`)
|
| 277 |
+
.style("font-weight", valueWeight)
|
| 278 |
+
.text(formatLocalValue(tick, yUnit));
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
xTicks.forEach(tick => {
|
| 282 |
+
const x = xScaleBase(tick);
|
| 283 |
+
g.append("line")
|
| 284 |
+
.attr("class", "gridline x-axis-gridline")
|
| 285 |
+
.attr("x1", x)
|
| 286 |
+
.attr("x2", x)
|
| 287 |
+
.attr("y1", 0)
|
| 288 |
+
.attr("y2", innerHeight)
|
| 289 |
+
.attr("stroke", gridColor);
|
| 290 |
+
g.append("text")
|
| 291 |
+
.attr("class", "x-axis-tick")
|
| 292 |
+
.attr("x", x)
|
| 293 |
+
.attr("y", innerHeight + 18)
|
| 294 |
+
.attr("text-anchor", "middle")
|
| 295 |
+
.attr("fill", mutedText)
|
| 296 |
+
.style("font-family", annotationFamily)
|
| 297 |
+
.style("font-size", `${Math.max(7.5, valueSize - 1)}px`)
|
| 298 |
+
.style("font-weight", valueWeight)
|
| 299 |
+
.text(xFormat(tick));
|
| 300 |
+
});
|
| 301 |
+
|
| 302 |
+
g.append("line")
|
| 303 |
+
.attr("class", "axis-line x-axis-line")
|
| 304 |
+
.attr("x1", 0)
|
| 305 |
+
.attr("x2", innerWidth)
|
| 306 |
+
.attr("y1", innerHeight)
|
| 307 |
+
.attr("y2", innerHeight)
|
| 308 |
+
.attr("stroke", "rgba(71, 85, 105, 0.50)");
|
| 309 |
+
g.append("line")
|
| 310 |
+
.attr("class", "axis-line y-axis-line")
|
| 311 |
+
.attr("x1", 0)
|
| 312 |
+
.attr("x2", 0)
|
| 313 |
+
.attr("y1", 0)
|
| 314 |
+
.attr("y2", innerHeight)
|
| 315 |
+
.attr("stroke", "rgba(71, 85, 105, 0.50)");
|
| 316 |
+
|
| 317 |
+
const line = d3.line()
|
| 318 |
+
.x(d => xScaleBase(d.date))
|
| 319 |
+
.y(d => yScale(d.value))
|
| 320 |
+
.curve(d3.curveStepAfter);
|
| 321 |
+
|
| 322 |
+
g.append("path")
|
| 323 |
+
.datum(entry.values)
|
| 324 |
+
.attr("class", "step-line")
|
| 325 |
+
.attr("data-group", entry.group)
|
| 326 |
+
.attr("d", line)
|
| 327 |
+
.attr("fill", "none")
|
| 328 |
+
.attr("stroke", color)
|
| 329 |
+
.attr("stroke-width", 2.4)
|
| 330 |
+
.attr("stroke-linejoin", "round")
|
| 331 |
+
.attr("stroke-linecap", "round");
|
| 332 |
+
|
| 333 |
+
entry.values.forEach(point => {
|
| 334 |
+
g.append("circle")
|
| 335 |
+
.attr("class", "data-point")
|
| 336 |
+
.attr("data-group", entry.group)
|
| 337 |
+
.attr("data-x", String(point.rawX))
|
| 338 |
+
.attr("data-value", point.value)
|
| 339 |
+
.attr("cx", xScaleBase(point.date))
|
| 340 |
+
.attr("cy", yScale(point.value))
|
| 341 |
+
.attr("r", 2.7)
|
| 342 |
+
.attr("fill", color)
|
| 343 |
+
.attr("stroke", "#ffffff")
|
| 344 |
+
.attr("stroke-width", 0.8);
|
| 345 |
+
});
|
| 346 |
+
|
| 347 |
+
const last = entry.values[entry.values.length - 1];
|
| 348 |
+
const labelX = Math.min(innerWidth - 2, xScaleBase(last.date) + 4);
|
| 349 |
+
const labelY = Math.max(valueSize, Math.min(innerHeight - 2, yScale(last.value) - 5));
|
| 350 |
+
g.append("text")
|
| 351 |
+
.attr("class", "endpoint-label value-label")
|
| 352 |
+
.attr("data-group", entry.group)
|
| 353 |
+
.attr("x", labelX)
|
| 354 |
+
.attr("y", labelY)
|
| 355 |
+
.attr("text-anchor", "end")
|
| 356 |
+
.attr("fill", textColor)
|
| 357 |
+
.style("font-family", annotationFamily)
|
| 358 |
+
.style("font-size", `${valueSize}px`)
|
| 359 |
+
.style("font-weight", valueWeight)
|
| 360 |
+
.text(formatLocalValue(last.value, yUnit));
|
| 361 |
+
});
|
| 362 |
+
|
| 363 |
+
svg.append("text")
|
| 364 |
+
.attr("class", "scale-note")
|
| 365 |
+
.attr("x", width / 2)
|
| 366 |
+
.attr("y", height - 28)
|
| 367 |
+
.attr("text-anchor", "middle")
|
| 368 |
+
.attr("fill", mutedText)
|
| 369 |
+
.style("font-family", annotationFamily)
|
| 370 |
+
.style("font-size", `${Math.max(8, valueSize)}px`)
|
| 371 |
+
.text(`${readableLabel(yColumn.label || yField)} uses local panel y-scales; x-axis is chronological`);
|
| 372 |
+
|
| 373 |
+
svg.append("text")
|
| 374 |
+
.attr("class", "category-note")
|
| 375 |
+
.attr("x", width / 2)
|
| 376 |
+
.attr("y", height - 12)
|
| 377 |
+
.attr("text-anchor", "middle")
|
| 378 |
+
.attr("fill", mutedText)
|
| 379 |
+
.style("font-family", annotationFamily)
|
| 380 |
+
.style("font-size", `${Math.max(7.5, valueSize - 1)}px`)
|
| 381 |
+
.text(`${readableLabel(groupColumn.label || groupField)} panels ordered by latest value; group icons removed`);
|
| 382 |
+
|
| 383 |
+
return svg.node();
|
| 384 |
+
}
|
modules/chart_engine/template/d3-js/type31_small_multiples_of_stepped_line_graphs/small_multiple_step_line_graph_plain_chart_01.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Small Multiple Stepped Line Graph",
|
| 5 |
+
"chart_name": "small_multiple_step_line_graph_plain_chart_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[3, 30], ["-inf", "inf"], [2, 6]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 400,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "no",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
const jsonData = data || {};
|
| 27 |
+
const chartData = jsonData.data?.data || [];
|
| 28 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 29 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 30 |
+
const chartUtilsFormatSample = chartUtils.format.autoText;
|
| 31 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 32 |
+
const chartUtilsStandard = {
|
| 33 |
+
schema: chartUtils.schema,
|
| 34 |
+
format: chartUtils.format,
|
| 35 |
+
text: chartUtils.text,
|
| 36 |
+
color: chartUtils.color,
|
| 37 |
+
legendLayout: chartUtils.legend.layout,
|
| 38 |
+
legendDraw: chartUtils.legend.draw,
|
| 39 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 40 |
+
};
|
| 41 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 42 |
+
x: { fallbackIndex: 0 },
|
| 43 |
+
y: { fallbackIndex: 1 },
|
| 44 |
+
y2: { fallbackIndex: 2 },
|
| 45 |
+
y3: { fallbackIndex: 3 },
|
| 46 |
+
size: { fallbackIndex: 2 },
|
| 47 |
+
group: { fallbackIndex: 2 },
|
| 48 |
+
group2: { fallbackIndex: 3 },
|
| 49 |
+
group3: { fallbackIndex: 4 }
|
| 50 |
+
});
|
| 51 |
+
const variables = jsonData.variables || {};
|
| 52 |
+
const typography = jsonData.typography || {};
|
| 53 |
+
const sourceColors = jsonData.colors || {};
|
| 54 |
+
const colorField = sourceColors.field || {};
|
| 55 |
+
|
| 56 |
+
d3.select(containerSelector).html("");
|
| 57 |
+
|
| 58 |
+
const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
|
| 59 |
+
const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
|
| 60 |
+
const groupColumn = chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || {};
|
| 61 |
+
const xField = xColumn.name;
|
| 62 |
+
const yField = yColumn.name;
|
| 63 |
+
const groupField = groupColumn.name;
|
| 64 |
+
const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
|
| 65 |
+
|
| 66 |
+
const width = Number(variables.width) || 600;
|
| 67 |
+
const height = Number(variables.height) || 600;
|
| 68 |
+
const labelTypography = typography.label || {};
|
| 69 |
+
const titleTypography = typography.title || labelTypography;
|
| 70 |
+
const valueTypography = typography.annotation || labelTypography;
|
| 71 |
+
const fontFamily = labelTypography.font_family || titleTypography.font_family || "Arial, sans-serif";
|
| 72 |
+
const titleFamily = titleTypography.font_family || fontFamily;
|
| 73 |
+
const valueFamily = valueTypography.font_family || fontFamily;
|
| 74 |
+
const textColor = sourceColors.text_color || "#1f2937";
|
| 75 |
+
const mutedText = "#64748b";
|
| 76 |
+
const baseLabelSize = parseFloat(labelTypography.font_size) || 11;
|
| 77 |
+
const baseTitleSize = parseFloat(titleTypography.font_size) || 13;
|
| 78 |
+
const baseValueSize = parseFloat(valueTypography.font_size) || 10.5;
|
| 79 |
+
const labelWeight = labelTypography.font_weight || "500";
|
| 80 |
+
const titleWeight = titleTypography.font_weight || "700";
|
| 81 |
+
const valueWeight = valueTypography.font_weight || "650";
|
| 82 |
+
|
| 83 |
+
const svg = d3.select(containerSelector)
|
| 84 |
+
.append("svg")
|
| 85 |
+
.attr("width", "100%")
|
| 86 |
+
.attr("height", height)
|
| 87 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 88 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 89 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 90 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 91 |
+
.attr("class", "small-multiple-step-line-chart-root");
|
| 92 |
+
|
| 93 |
+
if (!xField || !yField || !groupField) {
|
| 94 |
+
svg.append("text")
|
| 95 |
+
.attr("class", "chart-message")
|
| 96 |
+
.attr("x", width / 2)
|
| 97 |
+
.attr("y", height / 2)
|
| 98 |
+
.attr("text-anchor", "middle")
|
| 99 |
+
.attr("fill", textColor)
|
| 100 |
+
.style("font-family", fontFamily)
|
| 101 |
+
.style("font-size", "16px")
|
| 102 |
+
.text("Missing stepped-line fields");
|
| 103 |
+
return svg.node();
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function rgba(color, opacity) {
|
| 107 |
+
const c = d3.rgb(color);
|
| 108 |
+
return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function readableLabel(value) {
|
| 112 |
+
return String(value ?? "")
|
| 113 |
+
.replace(/_/g, " ")
|
| 114 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 115 |
+
.replace(/\s+/g, " ")
|
| 116 |
+
.trim();
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function parseTemporalDate(value) {
|
| 120 |
+
if (value instanceof Date && Number.isFinite(value.getTime())) return value;
|
| 121 |
+
if (typeof value === "number" && Number.isFinite(value)) return new Date(Date.UTC(value, 0, 1));
|
| 122 |
+
const text = String(value ?? "").trim();
|
| 123 |
+
if (!text) return null;
|
| 124 |
+
|
| 125 |
+
let match = text.match(/^(-?\d{4})$/);
|
| 126 |
+
if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
|
| 127 |
+
|
| 128 |
+
match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
|
| 129 |
+
if (match) {
|
| 130 |
+
return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
match = text.match(/^q([1-4])\s*(-?\d{4})$/i) || text.match(/^(-?\d{4})\s*q([1-4])$/i);
|
| 134 |
+
if (match) {
|
| 135 |
+
const year = match[1].length === 4 ? Number(match[1]) : Number(match[2]);
|
| 136 |
+
const quarter = match[1].length === 4 ? Number(match[2]) : Number(match[1]);
|
| 137 |
+
return new Date(Date.UTC(year, (quarter - 1) * 3, 1));
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const parsed = new Date(text);
|
| 141 |
+
if (Number.isFinite(parsed.getTime())) {
|
| 142 |
+
return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()));
|
| 143 |
+
}
|
| 144 |
+
return null;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
function compactNumber(value) {
|
| 148 |
+
const numeric = Number(value);
|
| 149 |
+
if (!Number.isFinite(numeric)) return String(value ?? "");
|
| 150 |
+
const abs = Math.abs(numeric);
|
| 151 |
+
if (abs >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
|
| 152 |
+
if (abs >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
|
| 153 |
+
if (abs >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
|
| 154 |
+
return d3.format(",.4~g")(numeric);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
function formatLocalValue(value, includeUnit = true) {
|
| 158 |
+
const formatted = compactNumber(value);
|
| 159 |
+
if (!includeUnit || !yUnit) return formatted;
|
| 160 |
+
if (yUnit === "%") return `${formatted}%`;
|
| 161 |
+
const currencyWithSuffix = yUnit.match(/^([£$€¥])\s*([A-Za-z]+)$/);
|
| 162 |
+
if (currencyWithSuffix) return `${currencyWithSuffix[1]}${formatted}${currencyWithSuffix[2]}`;
|
| 163 |
+
if (/^[£$€¥]$/.test(yUnit)) return `${yUnit}${formatted}`;
|
| 164 |
+
return `${formatted} ${yUnit}`;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
function wrapText(text, maxChars) {
|
| 168 |
+
const words = readableLabel(text).split(/\s+/).filter(Boolean);
|
| 169 |
+
if (!words.length) return [String(text ?? "")];
|
| 170 |
+
const lines = [];
|
| 171 |
+
let current = "";
|
| 172 |
+
words.forEach(word => {
|
| 173 |
+
const next = current ? `${current} ${word}` : word;
|
| 174 |
+
if (next.length <= maxChars) {
|
| 175 |
+
current = next;
|
| 176 |
+
return;
|
| 177 |
+
}
|
| 178 |
+
if (current) lines.push(current);
|
| 179 |
+
if (word.length <= maxChars) {
|
| 180 |
+
current = word;
|
| 181 |
+
return;
|
| 182 |
+
}
|
| 183 |
+
for (let i = 0; i < word.length; i += maxChars) {
|
| 184 |
+
lines.push(word.slice(i, i + maxChars));
|
| 185 |
+
}
|
| 186 |
+
current = "";
|
| 187 |
+
});
|
| 188 |
+
if (current) lines.push(current);
|
| 189 |
+
return lines.length ? lines : [String(text ?? "")];
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
function selectEvenTicks(values, maxTicks) {
|
| 193 |
+
if (values.length <= maxTicks) return values;
|
| 194 |
+
const selected = [];
|
| 195 |
+
for (let i = 0; i < maxTicks; i += 1) {
|
| 196 |
+
const index = Math.round(i * (values.length - 1) / (maxTicks - 1));
|
| 197 |
+
const value = values[index];
|
| 198 |
+
if (!selected.includes(value)) selected.push(value);
|
| 199 |
+
}
|
| 200 |
+
return selected;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
const parsedRows = chartData.map((d, index) => {
|
| 204 |
+
const date = parseTemporalDate(d[xField]);
|
| 205 |
+
const value = Number(d[yField]);
|
| 206 |
+
const group = readableLabel(d[groupField]);
|
| 207 |
+
return {
|
| 208 |
+
raw: d,
|
| 209 |
+
index,
|
| 210 |
+
date,
|
| 211 |
+
time: date ? date.getTime() : NaN,
|
| 212 |
+
xLabel: String(d[xField] ?? ""),
|
| 213 |
+
value,
|
| 214 |
+
group
|
| 215 |
+
};
|
| 216 |
+
}).filter(d => d.date && Number.isFinite(d.time) && Number.isFinite(d.value) && d.group);
|
| 217 |
+
|
| 218 |
+
if (parsedRows.length === 0) {
|
| 219 |
+
svg.append("text")
|
| 220 |
+
.attr("class", "chart-message")
|
| 221 |
+
.attr("x", width / 2)
|
| 222 |
+
.attr("y", height / 2)
|
| 223 |
+
.attr("text-anchor", "middle")
|
| 224 |
+
.attr("fill", textColor)
|
| 225 |
+
.style("font-family", fontFamily)
|
| 226 |
+
.style("font-size", "16px")
|
| 227 |
+
.text("Not enough temporal data");
|
| 228 |
+
return svg.node();
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
const groups = [];
|
| 232 |
+
parsedRows.forEach(d => {
|
| 233 |
+
if (!groups.includes(d.group)) groups.push(d.group);
|
| 234 |
+
});
|
| 235 |
+
|
| 236 |
+
const xLabelByTime = new Map();
|
| 237 |
+
parsedRows.forEach(d => {
|
| 238 |
+
if (!xLabelByTime.has(d.time)) xLabelByTime.set(d.time, d.xLabel);
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
const groupSeries = groups.map(group => {
|
| 242 |
+
const byTime = new Map();
|
| 243 |
+
parsedRows.filter(d => d.group === group).forEach(d => {
|
| 244 |
+
if (!byTime.has(d.time)) {
|
| 245 |
+
byTime.set(d.time, {
|
| 246 |
+
date: d.date,
|
| 247 |
+
time: d.time,
|
| 248 |
+
label: d.xLabel,
|
| 249 |
+
valueSum: 0,
|
| 250 |
+
count: 0
|
| 251 |
+
});
|
| 252 |
+
}
|
| 253 |
+
const entry = byTime.get(d.time);
|
| 254 |
+
entry.valueSum += d.value;
|
| 255 |
+
entry.count += 1;
|
| 256 |
+
});
|
| 257 |
+
const values = Array.from(byTime.values())
|
| 258 |
+
.map(d => ({
|
| 259 |
+
date: d.date,
|
| 260 |
+
time: d.time,
|
| 261 |
+
label: d.label,
|
| 262 |
+
value: d.valueSum / d.count
|
| 263 |
+
}))
|
| 264 |
+
.sort((a, b) => a.time - b.time);
|
| 265 |
+
return {
|
| 266 |
+
group,
|
| 267 |
+
values,
|
| 268 |
+
latestValue: values[values.length - 1]?.value,
|
| 269 |
+
meanValue: d3.mean(values, d => d.value) || 0
|
| 270 |
+
};
|
| 271 |
+
}).filter(d => d.values.length >= 2);
|
| 272 |
+
|
| 273 |
+
if (groupSeries.length === 0) {
|
| 274 |
+
svg.append("text")
|
| 275 |
+
.attr("class", "chart-message")
|
| 276 |
+
.attr("x", width / 2)
|
| 277 |
+
.attr("y", height / 2)
|
| 278 |
+
.attr("text-anchor", "middle")
|
| 279 |
+
.attr("fill", textColor)
|
| 280 |
+
.style("font-family", fontFamily)
|
| 281 |
+
.style("font-size", "16px")
|
| 282 |
+
.text("Not enough points per group");
|
| 283 |
+
return svg.node();
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
const xValues = Array.from(new Set(parsedRows.map(d => d.time)))
|
| 287 |
+
.sort((a, b) => a - b)
|
| 288 |
+
.map(time => new Date(time));
|
| 289 |
+
const allValues = groupSeries.flatMap(series => series.values.map(d => d.value));
|
| 290 |
+
let yMin = d3.min(allValues);
|
| 291 |
+
let yMax = d3.max(allValues);
|
| 292 |
+
if (yMin === yMax) {
|
| 293 |
+
const pad = Math.max(1, Math.abs(yMin) * 0.12);
|
| 294 |
+
yMin -= pad;
|
| 295 |
+
yMax += pad;
|
| 296 |
+
} else {
|
| 297 |
+
const pad = (yMax - yMin) * 0.12;
|
| 298 |
+
yMin -= pad;
|
| 299 |
+
yMax += pad;
|
| 300 |
+
}
|
| 301 |
+
const rawMin = d3.min(allValues);
|
| 302 |
+
const rawMax = d3.max(allValues);
|
| 303 |
+
if (rawMin >= 0) yMin = Math.max(0, yMin);
|
| 304 |
+
if (rawMax <= 0) yMax = Math.min(0, yMax);
|
| 305 |
+
|
| 306 |
+
const columns = groupSeries.length <= 2 ? groupSeries.length : (groupSeries.length <= 4 ? 2 : 3);
|
| 307 |
+
const rows = Math.ceil(groupSeries.length / columns);
|
| 308 |
+
const outer = { top: 22, right: 28, bottom: 38, left: 30 };
|
| 309 |
+
const gapX = groupSeries.length > 4 ? 18 : 24;
|
| 310 |
+
const gapY = rows > 1 ? 26 : 18;
|
| 311 |
+
const panelWidth = (width - outer.left - outer.right - gapX * (columns - 1)) / columns;
|
| 312 |
+
const panelHeight = (height - outer.top - outer.bottom - gapY * (rows - 1)) / rows;
|
| 313 |
+
const plot = {
|
| 314 |
+
left: Math.min(54, Math.max(40, panelWidth * 0.2)),
|
| 315 |
+
right: 18,
|
| 316 |
+
top: 46,
|
| 317 |
+
bottom: 34
|
| 318 |
+
};
|
| 319 |
+
const innerWidth = Math.max(82, panelWidth - plot.left - plot.right);
|
| 320 |
+
const innerHeight = Math.max(70, panelHeight - plot.top - plot.bottom);
|
| 321 |
+
|
| 322 |
+
const xScale = d3.scaleUtc()
|
| 323 |
+
.domain(d3.extent(xValues))
|
| 324 |
+
.range([0, innerWidth]);
|
| 325 |
+
const yScale = d3.scaleLinear()
|
| 326 |
+
.domain([yMin, yMax])
|
| 327 |
+
.nice(4)
|
| 328 |
+
.range([innerHeight, 0]);
|
| 329 |
+
const yTicks = yScale.ticks(innerHeight < 92 ? 3 : 4);
|
| 330 |
+
const xTickCount = innerWidth < 135 ? 2 : (innerWidth < 220 ? 3 : 4);
|
| 331 |
+
const xTicks = selectEvenTicks(xValues, xTickCount);
|
| 332 |
+
const palette = sourceColors.available_colors || d3.schemeTableau10 || d3.schemeCategory10;
|
| 333 |
+
const colorForGroup = group => {
|
| 334 |
+
if (colorField[group]) return colorField[group];
|
| 335 |
+
const index = groupSeries.findIndex(d => d.group === group);
|
| 336 |
+
return palette[index % palette.length] || "#2563eb";
|
| 337 |
+
};
|
| 338 |
+
|
| 339 |
+
function formatXTick(date) {
|
| 340 |
+
const label = xLabelByTime.get(date.getTime());
|
| 341 |
+
if (label) return String(label);
|
| 342 |
+
return d3.utcFormat("%Y")(date);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
const stepLine = d3.line()
|
| 346 |
+
.defined(d => Number.isFinite(d.value))
|
| 347 |
+
.x(d => xScale(d.date))
|
| 348 |
+
.y(d => yScale(d.value))
|
| 349 |
+
.curve(d3.curveStepAfter);
|
| 350 |
+
|
| 351 |
+
svg.append("text")
|
| 352 |
+
.attr("class", "scale-note axis-title")
|
| 353 |
+
.attr("x", outer.left)
|
| 354 |
+
.attr("y", 12)
|
| 355 |
+
.attr("fill", mutedText)
|
| 356 |
+
.style("font-family", fontFamily)
|
| 357 |
+
.style("font-size", "10px")
|
| 358 |
+
.style("font-weight", "600")
|
| 359 |
+
.text(`Shared ${readableLabel(yColumn.label || yField)} scale: ${formatLocalValue(yScale.domain()[0])} to ${formatLocalValue(yScale.domain()[1])}`);
|
| 360 |
+
|
| 361 |
+
groupSeries.forEach((series, index) => {
|
| 362 |
+
const row = Math.floor(index / columns);
|
| 363 |
+
const col = index % columns;
|
| 364 |
+
const rowCount = row === rows - 1 ? groupSeries.length - row * columns : columns;
|
| 365 |
+
const rowOffset = (columns - rowCount) * (panelWidth + gapX) / 2;
|
| 366 |
+
const panelX = outer.left + rowOffset + col * (panelWidth + gapX);
|
| 367 |
+
const panelY = outer.top + row * (panelHeight + gapY);
|
| 368 |
+
const color = colorForGroup(series.group);
|
| 369 |
+
|
| 370 |
+
const panel = svg.append("g")
|
| 371 |
+
.attr("class", "subplot step-line-panel")
|
| 372 |
+
.attr("data-group", series.group)
|
| 373 |
+
.attr("transform", `translate(${panelX}, ${panelY})`);
|
| 374 |
+
|
| 375 |
+
panel.append("rect")
|
| 376 |
+
.attr("class", "subplot-background")
|
| 377 |
+
.attr("width", panelWidth)
|
| 378 |
+
.attr("height", panelHeight)
|
| 379 |
+
.attr("rx", 6)
|
| 380 |
+
.attr("fill", rgba(textColor, 0.025))
|
| 381 |
+
.attr("stroke", rgba(textColor, 0.08))
|
| 382 |
+
.attr("stroke-width", 1);
|
| 383 |
+
|
| 384 |
+
panel.append("line")
|
| 385 |
+
.attr("class", "group-swatch")
|
| 386 |
+
.attr("x1", 12)
|
| 387 |
+
.attr("x2", 32)
|
| 388 |
+
.attr("y1", 17)
|
| 389 |
+
.attr("y2", 17)
|
| 390 |
+
.attr("stroke", color)
|
| 391 |
+
.attr("stroke-width", 3)
|
| 392 |
+
.attr("stroke-linecap", "round");
|
| 393 |
+
|
| 394 |
+
const titleLines = wrapText(series.group, Math.max(8, Math.floor((panelWidth - 50) / 7.2))).slice(0, 2);
|
| 395 |
+
const title = panel.append("text")
|
| 396 |
+
.attr("class", "group-label")
|
| 397 |
+
.attr("x", 40)
|
| 398 |
+
.attr("y", 14)
|
| 399 |
+
.attr("fill", textColor)
|
| 400 |
+
.style("font-family", titleFamily)
|
| 401 |
+
.style("font-size", `${Math.max(9.5, Math.min(baseTitleSize, 12.5))}px`)
|
| 402 |
+
.style("font-weight", titleWeight);
|
| 403 |
+
titleLines.forEach((line, lineIndex) => {
|
| 404 |
+
title.append("tspan")
|
| 405 |
+
.attr("x", 40)
|
| 406 |
+
.attr("dy", lineIndex === 0 ? 0 : 12)
|
| 407 |
+
.text(line);
|
| 408 |
+
});
|
| 409 |
+
|
| 410 |
+
const chart = panel.append("g")
|
| 411 |
+
.attr("class", "panel-plot")
|
| 412 |
+
.attr("transform", `translate(${plot.left}, ${plot.top})`);
|
| 413 |
+
|
| 414 |
+
yTicks.forEach(tick => {
|
| 415 |
+
chart.append("line")
|
| 416 |
+
.attr("class", "gridline y-gridline")
|
| 417 |
+
.attr("x1", 0)
|
| 418 |
+
.attr("x2", innerWidth)
|
| 419 |
+
.attr("y1", yScale(tick))
|
| 420 |
+
.attr("y2", yScale(tick))
|
| 421 |
+
.attr("stroke", rgba(textColor, 0.14))
|
| 422 |
+
.attr("stroke-width", 1);
|
| 423 |
+
chart.append("text")
|
| 424 |
+
.attr("class", "axis-tick y-axis-tick")
|
| 425 |
+
.attr("x", -7)
|
| 426 |
+
.attr("y", yScale(tick))
|
| 427 |
+
.attr("text-anchor", "end")
|
| 428 |
+
.attr("dominant-baseline", "middle")
|
| 429 |
+
.attr("fill", rgba(textColor, 0.68))
|
| 430 |
+
.style("font-family", fontFamily)
|
| 431 |
+
.style("font-size", `${Math.max(8, Math.min(baseLabelSize, 9.5))}px`)
|
| 432 |
+
.style("font-weight", labelWeight)
|
| 433 |
+
.text(formatLocalValue(tick));
|
| 434 |
+
});
|
| 435 |
+
|
| 436 |
+
chart.append("line")
|
| 437 |
+
.attr("class", "axis-line x-axis-line")
|
| 438 |
+
.attr("x1", 0)
|
| 439 |
+
.attr("x2", innerWidth)
|
| 440 |
+
.attr("y1", innerHeight)
|
| 441 |
+
.attr("y2", innerHeight)
|
| 442 |
+
.attr("stroke", rgba(textColor, 0.34))
|
| 443 |
+
.attr("stroke-width", 1);
|
| 444 |
+
|
| 445 |
+
xTicks.forEach(tick => {
|
| 446 |
+
chart.append("line")
|
| 447 |
+
.attr("class", "axis-tick-line")
|
| 448 |
+
.attr("x1", xScale(tick))
|
| 449 |
+
.attr("x2", xScale(tick))
|
| 450 |
+
.attr("y1", innerHeight)
|
| 451 |
+
.attr("y2", innerHeight + 4)
|
| 452 |
+
.attr("stroke", rgba(textColor, 0.38))
|
| 453 |
+
.attr("stroke-width", 1);
|
| 454 |
+
chart.append("text")
|
| 455 |
+
.attr("class", "axis-tick x-axis-tick")
|
| 456 |
+
.attr("x", xScale(tick))
|
| 457 |
+
.attr("y", innerHeight + 17)
|
| 458 |
+
.attr("text-anchor", "middle")
|
| 459 |
+
.attr("fill", rgba(textColor, 0.72))
|
| 460 |
+
.style("font-family", fontFamily)
|
| 461 |
+
.style("font-size", `${Math.max(7.8, Math.min(baseLabelSize, 9.2))}px`)
|
| 462 |
+
.style("font-weight", labelWeight)
|
| 463 |
+
.text(formatXTick(tick));
|
| 464 |
+
});
|
| 465 |
+
|
| 466 |
+
chart.append("path")
|
| 467 |
+
.datum(series.values)
|
| 468 |
+
.attr("class", "mark step-line data-line")
|
| 469 |
+
.attr("data-tag", "mark")
|
| 470 |
+
.attr("fill", "none")
|
| 471 |
+
.attr("stroke", color)
|
| 472 |
+
.attr("stroke-width", 2.6)
|
| 473 |
+
.attr("stroke-linecap", "round")
|
| 474 |
+
.attr("stroke-linejoin", "round")
|
| 475 |
+
.attr("d", stepLine);
|
| 476 |
+
|
| 477 |
+
chart.selectAll(".step-point")
|
| 478 |
+
.data(series.values)
|
| 479 |
+
.enter()
|
| 480 |
+
.append("circle")
|
| 481 |
+
.attr("class", "data-point step-point")
|
| 482 |
+
.attr("data-tag", "mark")
|
| 483 |
+
.attr("cx", d => xScale(d.date))
|
| 484 |
+
.attr("cy", d => yScale(d.value))
|
| 485 |
+
.attr("r", 2.8)
|
| 486 |
+
.attr("fill", "#ffffff")
|
| 487 |
+
.attr("stroke", color)
|
| 488 |
+
.attr("stroke-width", 1.4);
|
| 489 |
+
|
| 490 |
+
const lastPoint = series.values[series.values.length - 1];
|
| 491 |
+
if (lastPoint) {
|
| 492 |
+
chart.append("text")
|
| 493 |
+
.attr("class", "value-label latest-value")
|
| 494 |
+
.attr("x", innerWidth)
|
| 495 |
+
.attr("y", Math.max(8, Math.min(innerHeight - 6, yScale(lastPoint.value) - 8)))
|
| 496 |
+
.attr("text-anchor", "end")
|
| 497 |
+
.attr("fill", color)
|
| 498 |
+
.style("font-family", valueFamily)
|
| 499 |
+
.style("font-size", `${Math.max(8.2, Math.min(baseValueSize, 10.5))}px`)
|
| 500 |
+
.style("font-weight", valueWeight)
|
| 501 |
+
.text(formatLocalValue(lastPoint.value));
|
| 502 |
+
}
|
| 503 |
+
});
|
| 504 |
+
|
| 505 |
+
svg.append("text")
|
| 506 |
+
.attr("class", "category-note")
|
| 507 |
+
.attr("x", width / 2)
|
| 508 |
+
.attr("y", height - 8)
|
| 509 |
+
.attr("text-anchor", "middle")
|
| 510 |
+
.attr("fill", mutedText)
|
| 511 |
+
.style("font-family", fontFamily)
|
| 512 |
+
.style("font-size", "9.5px")
|
| 513 |
+
.text(`${readableLabel(xColumn.label || xField)} order: chronological; panels share the same y scale`);
|
| 514 |
+
|
| 515 |
+
return svg.node();
|
| 516 |
+
}
|