Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
modules/chart_engine/template/d3-js/type26_spline_graph/spline_graph_plain_chart_01.js
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "spline graph",
|
| 5 |
+
"chart_name": "spline_graph_plain_chart_01",
|
| 6 |
+
"required_fields": ["x", "y"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"]],
|
| 8 |
+
"required_fields_range": [[5, 30], ["-inf", "inf"]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": [],
|
| 12 |
+
"required_other_colors": ["primary", "secondary"],
|
| 13 |
+
"supported_effects": ["stroke", "spacing"],
|
| 14 |
+
"min_height": 400,
|
| 15 |
+
"min_width": 600,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
const jsonData = data || {};
|
| 27 |
+
const chartData = (jsonData.data && 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 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 54 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "600" }
|
| 55 |
+
};
|
| 56 |
+
const sourceColors = jsonData.colors || {};
|
| 57 |
+
const colorOther = sourceColors.other || {};
|
| 58 |
+
const colorField = sourceColors.field || {};
|
| 59 |
+
|
| 60 |
+
d3.select(containerSelector).html("");
|
| 61 |
+
|
| 62 |
+
const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
|
| 63 |
+
const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
|
| 64 |
+
const xField = xColumn.name || "date";
|
| 65 |
+
const yField = yColumn.name || "value";
|
| 66 |
+
const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
|
| 67 |
+
|
| 68 |
+
const width = Number(variables.width) || 700;
|
| 69 |
+
const height = Number(variables.height) || 500;
|
| 70 |
+
const textColor = sourceColors.text_color || "#26313f";
|
| 71 |
+
const lineColor = colorOther.primary || colorField.y || sourceColors.primary || "#1f6bdd";
|
| 72 |
+
const secondaryColor = colorOther.secondary || "#7fb7df";
|
| 73 |
+
const canvasColor = "#f5f9ff";
|
| 74 |
+
const cardColor = "#ffffff";
|
| 75 |
+
const plotColor = "#fafdff";
|
| 76 |
+
const accentColor = "#1272f2";
|
| 77 |
+
const purpleAccent = "#7d43df";
|
| 78 |
+
const tealAccent = "#16b7be";
|
| 79 |
+
const mutedColor = "#56677f";
|
| 80 |
+
const labelTypography = typography.label || {};
|
| 81 |
+
const valueTypography = typography.annotation || labelTypography;
|
| 82 |
+
const labelFamily = labelTypography.font_family || "Arial";
|
| 83 |
+
const valueFamily = valueTypography.font_family || labelFamily;
|
| 84 |
+
const labelWeight = labelTypography.font_weight || "normal";
|
| 85 |
+
const valueWeight = valueTypography.font_weight || "600";
|
| 86 |
+
const baseLabelSize = parseFloat(labelTypography.font_size) || 12;
|
| 87 |
+
const baseValueSize = parseFloat(valueTypography.font_size) || 12;
|
| 88 |
+
|
| 89 |
+
function rgba(color, opacity) {
|
| 90 |
+
const c = d3.rgb(color);
|
| 91 |
+
return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function parseTemporalDate(value) {
|
| 95 |
+
if (value instanceof Date && Number.isFinite(value.getTime())) return value;
|
| 96 |
+
if (typeof value === "number" && Number.isFinite(value)) {
|
| 97 |
+
return new Date(Date.UTC(value, 0, 1));
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
const text = String(value ?? "").trim();
|
| 101 |
+
if (!text) return null;
|
| 102 |
+
|
| 103 |
+
let match = text.match(/^(-?\d{4})$/);
|
| 104 |
+
if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
|
| 105 |
+
|
| 106 |
+
match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
|
| 107 |
+
if (match) {
|
| 108 |
+
return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
match = text.match(/^Q([1-4])\s+(-?\d{4})$/i) || text.match(/^(-?\d{4})\s+Q([1-4])$/i);
|
| 112 |
+
if (match) {
|
| 113 |
+
const year = match[1].length === 4 ? Number(match[1]) : Number(match[2]);
|
| 114 |
+
const quarter = match[1].length === 4 ? Number(match[2]) : Number(match[1]);
|
| 115 |
+
return new Date(Date.UTC(year, (quarter - 1) * 3, 1));
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
match = text.match(/^(spring|summer|fall|autumn|winter)\s+(-?\d{4})$/i);
|
| 119 |
+
if (match) {
|
| 120 |
+
const monthBySeason = { spring: 2, summer: 5, fall: 8, autumn: 8, winter: 11 };
|
| 121 |
+
return new Date(Date.UTC(Number(match[2]), monthBySeason[match[1].toLowerCase()], 1));
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
const parsed = new Date(text);
|
| 125 |
+
if (Number.isFinite(parsed.getTime())) {
|
| 126 |
+
return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()));
|
| 127 |
+
}
|
| 128 |
+
return null;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function formatNumber(value) {
|
| 132 |
+
const numericValue = Number(value);
|
| 133 |
+
if (!Number.isFinite(numericValue)) return String(value ?? "");
|
| 134 |
+
const absValue = Math.abs(numericValue);
|
| 135 |
+
if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
|
| 136 |
+
if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
|
| 137 |
+
if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}K`;
|
| 138 |
+
return d3.format(",.4~g")(numericValue);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function formatWithUnit(value, unit) {
|
| 142 |
+
const formatted = formatNumber(value);
|
| 143 |
+
if (!unit) return formatted;
|
| 144 |
+
if (unit === "$") return `$${formatted}`;
|
| 145 |
+
if (unit === "%") return `${formatted}%`;
|
| 146 |
+
return `${formatted} ${unit}`;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
function formatPointLabel(value, unit) {
|
| 150 |
+
if (!unit || unit === "$" || unit === "%") return formatWithUnit(value, unit);
|
| 151 |
+
return formatNumber(value);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function estimateTextWidth(text, fontSize, weight) {
|
| 155 |
+
const estimated = chartUtilsTextSample(String(text), {
|
| 156 |
+
fontSize,
|
| 157 |
+
fontWeight: weight || 700,
|
| 158 |
+
fontFamily: valueFamily
|
| 159 |
+
});
|
| 160 |
+
if (estimated && Number.isFinite(estimated.width)) return estimated.width;
|
| 161 |
+
return String(text).length * fontSize * 0.58;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
function selectEven(values, maxCount) {
|
| 165 |
+
if (values.length <= maxCount) return values;
|
| 166 |
+
const selected = [];
|
| 167 |
+
for (let i = 0; i < maxCount; i += 1) {
|
| 168 |
+
const index = Math.round(i * (values.length - 1) / (maxCount - 1));
|
| 169 |
+
const value = values[index];
|
| 170 |
+
if (!selected.includes(value)) selected.push(value);
|
| 171 |
+
}
|
| 172 |
+
return selected;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
function formatDateTick(date, labelByTime) {
|
| 176 |
+
const label = labelByTime.get(date.getTime());
|
| 177 |
+
if (label) return label;
|
| 178 |
+
return d3.utcFormat("%Y")(date);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
const parsedRows = chartData.map((d, index) => {
|
| 182 |
+
const date = parseTemporalDate(d[xField]);
|
| 183 |
+
const value = Number(d[yField]);
|
| 184 |
+
return {
|
| 185 |
+
...d,
|
| 186 |
+
__index: index,
|
| 187 |
+
__date: date,
|
| 188 |
+
__time: date ? date.getTime() : NaN,
|
| 189 |
+
__xLabel: String(d[xField] ?? ""),
|
| 190 |
+
__value: value
|
| 191 |
+
};
|
| 192 |
+
}).filter(d => d.__date && Number.isFinite(d.__time) && Number.isFinite(d.__value));
|
| 193 |
+
|
| 194 |
+
if (!parsedRows.length) return;
|
| 195 |
+
|
| 196 |
+
const byTime = new Map();
|
| 197 |
+
parsedRows.forEach(d => {
|
| 198 |
+
if (!byTime.has(d.__time)) {
|
| 199 |
+
byTime.set(d.__time, {
|
| 200 |
+
date: d.__date,
|
| 201 |
+
time: d.__time,
|
| 202 |
+
label: d.__xLabel,
|
| 203 |
+
valueSum: 0,
|
| 204 |
+
count: 0
|
| 205 |
+
});
|
| 206 |
+
}
|
| 207 |
+
const entry = byTime.get(d.__time);
|
| 208 |
+
entry.valueSum += d.__value;
|
| 209 |
+
entry.count += 1;
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
const series = Array.from(byTime.values())
|
| 213 |
+
.map(d => ({
|
| 214 |
+
date: d.date,
|
| 215 |
+
time: d.time,
|
| 216 |
+
label: d.label,
|
| 217 |
+
value: d.valueSum / d.count
|
| 218 |
+
}))
|
| 219 |
+
.sort((a, b) => a.time - b.time);
|
| 220 |
+
|
| 221 |
+
if (!series.length) return;
|
| 222 |
+
|
| 223 |
+
const firstPoint = series[0];
|
| 224 |
+
const lastPoint = series[series.length - 1];
|
| 225 |
+
const minPoint = series.reduce((best, d) => d.value < best.value ? d : best, firstPoint);
|
| 226 |
+
const maxPoint = series.reduce((best, d) => d.value > best.value ? d : best, firstPoint);
|
| 227 |
+
|
| 228 |
+
const xValues = series.map(d => d.date);
|
| 229 |
+
const labelByTime = new Map(series.map(d => [d.time, d.label]));
|
| 230 |
+
const values = series.map(d => d.value);
|
| 231 |
+
let yMin = d3.min(values);
|
| 232 |
+
let yMax = d3.max(values);
|
| 233 |
+
if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) return;
|
| 234 |
+
|
| 235 |
+
const rawYMin = yMin;
|
| 236 |
+
const rawYMax = yMax;
|
| 237 |
+
if (yMin === yMax) {
|
| 238 |
+
const pad = Math.max(1, Math.abs(yMin) * 0.1);
|
| 239 |
+
yMin -= pad;
|
| 240 |
+
yMax += pad;
|
| 241 |
+
} else {
|
| 242 |
+
const pad = (yMax - yMin) * 0.12;
|
| 243 |
+
yMin -= pad;
|
| 244 |
+
yMax += pad;
|
| 245 |
+
}
|
| 246 |
+
if (rawYMin >= 0) yMin = 0;
|
| 247 |
+
if (rawYMax <= 0) yMax = Math.min(0, yMax);
|
| 248 |
+
|
| 249 |
+
const cardMargin = Math.max(20, width * 0.035);
|
| 250 |
+
const badgeHeight = Math.max(40, height * 0.072);
|
| 251 |
+
const margin = {
|
| 252 |
+
top: Math.max(112, height * 0.19),
|
| 253 |
+
right: Math.max(40, width * 0.07),
|
| 254 |
+
bottom: Math.max(58, height * 0.12),
|
| 255 |
+
left: Math.max(72, width * 0.13)
|
| 256 |
+
};
|
| 257 |
+
const innerWidth = Math.max(120, width - margin.left - margin.right);
|
| 258 |
+
const innerHeight = Math.max(120, height - margin.top - margin.bottom);
|
| 259 |
+
const tickCount = innerWidth < 300 ? 4 : 5;
|
| 260 |
+
const xTicks = selectEven(xValues, tickCount);
|
| 261 |
+
|
| 262 |
+
const xExtent = d3.extent(xValues);
|
| 263 |
+
const xPad = Math.max(1, (xExtent[1].getTime() - xExtent[0].getTime()) * 0.03);
|
| 264 |
+
const xScale = d3.scaleUtc()
|
| 265 |
+
.domain([
|
| 266 |
+
new Date(xExtent[0].getTime() - xPad),
|
| 267 |
+
new Date(xExtent[1].getTime() + xPad)
|
| 268 |
+
])
|
| 269 |
+
.range([0, innerWidth]);
|
| 270 |
+
const yScale = d3.scaleLinear()
|
| 271 |
+
.domain([yMin, yMax])
|
| 272 |
+
.nice(5)
|
| 273 |
+
.range([innerHeight, 0]);
|
| 274 |
+
const yTicks = yScale.ticks(5);
|
| 275 |
+
const yDomain = yScale.domain();
|
| 276 |
+
const areaBaselineValue = Math.max(yDomain[0], Math.min(0, yDomain[1]));
|
| 277 |
+
const areaBaselineY = yScale(areaBaselineValue);
|
| 278 |
+
|
| 279 |
+
const svg = d3.select(containerSelector)
|
| 280 |
+
.append("svg")
|
| 281 |
+
.attr("width", "100%")
|
| 282 |
+
.attr("height", height)
|
| 283 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 284 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 285 |
+
.attr("xmlns", "http://www.w3.org/2000/svg");
|
| 286 |
+
|
| 287 |
+
const defs = svg.append("defs");
|
| 288 |
+
const areaGradient = defs.append("linearGradient")
|
| 289 |
+
.attr("id", "spline_graph_01_area_gradient")
|
| 290 |
+
.attr("x1", "0%")
|
| 291 |
+
.attr("x2", "100%")
|
| 292 |
+
.attr("y1", "0%")
|
| 293 |
+
.attr("y2", "100%");
|
| 294 |
+
areaGradient.append("stop").attr("offset", "0%").attr("stop-color", rgba(accentColor, 0.22));
|
| 295 |
+
areaGradient.append("stop").attr("offset", "58%").attr("stop-color", rgba(accentColor, 0.16));
|
| 296 |
+
areaGradient.append("stop").attr("offset", "100%").attr("stop-color", rgba(purpleAccent, 0.24));
|
| 297 |
+
const lineGradient = defs.append("linearGradient")
|
| 298 |
+
.attr("id", "spline_graph_01_line_gradient")
|
| 299 |
+
.attr("x1", "0%")
|
| 300 |
+
.attr("x2", "100%")
|
| 301 |
+
.attr("y1", "0%")
|
| 302 |
+
.attr("y2", "0%");
|
| 303 |
+
lineGradient.append("stop").attr("offset", "0%").attr("stop-color", tealAccent);
|
| 304 |
+
lineGradient.append("stop").attr("offset", "58%").attr("stop-color", accentColor);
|
| 305 |
+
lineGradient.append("stop").attr("offset", "100%").attr("stop-color", purpleAccent);
|
| 306 |
+
const badgeGradient = defs.append("linearGradient")
|
| 307 |
+
.attr("id", "spline_graph_01_badge_gradient")
|
| 308 |
+
.attr("x1", "0%")
|
| 309 |
+
.attr("x2", "100%")
|
| 310 |
+
.attr("y1", "0%")
|
| 311 |
+
.attr("y2", "0%");
|
| 312 |
+
badgeGradient.append("stop").attr("offset", "0%").attr("stop-color", "#1644bd");
|
| 313 |
+
badgeGradient.append("stop").attr("offset", "100%").attr("stop-color", accentColor);
|
| 314 |
+
const cardShadow = defs.append("filter")
|
| 315 |
+
.attr("id", "spline_graph_01_card_shadow")
|
| 316 |
+
.attr("x", "-8%")
|
| 317 |
+
.attr("y", "-12%")
|
| 318 |
+
.attr("width", "116%")
|
| 319 |
+
.attr("height", "128%");
|
| 320 |
+
cardShadow.append("feDropShadow")
|
| 321 |
+
.attr("dx", 0)
|
| 322 |
+
.attr("dy", 2)
|
| 323 |
+
.attr("stdDeviation", 2.2)
|
| 324 |
+
.attr("flood-color", "#34619a")
|
| 325 |
+
.attr("flood-opacity", 0.14);
|
| 326 |
+
|
| 327 |
+
svg.append("rect")
|
| 328 |
+
.attr("class", "chart-canvas")
|
| 329 |
+
.attr("x", 0)
|
| 330 |
+
.attr("y", 0)
|
| 331 |
+
.attr("width", width)
|
| 332 |
+
.attr("height", height)
|
| 333 |
+
.attr("fill", canvasColor);
|
| 334 |
+
|
| 335 |
+
svg.append("rect")
|
| 336 |
+
.attr("class", "chart-card")
|
| 337 |
+
.attr("x", cardMargin)
|
| 338 |
+
.attr("y", cardMargin)
|
| 339 |
+
.attr("width", width - cardMargin * 2)
|
| 340 |
+
.attr("height", height - cardMargin * 2)
|
| 341 |
+
.attr("rx", 18)
|
| 342 |
+
.attr("ry", 18)
|
| 343 |
+
.attr("fill", cardColor)
|
| 344 |
+
.attr("stroke", rgba(accentColor, 0.18))
|
| 345 |
+
.attr("stroke-width", 1.2)
|
| 346 |
+
.style("filter", "url(#spline_graph_01_card_shadow)");
|
| 347 |
+
|
| 348 |
+
const badgeX = margin.left - 44;
|
| 349 |
+
const badgeY = cardMargin + 28;
|
| 350 |
+
const badgeText = yUnit ? `${yField} (${yUnit})` : yField;
|
| 351 |
+
const badge = svg.append("g")
|
| 352 |
+
.attr("class", "chart-title-badge")
|
| 353 |
+
.attr("data-role", "measure-label")
|
| 354 |
+
.attr("transform", `translate(${badgeX}, ${badgeY})`);
|
| 355 |
+
badge.append("circle")
|
| 356 |
+
.attr("class", "badge-icon-slot")
|
| 357 |
+
.attr("data-asset-slot", "measure-icon")
|
| 358 |
+
.attr("data-asset-source-policy", "neutral_placeholder")
|
| 359 |
+
.attr("data-anchor", "chart-card-top-left")
|
| 360 |
+
.attr("data-collision-rule", "avoid measure label and y-axis tick labels")
|
| 361 |
+
.attr("cx", badgeHeight / 2)
|
| 362 |
+
.attr("cy", badgeHeight / 2)
|
| 363 |
+
.attr("r", badgeHeight * 0.43)
|
| 364 |
+
.attr("fill", "url(#spline_graph_01_badge_gradient)")
|
| 365 |
+
.attr("stroke", "rgba(255,255,255,0.82)")
|
| 366 |
+
.attr("stroke-width", 1.4)
|
| 367 |
+
.attr("filter", "url(#spline_graph_01_card_shadow)");
|
| 368 |
+
badge.append("circle")
|
| 369 |
+
.attr("class", "neutral-asset-placeholder")
|
| 370 |
+
.attr("cx", badgeHeight / 2)
|
| 371 |
+
.attr("cy", badgeHeight / 2)
|
| 372 |
+
.attr("r", badgeHeight * 0.18)
|
| 373 |
+
.attr("fill", "none")
|
| 374 |
+
.attr("stroke", "rgba(255,255,255,0.86)")
|
| 375 |
+
.attr("stroke-width", 2);
|
| 376 |
+
badge.append("text")
|
| 377 |
+
.attr("x", badgeHeight + 12)
|
| 378 |
+
.attr("y", badgeHeight / 2 + 5)
|
| 379 |
+
.attr("text-anchor", "start")
|
| 380 |
+
.style("font-family", valueFamily)
|
| 381 |
+
.style("font-size", `${Math.max(13, Math.min(baseValueSize + 4, 16))}px`)
|
| 382 |
+
.style("font-weight", 700)
|
| 383 |
+
.style("fill", "#071A48")
|
| 384 |
+
.text(badgeText);
|
| 385 |
+
|
| 386 |
+
const chart = svg.append("g")
|
| 387 |
+
.attr("class", "spline-plot")
|
| 388 |
+
.attr("data-count", series.length)
|
| 389 |
+
.attr("data-latest-value", lastPoint?.value ?? "")
|
| 390 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 391 |
+
|
| 392 |
+
chart.append("rect")
|
| 393 |
+
.attr("class", "plot-background")
|
| 394 |
+
.attr("x", 0)
|
| 395 |
+
.attr("y", 0)
|
| 396 |
+
.attr("width", innerWidth)
|
| 397 |
+
.attr("height", innerHeight)
|
| 398 |
+
.attr("rx", 0)
|
| 399 |
+
.attr("ry", 0)
|
| 400 |
+
.attr("fill", plotColor)
|
| 401 |
+
.attr("stroke", "none");
|
| 402 |
+
|
| 403 |
+
yTicks.forEach(tick => {
|
| 404 |
+
chart.append("line")
|
| 405 |
+
.attr("class", "gridline")
|
| 406 |
+
.attr("x1", 0)
|
| 407 |
+
.attr("x2", innerWidth)
|
| 408 |
+
.attr("y1", yScale(tick))
|
| 409 |
+
.attr("y2", yScale(tick))
|
| 410 |
+
.attr("stroke", rgba(accentColor, 0.17))
|
| 411 |
+
.attr("stroke-dasharray", "3 4")
|
| 412 |
+
.attr("stroke-width", 1);
|
| 413 |
+
|
| 414 |
+
chart.append("text")
|
| 415 |
+
.attr("class", "axis-tick y-axis-tick")
|
| 416 |
+
.attr("x", -8)
|
| 417 |
+
.attr("y", yScale(tick))
|
| 418 |
+
.attr("text-anchor", "end")
|
| 419 |
+
.attr("dominant-baseline", "middle")
|
| 420 |
+
.style("font-family", labelFamily)
|
| 421 |
+
.style("font-size", `${Math.max(10, Math.min(baseLabelSize + 1, 13))}px`)
|
| 422 |
+
.style("font-weight", labelWeight)
|
| 423 |
+
.style("fill", rgba("#07184a", 0.86))
|
| 424 |
+
.text(formatWithUnit(tick, yUnit));
|
| 425 |
+
});
|
| 426 |
+
|
| 427 |
+
xTicks.forEach(tick => {
|
| 428 |
+
chart.append("line")
|
| 429 |
+
.attr("class", "gridline x-gridline")
|
| 430 |
+
.attr("x1", xScale(tick))
|
| 431 |
+
.attr("x2", xScale(tick))
|
| 432 |
+
.attr("y1", 0)
|
| 433 |
+
.attr("y2", innerHeight)
|
| 434 |
+
.attr("stroke", rgba(accentColor, 0.15))
|
| 435 |
+
.attr("stroke-dasharray", "3 4")
|
| 436 |
+
.attr("stroke-width", 1);
|
| 437 |
+
|
| 438 |
+
chart.append("text")
|
| 439 |
+
.attr("class", "axis-tick x-axis-tick")
|
| 440 |
+
.attr("x", xScale(tick))
|
| 441 |
+
.attr("y", innerHeight + 22)
|
| 442 |
+
.attr("text-anchor", "middle")
|
| 443 |
+
.style("font-family", labelFamily)
|
| 444 |
+
.style("font-size", `${Math.max(10, Math.min(baseLabelSize + 1, 13))}px`)
|
| 445 |
+
.style("font-weight", labelWeight)
|
| 446 |
+
.style("fill", rgba("#07184a", 0.84))
|
| 447 |
+
.text(formatDateTick(tick, labelByTime));
|
| 448 |
+
});
|
| 449 |
+
|
| 450 |
+
chart.append("line")
|
| 451 |
+
.attr("class", "axis-line x-axis-line")
|
| 452 |
+
.attr("x1", 0)
|
| 453 |
+
.attr("x2", innerWidth)
|
| 454 |
+
.attr("y1", innerHeight)
|
| 455 |
+
.attr("y2", innerHeight)
|
| 456 |
+
.attr("stroke", rgba("#07184a", 0.70))
|
| 457 |
+
.attr("stroke-width", 1.2);
|
| 458 |
+
|
| 459 |
+
chart.append("line")
|
| 460 |
+
.attr("class", "axis-line y-axis-line")
|
| 461 |
+
.attr("x1", 0)
|
| 462 |
+
.attr("x2", 0)
|
| 463 |
+
.attr("y1", 0)
|
| 464 |
+
.attr("y2", innerHeight)
|
| 465 |
+
.attr("stroke", rgba("#07184a", 0.70))
|
| 466 |
+
.attr("stroke-width", 1.2);
|
| 467 |
+
|
| 468 |
+
const spline = d3.line()
|
| 469 |
+
.defined(d => Number.isFinite(d.value))
|
| 470 |
+
.x(d => xScale(d.date))
|
| 471 |
+
.y(d => yScale(d.value))
|
| 472 |
+
.curve(d3.curveMonotoneX);
|
| 473 |
+
|
| 474 |
+
const area = d3.area()
|
| 475 |
+
.defined(d => Number.isFinite(d.value))
|
| 476 |
+
.x(d => xScale(d.date))
|
| 477 |
+
.y0(areaBaselineY)
|
| 478 |
+
.y1(d => yScale(d.value))
|
| 479 |
+
.curve(d3.curveMonotoneX);
|
| 480 |
+
|
| 481 |
+
chart.append("path")
|
| 482 |
+
.datum(series)
|
| 483 |
+
.attr("class", "spline-area mark")
|
| 484 |
+
.attr("data-count", series.length)
|
| 485 |
+
.attr("fill", "url(#spline_graph_01_area_gradient)")
|
| 486 |
+
.attr("stroke", "none")
|
| 487 |
+
.attr("d", area);
|
| 488 |
+
|
| 489 |
+
chart.append("path")
|
| 490 |
+
.datum(series)
|
| 491 |
+
.attr("class", "spline-halo")
|
| 492 |
+
.attr("data-count", series.length)
|
| 493 |
+
.attr("fill", "none")
|
| 494 |
+
.attr("stroke", rgba(accentColor, 0.20))
|
| 495 |
+
.attr("stroke-width", variables.has_stroke === false ? 6.2 : 7.6)
|
| 496 |
+
.attr("stroke-linecap", "round")
|
| 497 |
+
.attr("stroke-linejoin", "round")
|
| 498 |
+
.attr("d", spline);
|
| 499 |
+
|
| 500 |
+
chart.append("path")
|
| 501 |
+
.datum(series)
|
| 502 |
+
.attr("class", "spline line mark")
|
| 503 |
+
.attr("data-count", series.length)
|
| 504 |
+
.attr("data-latest-value", lastPoint?.value ?? "")
|
| 505 |
+
.attr("fill", "none")
|
| 506 |
+
.attr("stroke", "url(#spline_graph_01_line_gradient)")
|
| 507 |
+
.attr("stroke-width", variables.has_stroke === false ? 3.6 : 4.4)
|
| 508 |
+
.attr("stroke-linecap", "round")
|
| 509 |
+
.attr("stroke-linejoin", "round")
|
| 510 |
+
.attr("d", spline);
|
| 511 |
+
|
| 512 |
+
chart.selectAll(".point")
|
| 513 |
+
.data(series)
|
| 514 |
+
.enter()
|
| 515 |
+
.append("circle")
|
| 516 |
+
.attr("class", "point mark")
|
| 517 |
+
.attr("data-time", d => d.time)
|
| 518 |
+
.attr("data-label", d => d.label)
|
| 519 |
+
.attr("data-value", d => d.value)
|
| 520 |
+
.attr("cx", d => xScale(d.date))
|
| 521 |
+
.attr("cy", d => yScale(d.value))
|
| 522 |
+
.attr("r", 7)
|
| 523 |
+
.attr("fill", "#ffffff")
|
| 524 |
+
.attr("stroke", d => d.time === lastPoint.time ? purpleAccent : accentColor)
|
| 525 |
+
.attr("stroke-width", 3);
|
| 526 |
+
|
| 527 |
+
const labelBoxes = [];
|
| 528 |
+
const valueFontSize = Math.max(12, Math.min(baseValueSize + 4, 17));
|
| 529 |
+
const importantTimes = new Set([
|
| 530 |
+
firstPoint.time,
|
| 531 |
+
lastPoint.time,
|
| 532 |
+
minPoint.time,
|
| 533 |
+
maxPoint.time
|
| 534 |
+
]);
|
| 535 |
+
const labelCandidates = series.map((point, index) => {
|
| 536 |
+
const x = xScale(point.date);
|
| 537 |
+
const y = Math.max(12, Math.min(innerHeight - 8, yScale(point.value) - 10));
|
| 538 |
+
const isLast = point.time === lastPoint.time;
|
| 539 |
+
const isFirst = point.time === firstPoint.time;
|
| 540 |
+
const labelText = formatPointLabel(point.value, yUnit);
|
| 541 |
+
const textAnchor = isLast ? "end" : (isFirst ? "start" : "middle");
|
| 542 |
+
const labelWidth = estimateTextWidth(labelText, valueFontSize, 700) + 10;
|
| 543 |
+
const left = textAnchor === "end" ? x - labelWidth : (textAnchor === "start" ? x : x - labelWidth / 2);
|
| 544 |
+
const right = textAnchor === "end" ? x : (textAnchor === "start" ? x + labelWidth : x + labelWidth / 2);
|
| 545 |
+
const top = y - valueFontSize;
|
| 546 |
+
const bottom = y + 4;
|
| 547 |
+
const priority = (isLast ? 100 : 0)
|
| 548 |
+
+ (point.time === firstPoint.time ? 80 : 0)
|
| 549 |
+
+ (point.time === minPoint.time || point.time === maxPoint.time ? 70 : 0)
|
| 550 |
+
+ (index % 2 === 0 ? 8 : 0);
|
| 551 |
+
return { point, x, y, isLast, labelText, textAnchor, left, right, top, bottom, priority, index };
|
| 552 |
+
}).sort((a, b) => b.priority - a.priority || a.index - b.index);
|
| 553 |
+
const selectedLabels = [];
|
| 554 |
+
labelCandidates.forEach(candidate => {
|
| 555 |
+
const inside = candidate.left >= -2 && candidate.right <= innerWidth + 2 && candidate.top >= -2 && candidate.bottom <= innerHeight + 12;
|
| 556 |
+
const overlaps = labelBoxes.some(box => (
|
| 557 |
+
candidate.left < box.right
|
| 558 |
+
&& candidate.right > box.left
|
| 559 |
+
&& candidate.top < box.bottom
|
| 560 |
+
&& candidate.bottom > box.top
|
| 561 |
+
));
|
| 562 |
+
if (inside && !overlaps) {
|
| 563 |
+
labelBoxes.push({
|
| 564 |
+
left: candidate.left - 3,
|
| 565 |
+
right: candidate.right + 3,
|
| 566 |
+
top: candidate.top - 3,
|
| 567 |
+
bottom: candidate.bottom + 3
|
| 568 |
+
});
|
| 569 |
+
selectedLabels.push(candidate);
|
| 570 |
+
}
|
| 571 |
+
});
|
| 572 |
+
|
| 573 |
+
selectedLabels.sort((a, b) => a.index - b.index).forEach(candidate => {
|
| 574 |
+
const point = candidate.point;
|
| 575 |
+
chart.append("text")
|
| 576 |
+
.attr("class", candidate.isLast ? "value latest-value" : "value key-value")
|
| 577 |
+
.attr("data-time", point.time)
|
| 578 |
+
.attr("data-label", point.label)
|
| 579 |
+
.attr("data-value", point.value)
|
| 580 |
+
.attr("x", candidate.x)
|
| 581 |
+
.attr("y", candidate.y)
|
| 582 |
+
.attr("text-anchor", candidate.textAnchor)
|
| 583 |
+
.style("font-family", valueFamily)
|
| 584 |
+
.style("font-size", `${valueFontSize}px`)
|
| 585 |
+
.style("font-weight", 700)
|
| 586 |
+
.style("fill", candidate.isLast ? purpleAccent : accentColor)
|
| 587 |
+
.text(candidate.labelText);
|
| 588 |
+
});
|
| 589 |
+
|
| 590 |
+
const slotW = Math.min(innerWidth * 0.36, 180);
|
| 591 |
+
const slotH = Math.min(innerHeight * 0.30, 132);
|
| 592 |
+
const slotX = Math.max(innerWidth * 0.52, innerWidth - slotW - 28);
|
| 593 |
+
const slotY = Math.min(innerHeight - slotH - 42, innerHeight * 0.53);
|
| 594 |
+
const growthSlot = chart.append("g")
|
| 595 |
+
.attr("class", "reserved-asset-slot spline-growth-cluster-slot")
|
| 596 |
+
.attr("data-asset-slot", "spline-growth-cluster")
|
| 597 |
+
.attr("data-asset-source-policy", "image2_asset")
|
| 598 |
+
.attr("data-anchor", "plot-lower-right")
|
| 599 |
+
.attr("data-collision-rule", "avoid spline, point labels, axes, and x tick labels")
|
| 600 |
+
.attr("transform", `translate(${slotX}, ${slotY})`);
|
| 601 |
+
growthSlot.append("rect")
|
| 602 |
+
.attr("class", "asset-slot-bbox")
|
| 603 |
+
.attr("width", slotW)
|
| 604 |
+
.attr("height", slotH)
|
| 605 |
+
.attr("rx", 16)
|
| 606 |
+
.attr("ry", 16)
|
| 607 |
+
.attr("fill", "rgba(255,255,255,0.66)")
|
| 608 |
+
.attr("stroke", rgba(purpleAccent, 0.30))
|
| 609 |
+
.attr("stroke-dasharray", "6 6")
|
| 610 |
+
.attr("stroke-width", 1.2)
|
| 611 |
+
.attr("filter", "url(#spline_graph_01_card_shadow)");
|
| 612 |
+
const dotGroup = growthSlot.append("g")
|
| 613 |
+
.attr("class", "decorative-dot-texture")
|
| 614 |
+
.attr("opacity", 0.45);
|
| 615 |
+
const dotSpacing = 13;
|
| 616 |
+
d3.range(0, 28).forEach(i => {
|
| 617 |
+
const col = i % 7;
|
| 618 |
+
const row = Math.floor(i / 7);
|
| 619 |
+
dotGroup.append("rect")
|
| 620 |
+
.attr("x", slotW * 0.08 + col * dotSpacing)
|
| 621 |
+
.attr("y", slotH * 0.58 + row * dotSpacing)
|
| 622 |
+
.attr("width", 4)
|
| 623 |
+
.attr("height", 4)
|
| 624 |
+
.attr("rx", 1)
|
| 625 |
+
.attr("fill", i % 3 === 0 ? rgba(purpleAccent, 0.45) : rgba(accentColor, 0.32));
|
| 626 |
+
});
|
| 627 |
+
growthSlot.append("circle")
|
| 628 |
+
.attr("class", "neutral-asset-placeholder")
|
| 629 |
+
.attr("cx", slotW * 0.58)
|
| 630 |
+
.attr("cy", slotH * 0.50)
|
| 631 |
+
.attr("r", Math.min(slotW, slotH) * 0.22)
|
| 632 |
+
.attr("fill", "none")
|
| 633 |
+
.attr("stroke", rgba(purpleAccent, 0.32))
|
| 634 |
+
.attr("stroke-width", 2);
|
| 635 |
+
|
| 636 |
+
return svg.node();
|
| 637 |
+
}
|
modules/chart_engine/template/d3-js/type26_spline_graph/spline_graph_plain_chart_02.js
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Spline Graph",
|
| 5 |
+
"chart_name": "spline_graph_plain_chart_02",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 30], ["-inf", "inf"], [2, 8]],
|
| 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": 600,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
const jsonData = data || {};
|
| 27 |
+
const chartData = (jsonData.data && jsonData.data.data) || [];
|
| 28 |
+
const variables = jsonData.variables || {};
|
| 29 |
+
const typography = jsonData.typography || {};
|
| 30 |
+
const colors = jsonData.colors || {};
|
| 31 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 32 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 33 |
+
const metadata = jsonData.metadata || {};
|
| 34 |
+
|
| 35 |
+
d3.select(containerSelector).html("");
|
| 36 |
+
|
| 37 |
+
const xField = (chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || dataColumns[0] || {}).name || chartUtils.schema.columnField(dataColumns, 0);
|
| 38 |
+
const yColumn = (chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || dataColumns[1] || {});
|
| 39 |
+
const yField = yColumn.name || chartUtils.schema.columnField(dataColumns, 1);
|
| 40 |
+
const groupField = (chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || dataColumns[2] || {}).name || chartUtils.schema.columnField(dataColumns, 2);
|
| 41 |
+
const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
|
| 42 |
+
|
| 43 |
+
const width = Math.max(980, Number(variables.width) || 980);
|
| 44 |
+
const height = Math.max(600, Number(variables.height) || 600);
|
| 45 |
+
const labelTypography = typography.label || {};
|
| 46 |
+
const titleTypography = typography.title || {};
|
| 47 |
+
const labelFamily = labelTypography.font_family || "Arial";
|
| 48 |
+
const titleFamily = titleTypography.font_family || labelFamily;
|
| 49 |
+
const baseLabelSize = parseFloat(labelTypography.font_size) || 13;
|
| 50 |
+
|
| 51 |
+
function rgba(color, opacity) {
|
| 52 |
+
const parsed = d3.rgb(color || "#000000");
|
| 53 |
+
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${opacity})`;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function isTemporalFieldName(fieldName) {
|
| 57 |
+
return /(year|date|month|time|timestamp|quarter|season|period|week|day)/i.test(String(fieldName || ""));
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function parseTemporal(value, fieldName) {
|
| 61 |
+
const temporalField = isTemporalFieldName(fieldName);
|
| 62 |
+
if (value instanceof Date && Number.isFinite(value.getTime())) return value;
|
| 63 |
+
if (typeof value === "number" && Number.isFinite(value)) {
|
| 64 |
+
if (Math.abs(value) >= 100000000000) return new Date(value);
|
| 65 |
+
if (Math.abs(value) >= 1000000000 && Math.abs(value) < 100000000000) return new Date(value * 1000);
|
| 66 |
+
if (temporalField && /month/i.test(String(fieldName || "")) && value >= 1 && value <= 12) {
|
| 67 |
+
return new Date(Date.UTC(2000, Math.round(value) - 1, 1));
|
| 68 |
+
}
|
| 69 |
+
if (value >= 1000 && value <= 9999) return new Date(Date.UTC(value, 0, 1));
|
| 70 |
+
return null;
|
| 71 |
+
}
|
| 72 |
+
const text = String(value ?? "").trim();
|
| 73 |
+
if (!text) return null;
|
| 74 |
+
const lower = text.toLowerCase();
|
| 75 |
+
const monthIndex = {
|
| 76 |
+
jan: 0, january: 0,
|
| 77 |
+
feb: 1, february: 1,
|
| 78 |
+
mar: 2, march: 2,
|
| 79 |
+
apr: 3, april: 3,
|
| 80 |
+
may: 4,
|
| 81 |
+
jun: 5, june: 5,
|
| 82 |
+
jul: 6, july: 6,
|
| 83 |
+
aug: 7, august: 7,
|
| 84 |
+
sep: 8, sept: 8, september: 8,
|
| 85 |
+
oct: 9, october: 9,
|
| 86 |
+
nov: 10, november: 10,
|
| 87 |
+
dec: 11, december: 11
|
| 88 |
+
};
|
| 89 |
+
const seasonIndex = {
|
| 90 |
+
winter: 0,
|
| 91 |
+
spring: 1,
|
| 92 |
+
summer: 2,
|
| 93 |
+
fall: 3,
|
| 94 |
+
autumn: 3
|
| 95 |
+
};
|
| 96 |
+
let match = text.match(/^(-?\d{4})$/);
|
| 97 |
+
if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
|
| 98 |
+
match = lower.match(/^(?:q([1-4])|quarter\s*([1-4]))(?:[\s,/-]*(\d{4}))?$/);
|
| 99 |
+
if (match && temporalField) {
|
| 100 |
+
const quarter = Number(match[1] || match[2]);
|
| 101 |
+
const year = match[3] ? Number(match[3]) : 2000;
|
| 102 |
+
return new Date(Date.UTC(year, (quarter - 1) * 3, 1));
|
| 103 |
+
}
|
| 104 |
+
match = lower.match(/^(\d{4})[\s,/-]*(?:q([1-4])|quarter\s*([1-4]))$/);
|
| 105 |
+
if (match) return new Date(Date.UTC(Number(match[1]), (Number(match[2] || match[3]) - 1) * 3, 1));
|
| 106 |
+
match = lower.match(/^([a-z]+)(?:[\s,/-]+(\d{4}))?$/);
|
| 107 |
+
if (match && temporalField && Object.prototype.hasOwnProperty.call(monthIndex, match[1])) {
|
| 108 |
+
return new Date(Date.UTC(match[2] ? Number(match[2]) : 2000, monthIndex[match[1]], 1));
|
| 109 |
+
}
|
| 110 |
+
if (match && temporalField && Object.prototype.hasOwnProperty.call(seasonIndex, match[1])) {
|
| 111 |
+
return new Date(Date.UTC(match[2] ? Number(match[2]) : 2000, seasonIndex[match[1]] * 3, 1));
|
| 112 |
+
}
|
| 113 |
+
match = lower.match(/^(\d{4})[\s,/-]+([a-z]+)$/);
|
| 114 |
+
if (match && Object.prototype.hasOwnProperty.call(monthIndex, match[2])) {
|
| 115 |
+
return new Date(Date.UTC(Number(match[1]), monthIndex[match[2]], 1));
|
| 116 |
+
}
|
| 117 |
+
if (match && Object.prototype.hasOwnProperty.call(seasonIndex, match[2])) {
|
| 118 |
+
return new Date(Date.UTC(Number(match[1]), seasonIndex[match[2]] * 3, 1));
|
| 119 |
+
}
|
| 120 |
+
match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
|
| 121 |
+
if (match) return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
|
| 122 |
+
const parsed = /[0-9]/.test(text) || temporalField ? new Date(text) : new Date(NaN);
|
| 123 |
+
if (Number.isFinite(parsed.getTime())) {
|
| 124 |
+
return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()));
|
| 125 |
+
}
|
| 126 |
+
return null;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function formatNumber(value) {
|
| 130 |
+
const numeric = Number(value);
|
| 131 |
+
if (!Number.isFinite(numeric)) return String(value ?? "");
|
| 132 |
+
const absValue = Math.abs(numeric);
|
| 133 |
+
if (absValue >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
|
| 134 |
+
if (absValue >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
|
| 135 |
+
if (absValue >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
|
| 136 |
+
return d3.format(",.4~g")(numeric);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function formatWithUnit(value) {
|
| 140 |
+
const formatted = formatNumber(value);
|
| 141 |
+
if (!yUnit) return formatted;
|
| 142 |
+
if (yUnit === "%") return `${formatted}%`;
|
| 143 |
+
if (yUnit === "$") return `$${formatted}`;
|
| 144 |
+
return `${formatted} ${yUnit}`;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
function wrapText(selection, text, maxWidth, lineHeight, maxLines) {
|
| 148 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 149 |
+
const lines = [];
|
| 150 |
+
let current = "";
|
| 151 |
+
words.forEach(word => {
|
| 152 |
+
const next = current ? `${current} ${word}` : word;
|
| 153 |
+
if (next.length * lineHeight * 0.52 <= maxWidth || !current) {
|
| 154 |
+
current = next;
|
| 155 |
+
} else {
|
| 156 |
+
lines.push(current);
|
| 157 |
+
current = word;
|
| 158 |
+
}
|
| 159 |
+
});
|
| 160 |
+
if (current) lines.push(current);
|
| 161 |
+
const visible = lines.slice(0, maxLines);
|
| 162 |
+
if (lines.length > maxLines && visible.length) {
|
| 163 |
+
visible[visible.length - 1] = `${visible[visible.length - 1].replace(/[,.!?;:]*$/, "")}...`;
|
| 164 |
+
}
|
| 165 |
+
selection.text(null);
|
| 166 |
+
visible.forEach((line, index) => {
|
| 167 |
+
selection.append("tspan")
|
| 168 |
+
.attr("x", selection.attr("x"))
|
| 169 |
+
.attr("dy", index === 0 ? 0 : lineHeight)
|
| 170 |
+
.text(line);
|
| 171 |
+
});
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function estimateTextWidth(text, fontSize, letterSpacing = 0) {
|
| 175 |
+
return String(text || "").split("").reduce((sum, ch) => {
|
| 176 |
+
if (/\s/.test(ch)) return sum + fontSize * 0.34 + letterSpacing;
|
| 177 |
+
if ("ilI.,'`".includes(ch)) return sum + fontSize * 0.32 + letterSpacing;
|
| 178 |
+
if ("mwMW".includes(ch)) return sum + fontSize * 0.92 + letterSpacing;
|
| 179 |
+
if (/[A-Z0-9]/.test(ch)) return sum + fontSize * 0.68 + letterSpacing;
|
| 180 |
+
return sum + fontSize * 0.58 + letterSpacing;
|
| 181 |
+
}, 0);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
function wrapTextNoEllipsis(selection, text, maxWidth, fontSize, lineHeight, maxLines, letterSpacing = 0) {
|
| 185 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 186 |
+
const lines = [];
|
| 187 |
+
let current = "";
|
| 188 |
+
words.forEach(word => {
|
| 189 |
+
const next = current ? `${current} ${word}` : word;
|
| 190 |
+
if (estimateTextWidth(next, fontSize, letterSpacing) <= maxWidth || !current) {
|
| 191 |
+
current = next;
|
| 192 |
+
} else {
|
| 193 |
+
lines.push(current);
|
| 194 |
+
current = word;
|
| 195 |
+
}
|
| 196 |
+
});
|
| 197 |
+
if (current) lines.push(current);
|
| 198 |
+
selection.text(null);
|
| 199 |
+
lines.forEach((line, index) => {
|
| 200 |
+
selection.append("tspan")
|
| 201 |
+
.attr("x", selection.attr("x"))
|
| 202 |
+
.attr("dy", index === 0 ? 0 : lineHeight)
|
| 203 |
+
.text(line);
|
| 204 |
+
});
|
| 205 |
+
return lines;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function wrapTitleLines(text, maxChars, maxLines) {
|
| 209 |
+
const words = String(text || "").toUpperCase().split(/\s+/).filter(Boolean);
|
| 210 |
+
const lines = [];
|
| 211 |
+
let current = "";
|
| 212 |
+
words.forEach(word => {
|
| 213 |
+
const next = current ? `${current} ${word}` : word;
|
| 214 |
+
if (next.length <= maxChars || !current) {
|
| 215 |
+
current = next;
|
| 216 |
+
} else {
|
| 217 |
+
lines.push(current);
|
| 218 |
+
current = word;
|
| 219 |
+
}
|
| 220 |
+
});
|
| 221 |
+
if (current) lines.push(current);
|
| 222 |
+
if (lines.length <= maxLines) return lines;
|
| 223 |
+
const kept = lines.slice(0, maxLines);
|
| 224 |
+
kept[maxLines - 1] = `${kept[maxLines - 1].replace(/[,.!?;:]*$/, "")}...`;
|
| 225 |
+
return kept;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function addText(parent, attrs, styles, text) {
|
| 229 |
+
const node = parent.append("text");
|
| 230 |
+
Object.entries(attrs || {}).forEach(([key, value]) => node.attr(key, value));
|
| 231 |
+
Object.entries(styles || {}).forEach(([key, value]) => node.style(key, value));
|
| 232 |
+
if (text !== undefined) node.text(text);
|
| 233 |
+
return node;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
function getGroupColor(group, index) {
|
| 237 |
+
const palette = ["#B7202E", "#159E9A", "#9BCC2E", "#237FD1", "#8C35C8", "#F39C12", "#4D84E2", "#38A1E6"];
|
| 238 |
+
if (index !== undefined && index >= 0) return palette[index % palette.length];
|
| 239 |
+
const resolved = colorResolver.field(group, index || 0, { fallbackKey: "primary" });
|
| 240 |
+
if (resolved && resolved.value) return resolved.value;
|
| 241 |
+
return palette[index % palette.length];
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
const rows = chartData.map((row, index) => {
|
| 245 |
+
const date = parseTemporal(row[xField], xField);
|
| 246 |
+
const groupTime = parseTemporal(row[groupField], groupField);
|
| 247 |
+
const value = Number(row[yField]);
|
| 248 |
+
const group = String(row[groupField] ?? `Series ${index + 1}`);
|
| 249 |
+
return {
|
| 250 |
+
...row,
|
| 251 |
+
__date: date,
|
| 252 |
+
__time: date ? date.getTime() : NaN,
|
| 253 |
+
__value: value,
|
| 254 |
+
__group: group,
|
| 255 |
+
__groupTime: groupTime ? groupTime.getTime() : NaN,
|
| 256 |
+
__xLabel: String(row[xField] ?? "")
|
| 257 |
+
};
|
| 258 |
+
}).filter(row => row.__date && Number.isFinite(row.__time) && Number.isFinite(row.__value));
|
| 259 |
+
|
| 260 |
+
if (!rows.length) return null;
|
| 261 |
+
|
| 262 |
+
const groups = Array.from(d3.group(rows, d => d.__group), ([name, values], index) => ({
|
| 263 |
+
name,
|
| 264 |
+
values: values.slice().sort((a, b) => a.__time - b.__time),
|
| 265 |
+
color: getGroupColor(name, index),
|
| 266 |
+
index
|
| 267 |
+
})).filter(series => series.values.length);
|
| 268 |
+
|
| 269 |
+
groups.forEach(series => {
|
| 270 |
+
series.first = series.values[0];
|
| 271 |
+
series.last = series.values[series.values.length - 1];
|
| 272 |
+
series.latestValue = series.last.__value;
|
| 273 |
+
series.groupTime = Number.isFinite(series.values[0].__groupTime) ? series.values[0].__groupTime : NaN;
|
| 274 |
+
});
|
| 275 |
+
const groupDimensionIsTemporal = isTemporalFieldName(groupField)
|
| 276 |
+
&& groups.length > 0
|
| 277 |
+
&& groups.every(series => Number.isFinite(series.groupTime));
|
| 278 |
+
groups.sort((a, b) => {
|
| 279 |
+
if (groupDimensionIsTemporal) return d3.ascending(a.groupTime, b.groupTime);
|
| 280 |
+
return d3.descending(a.latestValue, b.latestValue);
|
| 281 |
+
});
|
| 282 |
+
groups.forEach((series, index) => {
|
| 283 |
+
series.rank = index;
|
| 284 |
+
series.color = getGroupColor(series.name, index);
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
const allDates = rows.map(d => d.__date);
|
| 288 |
+
const allValues = rows.map(d => d.__value);
|
| 289 |
+
const xExtent = d3.extent(allDates);
|
| 290 |
+
const rawMax = d3.max(allValues);
|
| 291 |
+
const yMax = Math.max(1, rawMax || 1);
|
| 292 |
+
const niceTop = Math.max(10, Math.ceil(yMax / 20) * 20);
|
| 293 |
+
|
| 294 |
+
const svg = d3.select(containerSelector)
|
| 295 |
+
.append("svg")
|
| 296 |
+
.attr("width", width)
|
| 297 |
+
.attr("height", height)
|
| 298 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 299 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 300 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 301 |
+
.attr("style", "max-width: 100%; height: auto;");
|
| 302 |
+
|
| 303 |
+
const defs = svg.append("defs");
|
| 304 |
+
const bgGradient = defs.append("linearGradient")
|
| 305 |
+
.attr("id", "spline02-bg-gradient")
|
| 306 |
+
.attr("x1", "0%").attr("y1", "0%")
|
| 307 |
+
.attr("x2", "100%").attr("y2", "100%");
|
| 308 |
+
bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#E8F6FF");
|
| 309 |
+
bgGradient.append("stop").attr("offset", "58%").attr("stop-color", "#F8FCFF");
|
| 310 |
+
bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#EEF8FF");
|
| 311 |
+
|
| 312 |
+
const plotGradient = defs.append("linearGradient")
|
| 313 |
+
.attr("id", "spline02-plot-gradient")
|
| 314 |
+
.attr("x1", "0%").attr("y1", "0%")
|
| 315 |
+
.attr("x2", "100%").attr("y2", "100%");
|
| 316 |
+
plotGradient.append("stop").attr("offset", "0%").attr("stop-color", "#FFFFFF");
|
| 317 |
+
plotGradient.append("stop").attr("offset", "100%").attr("stop-color", "#F4FBFF");
|
| 318 |
+
|
| 319 |
+
const shadow = defs.append("filter")
|
| 320 |
+
.attr("id", "spline02-card-shadow")
|
| 321 |
+
.attr("x", "-8%").attr("y", "-12%")
|
| 322 |
+
.attr("width", "116%").attr("height", "128%");
|
| 323 |
+
shadow.append("feDropShadow")
|
| 324 |
+
.attr("dx", 0)
|
| 325 |
+
.attr("dy", 3)
|
| 326 |
+
.attr("stdDeviation", 4)
|
| 327 |
+
.attr("flood-color", "#2D5B8C")
|
| 328 |
+
.attr("flood-opacity", 0.16);
|
| 329 |
+
|
| 330 |
+
const softShadow = defs.append("filter")
|
| 331 |
+
.attr("id", "spline02-soft-shadow")
|
| 332 |
+
.attr("x", "-12%").attr("y", "-18%")
|
| 333 |
+
.attr("width", "124%").attr("height", "136%");
|
| 334 |
+
softShadow.append("feDropShadow")
|
| 335 |
+
.attr("dx", 0)
|
| 336 |
+
.attr("dy", 3)
|
| 337 |
+
.attr("stdDeviation", 6)
|
| 338 |
+
.attr("flood-color", "#5483B1")
|
| 339 |
+
.attr("flood-opacity", 0.18);
|
| 340 |
+
|
| 341 |
+
svg.append("rect")
|
| 342 |
+
.attr("class", "spline02-page-background")
|
| 343 |
+
.attr("width", width)
|
| 344 |
+
.attr("height", height)
|
| 345 |
+
.attr("fill", "#E3F3FF");
|
| 346 |
+
|
| 347 |
+
const cardMargin = 14;
|
| 348 |
+
svg.append("rect")
|
| 349 |
+
.attr("class", "spline02-outer-card")
|
| 350 |
+
.attr("x", cardMargin)
|
| 351 |
+
.attr("y", cardMargin)
|
| 352 |
+
.attr("width", width - cardMargin * 2)
|
| 353 |
+
.attr("height", height - cardMargin * 2)
|
| 354 |
+
.attr("rx", 16)
|
| 355 |
+
.attr("ry", 16)
|
| 356 |
+
.attr("fill", "url(#spline02-bg-gradient)")
|
| 357 |
+
.attr("stroke", "#B7D5EA")
|
| 358 |
+
.attr("stroke-width", 1)
|
| 359 |
+
.style("filter", "url(#spline02-card-shadow)");
|
| 360 |
+
|
| 361 |
+
const plot = {
|
| 362 |
+
x: 66,
|
| 363 |
+
y: 83,
|
| 364 |
+
width: 530,
|
| 365 |
+
height: 446
|
| 366 |
+
};
|
| 367 |
+
const right = {
|
| 368 |
+
x: 628,
|
| 369 |
+
y: 68,
|
| 370 |
+
width: 318,
|
| 371 |
+
height: 466
|
| 372 |
+
};
|
| 373 |
+
|
| 374 |
+
const xPad = Math.max(1, (xExtent[1].getTime() - xExtent[0].getTime()) * 0.03);
|
| 375 |
+
const xScale = d3.scaleUtc()
|
| 376 |
+
.domain([new Date(xExtent[0].getTime() - xPad), new Date(xExtent[1].getTime() + xPad)])
|
| 377 |
+
.range([0, plot.width]);
|
| 378 |
+
const yScale = d3.scaleLinear()
|
| 379 |
+
.domain([0, niceTop])
|
| 380 |
+
.range([plot.height, 0]);
|
| 381 |
+
|
| 382 |
+
const titleText = String(metadata.title || "Internet Speeds in Canada");
|
| 383 |
+
const mainTitle = titleText.toLowerCase().includes("internet") && titleText.toLowerCase().includes("canada")
|
| 384 |
+
? ["INTERNET SPEEDS", "IN CANADA"]
|
| 385 |
+
: wrapTitleLines(titleText, 21, 3);
|
| 386 |
+
const kicker = metadata.main_insight
|
| 387 |
+
? String(metadata.main_insight).replace(/\.$/, "")
|
| 388 |
+
: "Broadband subsidies boost";
|
| 389 |
+
|
| 390 |
+
const friendlyYField = String(yField || "").replace(/([a-z])([A-Z])/g, "$1 $2");
|
| 391 |
+
const inferredUnit = yUnit || (/speed/i.test(friendlyYField) ? "Mbps" : "");
|
| 392 |
+
const measureLabel = inferredUnit ? `${friendlyYField} (${inferredUnit})` : friendlyYField;
|
| 393 |
+
const measure = svg.append("g")
|
| 394 |
+
.attr("class", "spline02-measure-label")
|
| 395 |
+
.attr("data-role", "measure-label")
|
| 396 |
+
.attr("transform", `translate(28, 48)`);
|
| 397 |
+
measure.append("circle")
|
| 398 |
+
.attr("class", "reserved-asset-slot measure-icon-slot")
|
| 399 |
+
.attr("data-asset-slot", "spline02-measure-icon")
|
| 400 |
+
.attr("data-asset-source-policy", "neutral_placeholder")
|
| 401 |
+
.attr("data-anchor", "outer-card-top-left")
|
| 402 |
+
.attr("data-collision-rule", "avoid measure label and plot y-axis labels")
|
| 403 |
+
.attr("cx", 18)
|
| 404 |
+
.attr("cy", 0)
|
| 405 |
+
.attr("r", 16)
|
| 406 |
+
.attr("fill", "#0878A6")
|
| 407 |
+
.attr("stroke", "#0E4D79")
|
| 408 |
+
.attr("stroke-width", 1.2);
|
| 409 |
+
measure.append("circle")
|
| 410 |
+
.attr("class", "neutral-asset-placeholder")
|
| 411 |
+
.attr("cx", 18)
|
| 412 |
+
.attr("cy", 0)
|
| 413 |
+
.attr("r", 7)
|
| 414 |
+
.attr("fill", "none")
|
| 415 |
+
.attr("stroke", "#FFFFFF")
|
| 416 |
+
.attr("stroke-width", 2);
|
| 417 |
+
addText(measure, { x: 50, y: 5, "class": "spline02-measure-text" }, {
|
| 418 |
+
"font-family": titleFamily,
|
| 419 |
+
"font-size": "16px",
|
| 420 |
+
"font-weight": 800,
|
| 421 |
+
"fill": "#071B43"
|
| 422 |
+
}, measureLabel);
|
| 423 |
+
|
| 424 |
+
const chart = svg.append("g")
|
| 425 |
+
.attr("class", "spline02-plot")
|
| 426 |
+
.attr("data-x-field", xField)
|
| 427 |
+
.attr("data-y-field", yField)
|
| 428 |
+
.attr("data-group-field", groupField)
|
| 429 |
+
.attr("transform", `translate(${plot.x}, ${plot.y})`);
|
| 430 |
+
|
| 431 |
+
chart.append("rect")
|
| 432 |
+
.attr("class", "plot-background")
|
| 433 |
+
.attr("x", 0)
|
| 434 |
+
.attr("y", 0)
|
| 435 |
+
.attr("width", plot.width)
|
| 436 |
+
.attr("height", plot.height)
|
| 437 |
+
.attr("rx", 13)
|
| 438 |
+
.attr("ry", 13)
|
| 439 |
+
.attr("fill", "url(#spline02-plot-gradient)")
|
| 440 |
+
.attr("stroke", "#A9CFEB")
|
| 441 |
+
.attr("stroke-width", 1);
|
| 442 |
+
|
| 443 |
+
const yTicks = yScale.ticks(5);
|
| 444 |
+
yTicks.forEach(tick => {
|
| 445 |
+
chart.append("line")
|
| 446 |
+
.attr("class", "gridline y-gridline")
|
| 447 |
+
.attr("x1", 0)
|
| 448 |
+
.attr("x2", plot.width)
|
| 449 |
+
.attr("y1", yScale(tick))
|
| 450 |
+
.attr("y2", yScale(tick))
|
| 451 |
+
.attr("stroke", "#BBDDF2")
|
| 452 |
+
.attr("stroke-dasharray", "3 4")
|
| 453 |
+
.attr("stroke-width", 1);
|
| 454 |
+
addText(chart, { x: -22, y: yScale(tick) + 5, "text-anchor": "end", "class": "axis-tick y-axis-tick" }, {
|
| 455 |
+
"font-family": labelFamily,
|
| 456 |
+
"font-size": `${Math.max(12, baseLabelSize + 2)}px`,
|
| 457 |
+
"font-weight": 600,
|
| 458 |
+
"fill": "#071B43"
|
| 459 |
+
}, formatNumber(tick));
|
| 460 |
+
});
|
| 461 |
+
|
| 462 |
+
const xTickValues = Array.from(new Set(rows.map(d => d.__time)))
|
| 463 |
+
.sort((a, b) => a - b)
|
| 464 |
+
.map(time => new Date(time));
|
| 465 |
+
const xTicks = xTickValues.length <= 8 ? xTickValues : xScale.ticks(7);
|
| 466 |
+
xTicks.forEach(tick => {
|
| 467 |
+
chart.append("line")
|
| 468 |
+
.attr("class", "gridline x-gridline")
|
| 469 |
+
.attr("x1", xScale(tick))
|
| 470 |
+
.attr("x2", xScale(tick))
|
| 471 |
+
.attr("y1", 0)
|
| 472 |
+
.attr("y2", plot.height)
|
| 473 |
+
.attr("stroke", "#BBDDF2")
|
| 474 |
+
.attr("stroke-dasharray", "3 4")
|
| 475 |
+
.attr("stroke-width", 1);
|
| 476 |
+
addText(chart, { x: xScale(tick), y: plot.height + 22, "text-anchor": "middle", "class": "axis-tick x-axis-tick" }, {
|
| 477 |
+
"font-family": labelFamily,
|
| 478 |
+
"font-size": `${Math.max(11, baseLabelSize + 1)}px`,
|
| 479 |
+
"font-weight": 600,
|
| 480 |
+
"fill": "#071B43"
|
| 481 |
+
}, d3.utcFormat("%Y")(tick));
|
| 482 |
+
});
|
| 483 |
+
|
| 484 |
+
const slotBaseX = plot.width * 0.065;
|
| 485 |
+
const slotBaseY = plot.height * 0.055;
|
| 486 |
+
let slotW = Math.min(226, plot.width * 0.48);
|
| 487 |
+
let slotH = Math.min(214, plot.height * 0.48);
|
| 488 |
+
function slotCollisionPenalty(candidate) {
|
| 489 |
+
const pad = candidate.compact ? 8 : 14;
|
| 490 |
+
let score = rows.reduce((currentScore, point) => {
|
| 491 |
+
const px = xScale(point.__date);
|
| 492 |
+
const py = yScale(point.__value);
|
| 493 |
+
const inside = px >= candidate.x - pad
|
| 494 |
+
&& px <= candidate.x + candidate.w + pad
|
| 495 |
+
&& py >= candidate.y - pad
|
| 496 |
+
&& py <= candidate.y + candidate.h + pad;
|
| 497 |
+
return currentScore + (inside ? 120 : 0);
|
| 498 |
+
}, 0);
|
| 499 |
+
groups.forEach(series => {
|
| 500 |
+
for (let i = 1; i < series.values.length; i += 1) {
|
| 501 |
+
const a = series.values[i - 1];
|
| 502 |
+
const b = series.values[i];
|
| 503 |
+
for (let step = 0; step <= 24; step += 1) {
|
| 504 |
+
const t = step / 24;
|
| 505 |
+
const px = xScale(a.__date) * (1 - t) + xScale(b.__date) * t;
|
| 506 |
+
const py = yScale(a.__value) * (1 - t) + yScale(b.__value) * t;
|
| 507 |
+
const insideCore = px >= candidate.x
|
| 508 |
+
&& px <= candidate.x + candidate.w
|
| 509 |
+
&& py >= candidate.y
|
| 510 |
+
&& py <= candidate.y + candidate.h;
|
| 511 |
+
if (px >= candidate.x - pad
|
| 512 |
+
&& px <= candidate.x + candidate.w + pad
|
| 513 |
+
&& py >= candidate.y - pad
|
| 514 |
+
&& py <= candidate.y + candidate.h + pad) {
|
| 515 |
+
score += insideCore ? 90 : 36;
|
| 516 |
+
}
|
| 517 |
+
}
|
| 518 |
+
}
|
| 519 |
+
const endpointX = xScale(series.last.__date);
|
| 520 |
+
const endpointY = yScale(series.latestValue);
|
| 521 |
+
if (endpointX >= candidate.x - pad
|
| 522 |
+
&& endpointX <= candidate.x + candidate.w + pad
|
| 523 |
+
&& endpointY >= candidate.y - pad
|
| 524 |
+
&& endpointY <= candidate.y + candidate.h + pad) {
|
| 525 |
+
score += 180;
|
| 526 |
+
}
|
| 527 |
+
});
|
| 528 |
+
return score;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
function normalizeSlotCandidate(candidate) {
|
| 532 |
+
const safePad = 12;
|
| 533 |
+
const xMax = Math.max(safePad, Math.min(plot.width * 0.58, plot.width - candidate.w - safePad));
|
| 534 |
+
const yMax = Math.max(safePad, plot.height - candidate.h - 42);
|
| 535 |
+
const x = Math.max(safePad, Math.min(candidate.x, xMax));
|
| 536 |
+
const y = Math.max(safePad, Math.min(candidate.y, yMax));
|
| 537 |
+
const collision = slotCollisionPenalty({ ...candidate, x, y });
|
| 538 |
+
const drift = Math.abs(x - slotBaseX) / plot.width + Math.abs(y - slotBaseY) / plot.height;
|
| 539 |
+
const compactPenalty = candidate.compact ? 4 : 0;
|
| 540 |
+
const microPenalty = candidate.micro ? 9 : 0;
|
| 541 |
+
return {
|
| 542 |
+
...candidate,
|
| 543 |
+
x,
|
| 544 |
+
y,
|
| 545 |
+
collision,
|
| 546 |
+
score: collision * 1000 + drift * 30 + compactPenalty + microPenalty
|
| 547 |
+
};
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
const mediumW = Math.min(184, plot.width * 0.34);
|
| 551 |
+
const mediumH = Math.min(112, plot.height * 0.23);
|
| 552 |
+
const compactW = Math.min(150, plot.width * 0.28);
|
| 553 |
+
const compactH = Math.min(58, plot.height * 0.13);
|
| 554 |
+
const microW = Math.min(112, plot.width * 0.22);
|
| 555 |
+
const microH = Math.min(38, plot.height * 0.09);
|
| 556 |
+
const slotCandidates = [
|
| 557 |
+
{ x: slotBaseX, y: slotBaseY, w: slotW, h: slotH, variant: "reference-upper-left", compact: false },
|
| 558 |
+
{ x: slotBaseX, y: slotBaseY, w: mediumW, h: mediumH, variant: "medium-upper-left", compact: true },
|
| 559 |
+
{ x: slotBaseX + plot.width * 0.12, y: slotBaseY, w: mediumW, h: mediumH, variant: "medium-shifted-right", compact: true },
|
| 560 |
+
{ x: slotBaseX, y: slotBaseY + plot.height * 0.15, w: mediumW, h: mediumH, variant: "medium-lowered", compact: true },
|
| 561 |
+
{ x: slotBaseX + plot.width * 0.12, y: slotBaseY + plot.height * 0.15, w: mediumW, h: mediumH, variant: "medium-lowered-shifted", compact: true },
|
| 562 |
+
{ x: slotBaseX, y: slotBaseY + plot.height * 0.28, w: compactW, h: compactH, variant: "compact-lower-left", compact: true },
|
| 563 |
+
{ x: slotBaseX + plot.width * 0.14, y: slotBaseY + plot.height * 0.28, w: compactW, h: compactH, variant: "compact-lower-shifted", compact: true },
|
| 564 |
+
{ x: slotBaseX + plot.width * 0.26, y: slotBaseY + plot.height * 0.12, w: compactW, h: compactH, variant: "compact-inner-upper", compact: true },
|
| 565 |
+
{ x: slotBaseX + plot.width * 0.26, y: slotBaseY + plot.height * 0.28, w: compactW, h: compactH, variant: "compact-inner-lower", compact: true },
|
| 566 |
+
{ x: slotBaseX, y: slotBaseY + plot.height * 0.42, w: microW, h: microH, variant: "micro-low-left", compact: true, micro: true },
|
| 567 |
+
{ x: slotBaseX + plot.width * 0.18, y: slotBaseY + plot.height * 0.42, w: microW, h: microH, variant: "micro-low-shifted", compact: true, micro: true },
|
| 568 |
+
{ x: slotBaseX + plot.width * 0.34, y: slotBaseY + plot.height * 0.22, w: microW, h: microH, variant: "micro-inner", compact: true, micro: true },
|
| 569 |
+
{ x: slotBaseX, y: plot.height - microH - 54, w: microW, h: microH, variant: "micro-bottom-left", compact: true, micro: true },
|
| 570 |
+
{ x: slotBaseX + plot.width * 0.18, y: plot.height - microH - 54, w: microW, h: microH, variant: "micro-bottom-shifted", compact: true, micro: true },
|
| 571 |
+
{ x: slotBaseX + plot.width * 0.34, y: plot.height - microH - 54, w: microW, h: microH, variant: "micro-bottom-inner", compact: true, micro: true }
|
| 572 |
+
].map(normalizeSlotCandidate);
|
| 573 |
+
let slotCandidate = slotCandidates.sort((a, b) => a.score - b.score || a.y - b.y || a.x - b.x)[0];
|
| 574 |
+
slotW = slotCandidate.w;
|
| 575 |
+
slotH = slotCandidate.h;
|
| 576 |
+
const slotX = slotCandidate.x;
|
| 577 |
+
const slotY = slotCandidate.y;
|
| 578 |
+
const slotIsCompact = slotCandidate.compact || slotCandidate.collision > 0;
|
| 579 |
+
const slotHasCollision = slotCandidate.collision > 0;
|
| 580 |
+
const slotVisualSuppressed = slotHasCollision;
|
| 581 |
+
const slot = chart.append("g")
|
| 582 |
+
.attr("class", "reserved-asset-slot spline02-plot-illustration-card")
|
| 583 |
+
.attr("data-asset-slot", "spline02-plot-illustration-card")
|
| 584 |
+
.attr("data-asset-source-policy", "image2_asset")
|
| 585 |
+
.attr("data-anchor", "plot-upper-left")
|
| 586 |
+
.attr("data-placement-variant", slotCandidate.variant || "base")
|
| 587 |
+
.attr("data-placeholder-text-policy", "suppressed-no-ellipsis")
|
| 588 |
+
.attr("data-visibility-policy", slotVisualSuppressed ? "semantic-only-no-visible-placeholder-after-collision" : "visible-neutral-placeholder")
|
| 589 |
+
.attr("data-collision-rule", "stay inside plot; avoid splines, endpoints, axes/ticks, and right cards; shrink/relocate/fade and suppress internals when no clear space exists")
|
| 590 |
+
.attr("data-collision-score", slotCandidate.collision)
|
| 591 |
+
.attr("transform", `translate(${slotX}, ${slotY})`);
|
| 592 |
+
slot.append("rect")
|
| 593 |
+
.attr("class", "asset-slot-bbox")
|
| 594 |
+
.attr("x", 0)
|
| 595 |
+
.attr("y", 0)
|
| 596 |
+
.attr("width", slotW)
|
| 597 |
+
.attr("height", slotH)
|
| 598 |
+
.attr("rx", 18)
|
| 599 |
+
.attr("ry", 18)
|
| 600 |
+
.attr("fill", "#FFFFFF")
|
| 601 |
+
.attr("fill-opacity", slotVisualSuppressed ? 0 : (slotIsCompact ? 0.34 : 1))
|
| 602 |
+
.attr("stroke", "#C7DDF0")
|
| 603 |
+
.attr("stroke-opacity", slotVisualSuppressed ? 0 : (slotIsCompact ? 0.62 : 1))
|
| 604 |
+
.attr("stroke-width", 1)
|
| 605 |
+
.style("filter", "url(#spline02-soft-shadow)");
|
| 606 |
+
slot.append("rect")
|
| 607 |
+
.attr("class", "asset-slot-mask")
|
| 608 |
+
.attr("x", 12)
|
| 609 |
+
.attr("y", 16)
|
| 610 |
+
.attr("width", slotW - 24)
|
| 611 |
+
.attr("height", slotH * 0.50)
|
| 612 |
+
.attr("rx", 10)
|
| 613 |
+
.attr("fill", "#EAF6FE")
|
| 614 |
+
.attr("fill-opacity", slotVisualSuppressed ? 0 : (slotIsCompact ? 0.30 : 1))
|
| 615 |
+
.attr("stroke", "#D7E8F6")
|
| 616 |
+
.attr("stroke-opacity", slotVisualSuppressed ? 0 : (slotIsCompact ? 0.44 : 1))
|
| 617 |
+
.attr("stroke-width", 1)
|
| 618 |
+
.attr("stroke-dasharray", "5 5");
|
| 619 |
+
if (!slotVisualSuppressed) {
|
| 620 |
+
slot.append("circle")
|
| 621 |
+
.attr("class", "neutral-asset-placeholder")
|
| 622 |
+
.attr("cx", slotW * 0.50)
|
| 623 |
+
.attr("cy", slotIsCompact ? slotH * 0.50 : slotH * 0.31)
|
| 624 |
+
.attr("r", Math.min(slotW, slotH) * (slotIsCompact ? 0.14 : 0.15))
|
| 625 |
+
.attr("fill", "none")
|
| 626 |
+
.attr("stroke", "#1E74CF")
|
| 627 |
+
.attr("stroke-opacity", slotIsCompact ? 0.62 : 1)
|
| 628 |
+
.attr("stroke-width", 3)
|
| 629 |
+
.attr("stroke-dasharray", "6 5");
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
const line = d3.line()
|
| 633 |
+
.defined(d => Number.isFinite(d.__value))
|
| 634 |
+
.x(d => xScale(d.__date))
|
| 635 |
+
.y(d => yScale(d.__value))
|
| 636 |
+
.curve(d3.curveMonotoneX);
|
| 637 |
+
|
| 638 |
+
groups.slice().reverse().forEach(series => {
|
| 639 |
+
chart.append("path")
|
| 640 |
+
.datum(series.values)
|
| 641 |
+
.attr("class", "spline-line mark")
|
| 642 |
+
.attr("data-series", series.name)
|
| 643 |
+
.attr("fill", "none")
|
| 644 |
+
.attr("stroke", series.color)
|
| 645 |
+
.attr("stroke-width", 3)
|
| 646 |
+
.attr("stroke-linecap", "round")
|
| 647 |
+
.attr("stroke-linejoin", "round")
|
| 648 |
+
.attr("d", line);
|
| 649 |
+
});
|
| 650 |
+
|
| 651 |
+
groups.forEach(series => {
|
| 652 |
+
chart.append("circle")
|
| 653 |
+
.attr("class", "endpoint-dot mark")
|
| 654 |
+
.attr("data-series", series.name)
|
| 655 |
+
.attr("data-value", series.latestValue)
|
| 656 |
+
.attr("cx", xScale(series.last.__date))
|
| 657 |
+
.attr("cy", yScale(series.latestValue))
|
| 658 |
+
.attr("r", 4.5)
|
| 659 |
+
.attr("fill", series.color)
|
| 660 |
+
.attr("stroke", "#FFFFFF")
|
| 661 |
+
.attr("stroke-width", 1.4);
|
| 662 |
+
});
|
| 663 |
+
|
| 664 |
+
const rightGroup = svg.append("g")
|
| 665 |
+
.attr("class", "spline02-right-column")
|
| 666 |
+
.attr("transform", `translate(${right.x}, ${right.y})`);
|
| 667 |
+
|
| 668 |
+
rightGroup.append("line")
|
| 669 |
+
.attr("x1", 0)
|
| 670 |
+
.attr("x2", right.width)
|
| 671 |
+
.attr("y1", 0)
|
| 672 |
+
.attr("y2", 0)
|
| 673 |
+
.attr("stroke", "#1E74CF")
|
| 674 |
+
.attr("stroke-width", 1.2);
|
| 675 |
+
rightGroup.append("circle")
|
| 676 |
+
.attr("class", "reserved-asset-slot header-emblem-slot")
|
| 677 |
+
.attr("data-asset-slot", "spline02-header-emblem")
|
| 678 |
+
.attr("data-asset-source-policy", "neutral_placeholder")
|
| 679 |
+
.attr("data-anchor", "right-title-top-center")
|
| 680 |
+
.attr("data-collision-rule", "stay above title and below outer-card edge")
|
| 681 |
+
.attr("cx", right.width / 2)
|
| 682 |
+
.attr("cy", 0)
|
| 683 |
+
.attr("r", 13)
|
| 684 |
+
.attr("fill", "#F2FAFF")
|
| 685 |
+
.attr("stroke", "#1E74CF")
|
| 686 |
+
.attr("stroke-width", 1.2);
|
| 687 |
+
|
| 688 |
+
const titleFontSize = mainTitle.length <= 2 ? 34 : 24;
|
| 689 |
+
const titleLineStep = mainTitle.length <= 2 ? 52 : 30;
|
| 690 |
+
const titleStartY = mainTitle.length <= 2 ? 76 : 58;
|
| 691 |
+
mainTitle.forEach((lineText, index) => {
|
| 692 |
+
addText(rightGroup, { x: 0, y: 76 + index * 58, "class": "right-main-title" }, {
|
| 693 |
+
"font-family": titleFamily,
|
| 694 |
+
"font-size": `${titleFontSize}px`,
|
| 695 |
+
"font-weight": 900,
|
| 696 |
+
"fill": index === 0 ? "#08264C" : "#176EC8",
|
| 697 |
+
"letter-spacing": "0px"
|
| 698 |
+
}, lineText);
|
| 699 |
+
});
|
| 700 |
+
|
| 701 |
+
rightGroup.selectAll(".right-main-title")
|
| 702 |
+
.attr("y", (d, index) => titleStartY + index * titleLineStep);
|
| 703 |
+
|
| 704 |
+
const dividerY = titleStartY + (mainTitle.length - 1) * titleLineStep + (mainTitle.length <= 2 ? 48 : 32);
|
| 705 |
+
const kickerY = dividerY + 28;
|
| 706 |
+
|
| 707 |
+
rightGroup.append("line")
|
| 708 |
+
.attr("x1", 0)
|
| 709 |
+
.attr("x2", right.width)
|
| 710 |
+
.attr("y1", dividerY)
|
| 711 |
+
.attr("y2", dividerY)
|
| 712 |
+
.attr("stroke", "#1E74CF")
|
| 713 |
+
.attr("stroke-width", 1.1);
|
| 714 |
+
|
| 715 |
+
const kickerText = (titleText.toLowerCase().includes("broadband subsidies boost")
|
| 716 |
+
? "Broadband subsidies boost"
|
| 717 |
+
: kicker).toUpperCase();
|
| 718 |
+
const visibleCardCount = Math.min(groups.length, 8);
|
| 719 |
+
let cardGap = visibleCardCount >= 6 ? 6 : 8;
|
| 720 |
+
let cardH = 42;
|
| 721 |
+
const rightBottom = height - right.y - 22;
|
| 722 |
+
const cardStackHeight = Math.max(0, visibleCardCount * cardH + Math.max(0, visibleCardCount - 1) * cardGap);
|
| 723 |
+
const maxKickerBottom = Math.max(kickerY + 18, rightBottom - cardStackHeight - 18);
|
| 724 |
+
const maxKickerLines = visibleCardCount <= 4
|
| 725 |
+
? 3
|
| 726 |
+
: (maxKickerBottom - kickerY >= 30 ? 2 : 2);
|
| 727 |
+
const kickerMaxWidth = right.width - 34;
|
| 728 |
+
let kickerFont = kickerText.length > 42 ? 12 : 14;
|
| 729 |
+
let kickerTracking = kickerText.length > 42 ? 0.6 : 1.2;
|
| 730 |
+
let kickerLineHeight = kickerText.length > 42 ? 14 : 16;
|
| 731 |
+
while (kickerFont > 11) {
|
| 732 |
+
const tentativeLines = (() => {
|
| 733 |
+
const words = kickerText.split(/\s+/).filter(Boolean);
|
| 734 |
+
const lines = [];
|
| 735 |
+
let current = "";
|
| 736 |
+
words.forEach(word => {
|
| 737 |
+
const next = current ? `${current} ${word}` : word;
|
| 738 |
+
if (estimateTextWidth(next, kickerFont, kickerTracking) <= kickerMaxWidth || !current) {
|
| 739 |
+
current = next;
|
| 740 |
+
} else {
|
| 741 |
+
lines.push(current);
|
| 742 |
+
current = word;
|
| 743 |
+
}
|
| 744 |
+
});
|
| 745 |
+
if (current) lines.push(current);
|
| 746 |
+
return lines;
|
| 747 |
+
})();
|
| 748 |
+
if (tentativeLines.length <= maxKickerLines) break;
|
| 749 |
+
kickerFont -= 1;
|
| 750 |
+
kickerTracking = Math.max(0.2, kickerTracking - 0.2);
|
| 751 |
+
kickerLineHeight = Math.max(13, kickerLineHeight - 1);
|
| 752 |
+
}
|
| 753 |
+
const kickerNode = addText(rightGroup, { x: right.width / 2, y: kickerY, "text-anchor": "middle", "class": "right-kicker" }, {
|
| 754 |
+
"font-family": titleFamily,
|
| 755 |
+
"font-size": `${kickerFont}px`,
|
| 756 |
+
"font-weight": 800,
|
| 757 |
+
"fill": "#6E7076",
|
| 758 |
+
"letter-spacing": `${kickerTracking}px`
|
| 759 |
+
}, "");
|
| 760 |
+
const kickerLines = wrapTextNoEllipsis(kickerNode, kickerText, kickerMaxWidth, kickerFont, kickerLineHeight, maxKickerLines, kickerTracking);
|
| 761 |
+
const cardY0 = kickerY + Math.max(1, kickerLines.length) * kickerLineHeight + 18;
|
| 762 |
+
const availableCardHeight = Math.max(0, rightBottom - cardY0);
|
| 763 |
+
if (visibleCardCount > 0) {
|
| 764 |
+
const fittedCardH = (availableCardHeight - Math.max(0, visibleCardCount - 1) * cardGap) / visibleCardCount;
|
| 765 |
+
if (Number.isFinite(fittedCardH) && fittedCardH < cardH) {
|
| 766 |
+
cardH = Math.max(30, fittedCardH);
|
| 767 |
+
const fittedGap = visibleCardCount > 1
|
| 768 |
+
? (availableCardHeight - visibleCardCount * cardH) / (visibleCardCount - 1)
|
| 769 |
+
: cardGap;
|
| 770 |
+
cardGap = Math.max(4, Math.min(cardGap, fittedGap));
|
| 771 |
+
}
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
const chipW = 50;
|
| 775 |
+
const cardW = right.width;
|
| 776 |
+
groups.slice(0, 8).forEach((series, index) => {
|
| 777 |
+
const y = cardY0 + index * (cardH + cardGap);
|
| 778 |
+
const card = rightGroup.append("g")
|
| 779 |
+
.attr("class", "endpoint-value-card")
|
| 780 |
+
.attr("data-series", series.name)
|
| 781 |
+
.attr("data-value", series.latestValue)
|
| 782 |
+
.attr("data-relationship", "grid-aligned-centerline-to-endpoint-rank")
|
| 783 |
+
.attr("transform", `translate(0, ${y})`);
|
| 784 |
+
card.append("rect")
|
| 785 |
+
.attr("class", "endpoint-card-bg")
|
| 786 |
+
.attr("x", 0)
|
| 787 |
+
.attr("y", 0)
|
| 788 |
+
.attr("width", cardW)
|
| 789 |
+
.attr("height", cardH)
|
| 790 |
+
.attr("rx", 8)
|
| 791 |
+
.attr("ry", 8)
|
| 792 |
+
.attr("fill", "#FFFFFF")
|
| 793 |
+
.attr("stroke", "#E7F0F6")
|
| 794 |
+
.attr("stroke-width", 1)
|
| 795 |
+
.style("filter", "url(#spline02-soft-shadow)");
|
| 796 |
+
card.append("rect")
|
| 797 |
+
.attr("class", "endpoint-value-chip")
|
| 798 |
+
.attr("x", 0)
|
| 799 |
+
.attr("y", 0)
|
| 800 |
+
.attr("width", chipW)
|
| 801 |
+
.attr("height", cardH)
|
| 802 |
+
.attr("rx", 6)
|
| 803 |
+
.attr("ry", 6)
|
| 804 |
+
.attr("fill", series.color);
|
| 805 |
+
const valueText = d3.format(".0f")(series.latestValue);
|
| 806 |
+
const nominalValueFontSize = valueText.length <= 2 ? 22 : (valueText.length === 3 ? 20 : (valueText.length === 4 ? 17 : 14));
|
| 807 |
+
const valueFontSize = Math.min(nominalValueFontSize, Math.max(14, cardH * 0.52));
|
| 808 |
+
const cardTextY = Math.min(cardH - 8, cardH * 0.66);
|
| 809 |
+
addText(card, { x: chipW / 2, y: cardTextY, "text-anchor": "middle", "class": "endpoint-value" }, {
|
| 810 |
+
"font-family": titleFamily,
|
| 811 |
+
"font-size": `${valueFontSize}px`,
|
| 812 |
+
"font-weight": 900,
|
| 813 |
+
"fill": "#FFFFFF"
|
| 814 |
+
}, valueText);
|
| 815 |
+
const label = addText(card, { x: chipW + 14, y: cardTextY - 1, "class": "endpoint-label" }, {
|
| 816 |
+
"font-family": labelFamily,
|
| 817 |
+
"font-size": `${Math.max(10, Math.min(13, baseLabelSize - 1, cardH * 0.36))}px`,
|
| 818 |
+
"font-weight": 800,
|
| 819 |
+
"fill": "#071B43"
|
| 820 |
+
}, "");
|
| 821 |
+
wrapText(label, series.name, cardW - chipW - 64, 15, 1);
|
| 822 |
+
|
| 823 |
+
const miniX = cardW - 38;
|
| 824 |
+
const miniBaseline = cardH - 7;
|
| 825 |
+
const miniMaxH = Math.max(10, cardH - 18);
|
| 826 |
+
const recent = series.values.slice(-4);
|
| 827 |
+
const miniValues = recent.map(d => d.__value);
|
| 828 |
+
const miniMin = d3.min(miniValues);
|
| 829 |
+
const miniMax = d3.max(miniValues);
|
| 830 |
+
recent.forEach((point, barIndex) => {
|
| 831 |
+
const ratio = miniMax === miniMin ? 0.55 : (point.__value - miniMin) / (miniMax - miniMin);
|
| 832 |
+
const h = Math.min(miniMaxH, 5 + ratio * Math.max(8, miniMaxH - 5));
|
| 833 |
+
card.append("rect")
|
| 834 |
+
.attr("class", "endpoint-mini-bar mark")
|
| 835 |
+
.attr("data-series", series.name)
|
| 836 |
+
.attr("data-time", point.__time)
|
| 837 |
+
.attr("x", miniX + barIndex * 8)
|
| 838 |
+
.attr("y", miniBaseline - h)
|
| 839 |
+
.attr("width", 5)
|
| 840 |
+
.attr("height", h)
|
| 841 |
+
.attr("rx", 1)
|
| 842 |
+
.attr("fill", series.color)
|
| 843 |
+
.attr("opacity", 0.88);
|
| 844 |
+
});
|
| 845 |
+
});
|
| 846 |
+
|
| 847 |
+
return svg.node();
|
| 848 |
+
}
|
modules/chart_engine/template/d3-js/type26_spline_graph/spline_graph_plain_chart_03.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Spline Graph",
|
| 5 |
+
"chart_name": "spline_graph_plain_chart_03",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[3, 50], [-1000, 1000], [2, 2]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": [],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": ["gradient", "opacity"],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "styled",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes",
|
| 21 |
+
"chart_for": "comparison"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data || {};
|
| 28 |
+
const chartData = (jsonData.data && jsonData.data.data) || [];
|
| 29 |
+
const variables = jsonData.variables || {};
|
| 30 |
+
const typography = jsonData.typography || {};
|
| 31 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 32 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 33 |
+
|
| 34 |
+
d3.select(containerSelector).html("");
|
| 35 |
+
|
| 36 |
+
const xField = (chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || dataColumns[0] || {}).name || chartUtils.schema.columnField(dataColumns, 0);
|
| 37 |
+
const yColumn = (chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || dataColumns[1] || {});
|
| 38 |
+
const yField = yColumn.name || chartUtils.schema.columnField(dataColumns, 1);
|
| 39 |
+
const groupField = (chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || dataColumns[2] || {}).name || chartUtils.schema.columnField(dataColumns, 2);
|
| 40 |
+
const width = Math.max(520, Number(variables.width) || 640);
|
| 41 |
+
const height = Math.max(420, Number(variables.height) || 520);
|
| 42 |
+
const fontFamily = (typography.label && typography.label.font_family) || "Century Gothic, Arial, sans-serif";
|
| 43 |
+
|
| 44 |
+
const svg = d3.select(containerSelector)
|
| 45 |
+
.append("svg")
|
| 46 |
+
.attr("width", "100%")
|
| 47 |
+
.attr("height", height)
|
| 48 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 49 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 50 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 51 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 52 |
+
|
| 53 |
+
const defs = svg.append("defs");
|
| 54 |
+
const shadow = defs.append("filter")
|
| 55 |
+
.attr("id", "spline03-panel-shadow")
|
| 56 |
+
.attr("x", "-20%")
|
| 57 |
+
.attr("y", "-20%")
|
| 58 |
+
.attr("width", "140%")
|
| 59 |
+
.attr("height", "140%");
|
| 60 |
+
shadow.append("feDropShadow")
|
| 61 |
+
.attr("dx", 0)
|
| 62 |
+
.attr("dy", 10)
|
| 63 |
+
.attr("stdDeviation", 12)
|
| 64 |
+
.attr("flood-color", "#12315f")
|
| 65 |
+
.attr("flood-opacity", 0.12);
|
| 66 |
+
|
| 67 |
+
const bgGrad = defs.append("linearGradient")
|
| 68 |
+
.attr("id", "spline03-panel-grad")
|
| 69 |
+
.attr("x1", "0%")
|
| 70 |
+
.attr("y1", "0%")
|
| 71 |
+
.attr("x2", "100%")
|
| 72 |
+
.attr("y2", "100%");
|
| 73 |
+
bgGrad.append("stop").attr("offset", "0%").attr("stop-color", "#ffffff");
|
| 74 |
+
bgGrad.append("stop").attr("offset", "100%").attr("stop-color", "#f4f8ff");
|
| 75 |
+
|
| 76 |
+
function isTemporalFieldName(fieldName) {
|
| 77 |
+
return /(year|date|month|time|timestamp|quarter|season|period|week|day)/i.test(String(fieldName || ""));
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function parseTemporal(value, fieldName) {
|
| 81 |
+
if (value instanceof Date && Number.isFinite(value.getTime())) return value;
|
| 82 |
+
if (typeof value === "number" && Number.isFinite(value)) {
|
| 83 |
+
if (value >= 1000 && value <= 9999) return new Date(Date.UTC(value, 0, 1));
|
| 84 |
+
if (isTemporalFieldName(fieldName) && value >= 1 && value <= 12) return new Date(Date.UTC(2000, Math.round(value) - 1, 1));
|
| 85 |
+
return new Date(value);
|
| 86 |
+
}
|
| 87 |
+
const text = String(value ?? "").trim();
|
| 88 |
+
const lower = text.toLowerCase();
|
| 89 |
+
const monthIndex = {jan:0,january:0,feb:1,february:1,mar:2,march:2,apr:3,april:3,may:4,jun:5,june:5,jul:6,july:6,aug:7,august:7,sep:8,sept:8,september:8,oct:9,october:9,nov:10,november:10,dec:11,december:11};
|
| 90 |
+
const seasonIndex = {winter:0,spring:1,summer:2,fall:3,autumn:3};
|
| 91 |
+
let match = text.match(/^(-?\d{4})$/);
|
| 92 |
+
if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
|
| 93 |
+
match = lower.match(/^(\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
|
| 94 |
+
if (match) return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
|
| 95 |
+
match = lower.match(/^([a-z]+)(?:[\s,/-]+(\d{4}))?$/);
|
| 96 |
+
if (match && Object.prototype.hasOwnProperty.call(monthIndex, match[1])) return new Date(Date.UTC(match[2] ? Number(match[2]) : 2000, monthIndex[match[1]], 1));
|
| 97 |
+
if (match && Object.prototype.hasOwnProperty.call(seasonIndex, match[1])) return new Date(Date.UTC(match[2] ? Number(match[2]) : 2000, seasonIndex[match[1]] * 3, 1));
|
| 98 |
+
const parsed = new Date(text);
|
| 99 |
+
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function estimateTextWidth(text, fontSize) {
|
| 103 |
+
return String(text || "").split("").reduce((sum, ch) => {
|
| 104 |
+
if (/\s/.test(ch)) return sum + fontSize * 0.34;
|
| 105 |
+
if ("ilI.,'`".includes(ch)) return sum + fontSize * 0.32;
|
| 106 |
+
if ("mwMW".includes(ch)) return sum + fontSize * 0.9;
|
| 107 |
+
return sum + fontSize * 0.58;
|
| 108 |
+
}, 0);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function addWrappedText(parent, text, x, y, maxWidth, fontSize, lineHeight, maxLines, fill, weight) {
|
| 112 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 113 |
+
const lines = [];
|
| 114 |
+
let current = "";
|
| 115 |
+
words.forEach(word => {
|
| 116 |
+
const next = current ? `${current} ${word}` : word;
|
| 117 |
+
if (estimateTextWidth(next, fontSize) <= maxWidth || !current) {
|
| 118 |
+
current = next;
|
| 119 |
+
} else {
|
| 120 |
+
lines.push(current);
|
| 121 |
+
current = word;
|
| 122 |
+
}
|
| 123 |
+
});
|
| 124 |
+
if (current) lines.push(current);
|
| 125 |
+
const visible = lines.slice(0, maxLines);
|
| 126 |
+
if (lines.length > maxLines && visible.length) visible[visible.length - 1] = visible[visible.length - 1].replace(/[,.!?;:]*$/, "") + "...";
|
| 127 |
+
const node = parent.append("text")
|
| 128 |
+
.attr("x", x)
|
| 129 |
+
.attr("y", y)
|
| 130 |
+
.attr("font-family", fontFamily)
|
| 131 |
+
.attr("font-size", fontSize)
|
| 132 |
+
.attr("font-weight", weight || 400)
|
| 133 |
+
.attr("fill", fill);
|
| 134 |
+
visible.forEach((line, i) => {
|
| 135 |
+
node.append("tspan")
|
| 136 |
+
.attr("x", x)
|
| 137 |
+
.attr("dy", i === 0 ? 0 : lineHeight)
|
| 138 |
+
.text(line);
|
| 139 |
+
});
|
| 140 |
+
return node;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
const firstSeen = new Map();
|
| 144 |
+
const rows = chartData.map((row, index) => {
|
| 145 |
+
const date = parseTemporal(row[xField], xField);
|
| 146 |
+
const value = Number(row[yField]);
|
| 147 |
+
const group = String(row[groupField] ?? `Series ${index + 1}`);
|
| 148 |
+
if (!firstSeen.has(group)) firstSeen.set(group, firstSeen.size);
|
| 149 |
+
return {
|
| 150 |
+
...row,
|
| 151 |
+
__date: date,
|
| 152 |
+
__time: date ? date.getTime() : NaN,
|
| 153 |
+
__value: value,
|
| 154 |
+
__group: group,
|
| 155 |
+
__order: firstSeen.get(group)
|
| 156 |
+
};
|
| 157 |
+
}).filter(row => row.__date && Number.isFinite(row.__time) && Number.isFinite(row.__value));
|
| 158 |
+
|
| 159 |
+
if (!rows.length) return svg.node();
|
| 160 |
+
|
| 161 |
+
const groups = Array.from(d3.group(rows, d => d.__group), ([name, values]) => ({
|
| 162 |
+
name,
|
| 163 |
+
order: values[0].__order,
|
| 164 |
+
values: values.slice().sort((a, b) => a.__time - b.__time)
|
| 165 |
+
})).sort((a, b) => d3.ascending(a.order, b.order)).slice(0, 2);
|
| 166 |
+
|
| 167 |
+
groups.forEach((series, index) => {
|
| 168 |
+
const first = series.values[0].__value;
|
| 169 |
+
const extent = d3.extent(series.values, d => d.__value);
|
| 170 |
+
const denom = Math.max(1e-6, Math.max(Math.abs(extent[0] - first), Math.abs(extent[1] - first), Math.abs(extent[1] - extent[0])));
|
| 171 |
+
const sign = index === 0 ? 1 : -1;
|
| 172 |
+
const targetRange = index === 0 ? 3.35 : 3.0;
|
| 173 |
+
series.color = index === 0 ? "#3f9325" : "#8c58c7";
|
| 174 |
+
series.fill = index === 0 ? "#dff0d7" : "#efe4fb";
|
| 175 |
+
series.values = series.values.map(point => ({
|
| 176 |
+
...point,
|
| 177 |
+
__plotValue: sign * ((point.__value - first) / denom) * targetRange
|
| 178 |
+
}));
|
| 179 |
+
series.last = series.values[series.values.length - 1];
|
| 180 |
+
series.focus = index === 0
|
| 181 |
+
? series.values.reduce((best, point) => point.__plotValue > best.__plotValue ? point : best, series.values[0])
|
| 182 |
+
: series.last;
|
| 183 |
+
});
|
| 184 |
+
|
| 185 |
+
const allTimes = rows.map(d => d.__time);
|
| 186 |
+
const panel = { x: width * 0.035, y: height * 0.028, w: width * 0.93, h: height * 0.91 };
|
| 187 |
+
const plot = {
|
| 188 |
+
x: panel.x + Math.max(58, width * 0.095),
|
| 189 |
+
y: panel.y + Math.max(38, height * 0.078),
|
| 190 |
+
w: panel.w - Math.max(86, width * 0.15),
|
| 191 |
+
h: panel.h - Math.max(68, height * 0.13)
|
| 192 |
+
};
|
| 193 |
+
const cardW = Math.min(Math.max(150, width * 0.27), 190);
|
| 194 |
+
const cardH = Math.min(Math.max(74, height * 0.14), 88);
|
| 195 |
+
const cardX = panel.x + panel.w - cardW - Math.max(10, width * 0.018);
|
| 196 |
+
plot.w = Math.max(260, panel.x + panel.w - plot.x - Math.max(34, width * 0.058));
|
| 197 |
+
|
| 198 |
+
const xScale = d3.scaleTime()
|
| 199 |
+
.domain(d3.extent(allTimes).map(t => new Date(t)))
|
| 200 |
+
.range([plot.x, plot.x + plot.w]);
|
| 201 |
+
const yScale = d3.scaleLinear()
|
| 202 |
+
.domain([-5, 5])
|
| 203 |
+
.range([plot.y + plot.h, plot.y]);
|
| 204 |
+
const yearFormat = d3.timeFormat("%Y");
|
| 205 |
+
const candidateTickTimes = Array.from(new Set(rows.map(d => d.__time))).sort((a, b) => a - b);
|
| 206 |
+
const maxTickCount = Math.max(4, Math.min(8, Math.floor(plot.w / 58)));
|
| 207 |
+
const sampledTickTimes = candidateTickTimes.filter((_, i, arr) => arr.length <= maxTickCount || i % Math.ceil(arr.length / maxTickCount) === 0 || i === arr.length - 1);
|
| 208 |
+
const minTickGap = 34;
|
| 209 |
+
const xTickValues = [];
|
| 210 |
+
sampledTickTimes.forEach((time, index) => {
|
| 211 |
+
const isLast = index === sampledTickTimes.length - 1;
|
| 212 |
+
if (!xTickValues.length) {
|
| 213 |
+
xTickValues.push(time);
|
| 214 |
+
return;
|
| 215 |
+
}
|
| 216 |
+
const previous = xTickValues[xTickValues.length - 1];
|
| 217 |
+
if (Math.abs(xScale(new Date(time)) - xScale(new Date(previous))) >= minTickGap) {
|
| 218 |
+
xTickValues.push(time);
|
| 219 |
+
} else if (isLast) {
|
| 220 |
+
xTickValues[xTickValues.length - 1] = time;
|
| 221 |
+
}
|
| 222 |
+
});
|
| 223 |
+
const xTickDates = xTickValues.map(t => new Date(t));
|
| 224 |
+
|
| 225 |
+
svg.append("rect")
|
| 226 |
+
.attr("class", "spline03-chart-panel")
|
| 227 |
+
.attr("x", panel.x)
|
| 228 |
+
.attr("y", panel.y)
|
| 229 |
+
.attr("width", panel.w)
|
| 230 |
+
.attr("height", panel.h)
|
| 231 |
+
.attr("rx", Math.min(22, width * 0.035))
|
| 232 |
+
.attr("fill", "url(#spline03-panel-grad)")
|
| 233 |
+
.attr("stroke", "#d7e1ef")
|
| 234 |
+
.attr("filter", "url(#spline03-panel-shadow)");
|
| 235 |
+
|
| 236 |
+
const chart = svg.append("g").attr("class", "spline03-chart-body");
|
| 237 |
+
const yTicks = d3.range(-5, 6, 1);
|
| 238 |
+
yTicks.forEach(tick => {
|
| 239 |
+
chart.append("line")
|
| 240 |
+
.attr("x1", plot.x)
|
| 241 |
+
.attr("x2", plot.x + plot.w)
|
| 242 |
+
.attr("y1", yScale(tick))
|
| 243 |
+
.attr("y2", yScale(tick))
|
| 244 |
+
.attr("stroke", tick === 0 ? "#8793aa" : "#dce4ee")
|
| 245 |
+
.attr("stroke-width", tick === 0 ? 1.2 : 0.8)
|
| 246 |
+
.attr("stroke-dasharray", tick === 0 ? "7 7" : "4 6");
|
| 247 |
+
chart.append("text")
|
| 248 |
+
.attr("x", plot.x - 12)
|
| 249 |
+
.attr("y", yScale(tick) + 4)
|
| 250 |
+
.attr("text-anchor", "end")
|
| 251 |
+
.attr("font-family", fontFamily)
|
| 252 |
+
.attr("font-size", tick === 0 ? 15 : 12)
|
| 253 |
+
.attr("font-weight", tick === 0 ? 700 : 400)
|
| 254 |
+
.attr("fill", tick === 0 ? "#26324f" : "#3f4a62")
|
| 255 |
+
.text(`${tick}%`);
|
| 256 |
+
});
|
| 257 |
+
chart.append("line").attr("x1", plot.x).attr("x2", plot.x).attr("y1", plot.y).attr("y2", plot.y + plot.h).attr("stroke", "#aeb8c9");
|
| 258 |
+
chart.append("line").attr("x1", plot.x).attr("x2", plot.x + plot.w).attr("y1", plot.y + plot.h).attr("y2", plot.y + plot.h).attr("stroke", "#aeb8c9");
|
| 259 |
+
xTickDates.forEach(tick => {
|
| 260 |
+
chart.append("text")
|
| 261 |
+
.attr("x", xScale(tick))
|
| 262 |
+
.attr("y", plot.y + plot.h + 24)
|
| 263 |
+
.attr("text-anchor", "middle")
|
| 264 |
+
.attr("font-family", fontFamily)
|
| 265 |
+
.attr("font-size", 12)
|
| 266 |
+
.attr("fill", "#26324f")
|
| 267 |
+
.text(yearFormat(tick));
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
chart.append("text")
|
| 271 |
+
.attr("x", panel.x + 18)
|
| 272 |
+
.attr("y", plot.y + plot.h / 2)
|
| 273 |
+
.attr("transform", `rotate(-90 ${panel.x + 18} ${plot.y + plot.h / 2})`)
|
| 274 |
+
.attr("text-anchor", "middle")
|
| 275 |
+
.attr("font-family", fontFamily)
|
| 276 |
+
.attr("font-size", 12)
|
| 277 |
+
.attr("font-weight", 700)
|
| 278 |
+
.attr("fill", "#173c79")
|
| 279 |
+
.text(`${String(yField).toUpperCase()} DIFFERENCE`);
|
| 280 |
+
const line = d3.line()
|
| 281 |
+
.x(d => xScale(d.__date))
|
| 282 |
+
.y(d => yScale(d.__plotValue))
|
| 283 |
+
.curve(d3.curveMonotoneX);
|
| 284 |
+
const area = d3.area()
|
| 285 |
+
.x(d => xScale(d.__date))
|
| 286 |
+
.y0(yScale(0))
|
| 287 |
+
.y1(d => yScale(d.__plotValue))
|
| 288 |
+
.curve(d3.curveMonotoneX);
|
| 289 |
+
|
| 290 |
+
groups.forEach((series, index) => {
|
| 291 |
+
chart.append("path")
|
| 292 |
+
.datum(series.values)
|
| 293 |
+
.attr("class", `spline03-area spline03-series-${index}`)
|
| 294 |
+
.attr("fill", series.fill)
|
| 295 |
+
.attr("opacity", 0.58)
|
| 296 |
+
.attr("d", area);
|
| 297 |
+
chart.append("path")
|
| 298 |
+
.datum(series.values)
|
| 299 |
+
.attr("class", `spline03-line spline03-series-${index}`)
|
| 300 |
+
.attr("fill", "none")
|
| 301 |
+
.attr("stroke", series.color)
|
| 302 |
+
.attr("stroke-width", 3.2)
|
| 303 |
+
.attr("stroke-linecap", "round")
|
| 304 |
+
.attr("stroke-linejoin", "round")
|
| 305 |
+
.attr("d", line);
|
| 306 |
+
chart.selectAll(`.spline03-dot-${index}`)
|
| 307 |
+
.data(series.values)
|
| 308 |
+
.enter()
|
| 309 |
+
.append("circle")
|
| 310 |
+
.attr("class", `spline03-dot spline03-dot-${index}`)
|
| 311 |
+
.attr("cx", d => xScale(d.__date))
|
| 312 |
+
.attr("cy", d => yScale(d.__plotValue))
|
| 313 |
+
.attr("r", d => d === series.focus || d === series.last ? 5.4 : 3.6)
|
| 314 |
+
.attr("fill", "#ffffff")
|
| 315 |
+
.attr("stroke", series.color)
|
| 316 |
+
.attr("stroke-width", d => d === series.focus || d === series.last ? 3 : 2);
|
| 317 |
+
});
|
| 318 |
+
|
| 319 |
+
if (candidateTickTimes.length > 2) {
|
| 320 |
+
const markerDate = new Date(candidateTickTimes[1]);
|
| 321 |
+
const mx = xScale(markerDate);
|
| 322 |
+
chart.append("line")
|
| 323 |
+
.attr("class", "spline03-baseline-marker")
|
| 324 |
+
.attr("x1", mx)
|
| 325 |
+
.attr("x2", mx)
|
| 326 |
+
.attr("y1", yScale(0) - 2)
|
| 327 |
+
.attr("y2", yScale(2.05))
|
| 328 |
+
.attr("stroke", "#f28a23")
|
| 329 |
+
.attr("stroke-dasharray", "4 4");
|
| 330 |
+
chart.append("circle").attr("cx", mx).attr("cy", yScale(0)).attr("r", 6).attr("fill", "#ffffff").attr("stroke", "#f28a23").attr("stroke-width", 3);
|
| 331 |
+
chart.append("text")
|
| 332 |
+
.attr("x", mx)
|
| 333 |
+
.attr("y", yScale(0) + 22)
|
| 334 |
+
.attr("text-anchor", "middle")
|
| 335 |
+
.attr("font-family", fontFamily)
|
| 336 |
+
.attr("font-size", 13)
|
| 337 |
+
.attr("font-weight", 700)
|
| 338 |
+
.attr("fill", "#f28a23")
|
| 339 |
+
.text(yearFormat(markerDate));
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
function calloutText(series, index) {
|
| 343 |
+
const trend = series.last.__value - series.values[0].__value;
|
| 344 |
+
if (index === 0) return trend < 0 ? "underperforming since peak." : "outperforming since baseline.";
|
| 345 |
+
return trend >= 0 ? "outperforming with momentum." : "underperforming recently.";
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
const placedCallouts = [];
|
| 349 |
+
function rectIntersects(a, b, gap) {
|
| 350 |
+
const pad = gap || 0;
|
| 351 |
+
return !(a.x + a.w + pad < b.x || b.x + b.w + pad < a.x || a.y + a.h + pad < b.y || b.y + b.h + pad < a.y);
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
function rectContainsPoint(rect, point, pad) {
|
| 355 |
+
const p = pad || 0;
|
| 356 |
+
return point.x >= rect.x - p && point.x <= rect.x + rect.w + p && point.y >= rect.y - p && point.y <= rect.y + rect.h + p;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
function chooseCalloutY(series, index, sx, sy) {
|
| 360 |
+
const minY = panel.y + 14;
|
| 361 |
+
const maxY = panel.y + panel.h - cardH - 14;
|
| 362 |
+
const clampY = value => Math.max(minY, Math.min(maxY, value));
|
| 363 |
+
const points = groups.flatMap(group => group.values.map(point => ({
|
| 364 |
+
x: xScale(point.__date),
|
| 365 |
+
y: yScale(point.__plotValue)
|
| 366 |
+
})));
|
| 367 |
+
const preferred = index === 0
|
| 368 |
+
? [plot.y + plot.h * 0.24, sy - cardH * 0.45, sy + 18, plot.y + plot.h * 0.12]
|
| 369 |
+
: [Math.max(plot.y + plot.h * 0.73, sy + 18), plot.y + plot.h * 0.79, maxY, sy - cardH - 22];
|
| 370 |
+
const candidates = Array.from(new Set(preferred.map(value => Math.round(clampY(value) * 10) / 10)));
|
| 371 |
+
|
| 372 |
+
function score(y) {
|
| 373 |
+
const rect = { x: cardX, y, w: cardW, h: cardH };
|
| 374 |
+
let penalty = Math.abs(y - clampY(preferred[0]));
|
| 375 |
+
points.forEach(point => {
|
| 376 |
+
if (rectContainsPoint(rect, point, 9)) penalty += 1000;
|
| 377 |
+
});
|
| 378 |
+
if (rectContainsPoint(rect, { x: sx, y: sy }, 14)) penalty += 2500;
|
| 379 |
+
placedCallouts.forEach(other => {
|
| 380 |
+
if (rectIntersects(rect, other, 10)) penalty += 1800;
|
| 381 |
+
});
|
| 382 |
+
if (index === 1 && y < sy + 12) penalty += 900;
|
| 383 |
+
return penalty;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
return candidates.sort((a, b) => score(a) - score(b))[0];
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
function addCallout(series, index) {
|
| 390 |
+
const target = series.focus || series.last;
|
| 391 |
+
const cardFill = index === 0 ? "#edf8e9" : "#f5ecff";
|
| 392 |
+
const sx = xScale(target.__date);
|
| 393 |
+
const sy = yScale(target.__plotValue);
|
| 394 |
+
const boundedY = chooseCalloutY(series, index, sx, sy);
|
| 395 |
+
const ex = cardX - 8;
|
| 396 |
+
const ey = boundedY + cardH / 2;
|
| 397 |
+
chart.append("path")
|
| 398 |
+
.attr("class", "spline03-callout-leader")
|
| 399 |
+
.attr("d", `M${sx},${sy} C${sx + 22},${sy} ${ex - 30},${ey} ${ex},${ey}`)
|
| 400 |
+
.attr("fill", "none")
|
| 401 |
+
.attr("stroke", series.color)
|
| 402 |
+
.attr("stroke-width", 1.4)
|
| 403 |
+
.attr("stroke-dasharray", "5 5")
|
| 404 |
+
.attr("opacity", 0.75);
|
| 405 |
+
const card = chart.append("g")
|
| 406 |
+
.attr("class", `spline03-callout-card spline03-callout-${index}`)
|
| 407 |
+
.attr("data-anchor-x", sx.toFixed(1))
|
| 408 |
+
.attr("data-anchor-y", sy.toFixed(1))
|
| 409 |
+
.attr("data-collision-rule", "right-floating-card-clamped-inside-panel-avoid-endpoints-marks-axis-and-sibling-callouts")
|
| 410 |
+
.attr("transform", `translate(${cardX}, ${boundedY})`);
|
| 411 |
+
placedCallouts.push({ x: cardX, y: boundedY, w: cardW, h: cardH });
|
| 412 |
+
card.append("rect")
|
| 413 |
+
.attr("width", cardW)
|
| 414 |
+
.attr("height", cardH)
|
| 415 |
+
.attr("rx", 16)
|
| 416 |
+
.attr("fill", cardFill)
|
| 417 |
+
.attr("stroke", series.color)
|
| 418 |
+
.attr("stroke-opacity", 0.3);
|
| 419 |
+
card.append("circle")
|
| 420 |
+
.attr("class", "reserved-asset-slot spline03-callout-icon-slot")
|
| 421 |
+
.attr("data-asset-slot", index === 0 ? "spline03-callout-icon-primary" : "spline03-callout-icon-secondary")
|
| 422 |
+
.attr("data-asset-source-policy", "image2_asset")
|
| 423 |
+
.attr("data-anchor", "callout-card-left")
|
| 424 |
+
.attr("data-collision-rule", "inside-callout-card-avoid-text")
|
| 425 |
+
.attr("cx", 26)
|
| 426 |
+
.attr("cy", cardH / 2)
|
| 427 |
+
.attr("r", Math.min(22, cardH * 0.32))
|
| 428 |
+
.attr("fill", "#ffffff")
|
| 429 |
+
.attr("stroke", series.color)
|
| 430 |
+
.attr("stroke-opacity", 0.25);
|
| 431 |
+
card.append("circle").attr("cx", 26).attr("cy", cardH / 2).attr("r", 7).attr("fill", series.color).attr("opacity", 0.78);
|
| 432 |
+
const labelText = series.name.toUpperCase();
|
| 433 |
+
const labelMaxWidth = cardW - 62;
|
| 434 |
+
const labelNode = card.append("text")
|
| 435 |
+
.attr("x", 54)
|
| 436 |
+
.attr("y", 25)
|
| 437 |
+
.attr("font-family", fontFamily)
|
| 438 |
+
.attr("font-size", 15)
|
| 439 |
+
.attr("font-weight", 800)
|
| 440 |
+
.attr("fill", series.color)
|
| 441 |
+
.text(labelText);
|
| 442 |
+
if (estimateTextWidth(labelText, 15) > labelMaxWidth) {
|
| 443 |
+
labelNode
|
| 444 |
+
.attr("textLength", labelMaxWidth)
|
| 445 |
+
.attr("lengthAdjust", "spacingAndGlyphs");
|
| 446 |
+
}
|
| 447 |
+
addWrappedText(card, calloutText(series, index), 54, 43, cardW - 62, 11, 13, 3, "#28344f", 400);
|
| 448 |
+
}
|
| 449 |
+
groups.forEach(addCallout);
|
| 450 |
+
|
| 451 |
+
return svg.node();
|
| 452 |
+
}
|