Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
modules/chart_engine/template/d3-js/type20_vertical_dot_bar_chart/vertical_dot_bar_plain_chart_01.js
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Vertical Dot Bar Chart",
|
| 5 |
+
"chart_name": "vertical_dot_bar_plain_chart_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical", "temporal"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, "inf"], [0, "inf"]],
|
| 10 |
+
"required_fields_icons": [],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": [],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": ["gradient"],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "none",
|
| 20 |
+
"has_x_axis": "no",
|
| 21 |
+
"has_y_axis": "no"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data;
|
| 28 |
+
const chartData = jsonData.data.data;
|
| 29 |
+
const variables = jsonData.variables || {};
|
| 30 |
+
const typography = jsonData.typography || {
|
| 31 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 32 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 33 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 34 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 35 |
+
};
|
| 36 |
+
const sourceColors = jsonData.colors || {
|
| 37 |
+
text_color: "#13233A",
|
| 38 |
+
other: { primary: "#0B8F86", secondary: "#61C7BD" }
|
| 39 |
+
};
|
| 40 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 41 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 42 |
+
const chartUtilsFormatSample = chartUtils.format.autoText;
|
| 43 |
+
const chartUtilsStandard = {
|
| 44 |
+
schema: chartUtils.schema,
|
| 45 |
+
format: chartUtils.format,
|
| 46 |
+
text: chartUtils.text,
|
| 47 |
+
color: chartUtils.color,
|
| 48 |
+
legendLayout: chartUtils.legend.layout,
|
| 49 |
+
legendDraw: chartUtils.legend.draw,
|
| 50 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 51 |
+
};
|
| 52 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 53 |
+
x: { fallbackIndex: 0 },
|
| 54 |
+
y: { fallbackIndex: 1 },
|
| 55 |
+
y2: { fallbackIndex: 2 },
|
| 56 |
+
y3: { fallbackIndex: 3 },
|
| 57 |
+
size: { fallbackIndex: 2 },
|
| 58 |
+
group: { fallbackIndex: 2 },
|
| 59 |
+
group2: { fallbackIndex: 3 },
|
| 60 |
+
group3: { fallbackIndex: 4 }
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
const fontFamily = typography.label.font_family || "Arial";
|
| 64 |
+
const annotationFamily = typography.annotation.font_family || fontFamily;
|
| 65 |
+
const primaryInput = sourceColors.other?.primary || "#09B8AE";
|
| 66 |
+
const primaryColor = d3.interpolateRgb(primaryInput, "#078B84")(0.72);
|
| 67 |
+
const primaryDark = d3.interpolateRgb(primaryColor, "#004F55")(0.55);
|
| 68 |
+
const primaryLight = d3.interpolateRgb(primaryColor, "#9FDCD6")(0.68);
|
| 69 |
+
const referencePalette = [
|
| 70 |
+
"#0A8C96", "#4D8E38", "#176FBE", "#6F55B8", "#168E93", "#E58200",
|
| 71 |
+
"#1768A9", "#D89A00", "#E45D2A", "#D83F62", "#15858A", "#8EA42A"
|
| 72 |
+
];
|
| 73 |
+
const textColor = "#061B38";
|
| 74 |
+
const mutedText = "#45617F";
|
| 75 |
+
const panelStroke = "#D7E7EF";
|
| 76 |
+
const lineColor = "#B9CCDE";
|
| 77 |
+
const warmAccent = sourceColors.other?.secondary || "#E7BF63";
|
| 78 |
+
|
| 79 |
+
const measureLabelText = (text, fontSize, fontWeight = "500", family = fontFamily) => {
|
| 80 |
+
const canvas = document.createElement("canvas");
|
| 81 |
+
const ctx = canvas.getContext("2d");
|
| 82 |
+
ctx.font = `${fontWeight} ${fontSize}px ${family}`;
|
| 83 |
+
return chartUtils.text.contextWidth(ctx, String(text));
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
const fittedFontSize = (text, maxWidth, baseFontSize, minFontSize = 5.6, fontWeight = "500", family = fontFamily) => {
|
| 87 |
+
let size = baseFontSize;
|
| 88 |
+
while (size > minFontSize && measureLabelText(text, size, fontWeight, family) > maxWidth) {
|
| 89 |
+
size -= 0.4;
|
| 90 |
+
}
|
| 91 |
+
return Math.max(minFontSize, size);
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
const applyTextFit = (selection, text, maxWidth, fontSize, fontWeight = "500", family = fontFamily) => {
|
| 95 |
+
selection.text(text);
|
| 96 |
+
if (measureLabelText(text, fontSize, fontWeight, family) > maxWidth) {
|
| 97 |
+
selection
|
| 98 |
+
.attr("textLength", maxWidth)
|
| 99 |
+
.attr("lengthAdjust", "spacingAndGlyphs");
|
| 100 |
+
}
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
const formatLocalValue = (value) => {
|
| 104 |
+
if (Math.abs(value) >= 1000000000) return d3.format("~g")(value / 1000000000) + "B";
|
| 105 |
+
if (Math.abs(value) >= 1000000) return d3.format("~g")(value / 1000000) + "M";
|
| 106 |
+
if (Math.abs(value) >= 1000) return d3.format("~g")(value / 1000) + "K";
|
| 107 |
+
return d3.format("~g")(value);
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
const formatValueWithUnit = (value, unit) => {
|
| 111 |
+
const formatted = formatLocalValue(value);
|
| 112 |
+
const trimmedUnit = String(unit || "").trim();
|
| 113 |
+
if (!trimmedUnit) return formatted;
|
| 114 |
+
if (trimmedUnit === "$") return `$${formatted}`;
|
| 115 |
+
if (trimmedUnit === "%") return `${formatted}%`;
|
| 116 |
+
return `${formatted} ${trimmedUnit}`;
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
const splitFormattedValue = (value, unit) => {
|
| 120 |
+
const trimmedUnit = String(unit || "").trim();
|
| 121 |
+
const formatted = formatLocalValue(value);
|
| 122 |
+
if (!trimmedUnit) return { main: formatted, unit: "" };
|
| 123 |
+
if (trimmedUnit === "$") return { main: `$${formatted}`, unit: "" };
|
| 124 |
+
if (trimmedUnit === "%") return { main: `${formatted}%`, unit: "" };
|
| 125 |
+
return { main: formatted, unit: trimmedUnit };
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const labelTokens = (text) => String(text)
|
| 129 |
+
.replace(/[()[\]{}]/g, " ")
|
| 130 |
+
.replace(/[,:;/\\|_-]+/g, " ")
|
| 131 |
+
.split(/\s+/)
|
| 132 |
+
.map(token => token.trim())
|
| 133 |
+
.filter(Boolean);
|
| 134 |
+
|
| 135 |
+
const splitCategoryLines = (text, maxWidth, fontSize) => {
|
| 136 |
+
const str = String(text).trim();
|
| 137 |
+
const parenthetical = str.match(/^(.+?)\s*\(([^()]+)\)\s*$/);
|
| 138 |
+
if (parenthetical) {
|
| 139 |
+
const first = parenthetical[1].trim();
|
| 140 |
+
const second = `(${parenthetical[2].trim()})`;
|
| 141 |
+
return [first, second];
|
| 142 |
+
}
|
| 143 |
+
const tokens = labelTokens(str);
|
| 144 |
+
if (tokens.length <= 1) return [str];
|
| 145 |
+
|
| 146 |
+
const lines = [];
|
| 147 |
+
let current = "";
|
| 148 |
+
tokens.forEach(token => {
|
| 149 |
+
const next = current ? `${current} ${token}` : token;
|
| 150 |
+
if (lines.length < 1 && measureLabelText(next, fontSize, "600") <= maxWidth) {
|
| 151 |
+
current = next;
|
| 152 |
+
} else if (lines.length < 1) {
|
| 153 |
+
lines.push(current || token);
|
| 154 |
+
current = current ? token : "";
|
| 155 |
+
} else {
|
| 156 |
+
current = current ? `${current} ${token}` : token;
|
| 157 |
+
}
|
| 158 |
+
});
|
| 159 |
+
if (current) lines.push(current);
|
| 160 |
+
return lines.slice(0, 2);
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
const compactCategory = (text, isTemporalLabel) => {
|
| 164 |
+
const str = String(text).trim();
|
| 165 |
+
if (isTemporalLabel) return str;
|
| 166 |
+
const tokens = labelTokens(str);
|
| 167 |
+
if (tokens.length > 5) {
|
| 168 |
+
return `${tokens[0]} ${tokens[tokens.length - 1]}`;
|
| 169 |
+
}
|
| 170 |
+
return str;
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
const buildDisplayLabels = (values, isTemporalLabel) => {
|
| 174 |
+
const labels = values.map(value => compactCategory(value, isTemporalLabel));
|
| 175 |
+
const seen = new Map();
|
| 176 |
+
labels.forEach(label => {
|
| 177 |
+
const key = label.toLowerCase();
|
| 178 |
+
seen.set(key, (seen.get(key) || 0) + 1);
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
return labels.map((label, index) => {
|
| 182 |
+
if ((seen.get(label.toLowerCase()) || 0) === 1) return label;
|
| 183 |
+
const sourceTokens = labelTokens(values[index]);
|
| 184 |
+
if (sourceTokens.length <= 2) return String(values[index]);
|
| 185 |
+
for (let tokenCount = 2; tokenCount <= Math.min(4, sourceTokens.length); tokenCount++) {
|
| 186 |
+
const candidate = sourceTokens.slice(sourceTokens.length - tokenCount).join(" ");
|
| 187 |
+
const duplicate = values.some((other, otherIndex) => {
|
| 188 |
+
if (otherIndex === index) return false;
|
| 189 |
+
const otherTokens = labelTokens(other);
|
| 190 |
+
return otherTokens.slice(Math.max(0, otherTokens.length - tokenCount)).join(" ").toLowerCase()
|
| 191 |
+
=== candidate.toLowerCase();
|
| 192 |
+
});
|
| 193 |
+
if (!duplicate) return candidate;
|
| 194 |
+
}
|
| 195 |
+
return String(values[index]);
|
| 196 |
+
});
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
const temporalKey = (value) => {
|
| 200 |
+
if (value instanceof Date) return value.getTime();
|
| 201 |
+
if (typeof value === "number") return value;
|
| 202 |
+
const str = String(value).trim();
|
| 203 |
+
if (/^-?\d+(\.\d+)?$/.test(str)) return +str;
|
| 204 |
+
const seasonOrder = { winter: 0, spring: 1, summer: 2, fall: 3, autumn: 3 };
|
| 205 |
+
const seasonYear = str.match(/^(winter|spring|summer|fall|autumn)\s+(-?\d{1,4})$/i);
|
| 206 |
+
if (seasonYear) return (+seasonYear[2]) * 10 + seasonOrder[seasonYear[1].toLowerCase()];
|
| 207 |
+
const yearSeason = str.match(/^(-?\d{1,4})\s+(winter|spring|summer|fall|autumn)$/i);
|
| 208 |
+
if (yearSeason) return (+yearSeason[1]) * 10 + seasonOrder[yearSeason[2].toLowerCase()];
|
| 209 |
+
const parsed = Date.parse(str);
|
| 210 |
+
if (!Number.isNaN(parsed)) return parsed;
|
| 211 |
+
const yearMatch = str.match(/^-?\d{1,4}/);
|
| 212 |
+
if (yearMatch) return +yearMatch[0];
|
| 213 |
+
return null;
|
| 214 |
+
};
|
| 215 |
+
|
| 216 |
+
const wrapTextLines = (text, maxWidth, fontSize, maxLines, fontWeight = "500", family = fontFamily) => {
|
| 217 |
+
const words = String(text || "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
|
| 218 |
+
const lines = [];
|
| 219 |
+
let current = "";
|
| 220 |
+
words.forEach(word => {
|
| 221 |
+
if (lines.length >= maxLines) return;
|
| 222 |
+
const next = current ? `${current} ${word}` : word;
|
| 223 |
+
if (measureLabelText(next, fontSize, fontWeight, family) <= maxWidth || !current) {
|
| 224 |
+
current = next;
|
| 225 |
+
} else {
|
| 226 |
+
lines.push(current);
|
| 227 |
+
current = word;
|
| 228 |
+
}
|
| 229 |
+
});
|
| 230 |
+
if (current && lines.length < maxLines) lines.push(current);
|
| 231 |
+
return lines;
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
d3.select(containerSelector).html("");
|
| 235 |
+
|
| 236 |
+
const width = Math.max(1360, variables.width || 1360);
|
| 237 |
+
const chartCardX = 18;
|
| 238 |
+
const chartCardY = 18;
|
| 239 |
+
const chartCardWidth = width - chartCardX * 2;
|
| 240 |
+
const chartCardHeight = 548;
|
| 241 |
+
const height = chartCardY + chartCardHeight + 28;
|
| 242 |
+
|
| 243 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw?.name || "dimension";
|
| 244 |
+
const valueField = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw?.name || "value";
|
| 245 |
+
const valueColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
|
| 246 |
+
const dimensionColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
|
| 247 |
+
const valueUnit = valueColumn.unit && valueColumn.unit !== "none" ? valueColumn.unit : "";
|
| 248 |
+
|
| 249 |
+
const normalizedData = chartData.map((d, index) => ({
|
| 250 |
+
...d,
|
| 251 |
+
__index: index,
|
| 252 |
+
__dimension: d[dimensionField],
|
| 253 |
+
__rawValue: Math.max(0, +d[valueField] || 0),
|
| 254 |
+
__temporalKey: temporalKey(d[dimensionField])
|
| 255 |
+
}));
|
| 256 |
+
const validTemporalKeys = normalizedData.filter(d => d.__temporalKey !== null).length;
|
| 257 |
+
const isTemporal = dimensionColumn.data_type === "temporal" || validTemporalKeys === normalizedData.length;
|
| 258 |
+
const sortedData = [...normalizedData].sort((a, b) => {
|
| 259 |
+
if (isTemporal) {
|
| 260 |
+
return d3.ascending(a.__temporalKey, b.__temporalKey) || d3.ascending(a.__index, b.__index);
|
| 261 |
+
}
|
| 262 |
+
return d3.descending(a.__rawValue, b.__rawValue) || d3.ascending(a.__index, b.__index);
|
| 263 |
+
}).slice(0, 12);
|
| 264 |
+
const sortedDimensions = sortedData.map(d => d.__dimension);
|
| 265 |
+
const displayDimensions = buildDisplayLabels(sortedDimensions, isTemporal);
|
| 266 |
+
|
| 267 |
+
const maxValue = d3.max(sortedData, d => d.__rawValue) || 0;
|
| 268 |
+
const maxDisplayDots = 22;
|
| 269 |
+
const useDirectCounts = maxValue <= maxDisplayDots && Number.isInteger(maxValue);
|
| 270 |
+
sortedData.forEach(d => {
|
| 271 |
+
if (d.__rawValue <= 0 || maxValue <= 0) {
|
| 272 |
+
d.__dotCount = 0;
|
| 273 |
+
} else if (useDirectCounts) {
|
| 274 |
+
d.__dotCount = Math.round(d.__rawValue);
|
| 275 |
+
} else {
|
| 276 |
+
d.__dotCount = Math.max(1, Math.round((d.__rawValue / maxValue) * maxDisplayDots));
|
| 277 |
+
}
|
| 278 |
+
});
|
| 279 |
+
sortedData.forEach((d, index) => {
|
| 280 |
+
const baseColor = referencePalette[index % referencePalette.length];
|
| 281 |
+
d.__baseColor = baseColor;
|
| 282 |
+
d.__darkColor = chartUtils.color.variant(baseColor, { mode: "darker", amount: 0.24 });
|
| 283 |
+
d.__lightColor = chartUtils.color.variant(baseColor, { mode: "brighter", amount: 0.48 });
|
| 284 |
+
d.__paleColor = d3.interpolateRgb("#FFFFFF", baseColor)(0.18);
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
const svg = d3.select(containerSelector)
|
| 288 |
+
.append("svg")
|
| 289 |
+
.attr("width", "100%")
|
| 290 |
+
.attr("height", height)
|
| 291 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 292 |
+
.attr("style", "max-width: 100%; height: auto;");
|
| 293 |
+
|
| 294 |
+
const defs = svg.append("defs");
|
| 295 |
+
const shadow = defs.append("filter")
|
| 296 |
+
.attr("id", "vertical-dot-reference-shadow")
|
| 297 |
+
.attr("x", "-7%")
|
| 298 |
+
.attr("y", "-7%")
|
| 299 |
+
.attr("width", "114%")
|
| 300 |
+
.attr("height", "114%");
|
| 301 |
+
shadow.append("feDropShadow")
|
| 302 |
+
.attr("dx", 0)
|
| 303 |
+
.attr("dy", 7)
|
| 304 |
+
.attr("stdDeviation", 8)
|
| 305 |
+
.attr("flood-color", "#5A7D94")
|
| 306 |
+
.attr("flood-opacity", 0.16);
|
| 307 |
+
|
| 308 |
+
for (let index = 0; index < sortedData.length; index++) {
|
| 309 |
+
const iconGrad = defs.append("radialGradient")
|
| 310 |
+
.attr("id", `vertical-dot-icon-gradient-${index}`)
|
| 311 |
+
.attr("cx", "38%")
|
| 312 |
+
.attr("cy", "34%")
|
| 313 |
+
.attr("r", "72%");
|
| 314 |
+
iconGrad.append("stop").attr("offset", "0%").attr("stop-color", sortedData[index].__lightColor);
|
| 315 |
+
iconGrad.append("stop").attr("offset", "100%").attr("stop-color", sortedData[index].__darkColor);
|
| 316 |
+
|
| 317 |
+
const grad = defs.append("linearGradient")
|
| 318 |
+
.attr("id", `vertical-dot-pill-gradient-${index}`)
|
| 319 |
+
.attr("x1", "0%")
|
| 320 |
+
.attr("x2", "100%")
|
| 321 |
+
.attr("y1", "0%")
|
| 322 |
+
.attr("y2", "0%");
|
| 323 |
+
grad.append("stop").attr("offset", "0%").attr("stop-color", sortedData[index].__darkColor);
|
| 324 |
+
grad.append("stop").attr("offset", "100%").attr("stop-color", sortedData[index].__lightColor);
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
svg.append("rect")
|
| 328 |
+
.attr("class", "chart-canvas-bg")
|
| 329 |
+
.attr("x", 0)
|
| 330 |
+
.attr("y", 0)
|
| 331 |
+
.attr("width", width)
|
| 332 |
+
.attr("height", height)
|
| 333 |
+
.attr("fill", "#F7FBFC");
|
| 334 |
+
|
| 335 |
+
svg.append("rect")
|
| 336 |
+
.attr("class", "chart-style-panel")
|
| 337 |
+
.attr("x", chartCardX)
|
| 338 |
+
.attr("y", chartCardY)
|
| 339 |
+
.attr("width", chartCardWidth)
|
| 340 |
+
.attr("height", chartCardHeight)
|
| 341 |
+
.attr("rx", 14)
|
| 342 |
+
.attr("ry", 14)
|
| 343 |
+
.attr("fill", "#FFFFFF")
|
| 344 |
+
.attr("stroke", panelStroke)
|
| 345 |
+
.attr("stroke-width", 1.1)
|
| 346 |
+
.attr("filter", "url(#vertical-dot-reference-shadow)");
|
| 347 |
+
|
| 348 |
+
const innerX = chartCardX + 26;
|
| 349 |
+
const innerWidth = chartCardWidth - 52;
|
| 350 |
+
const iconY = chartCardY + 62;
|
| 351 |
+
const labelTopY = chartCardY + 112;
|
| 352 |
+
const plotTop = chartCardY + 176;
|
| 353 |
+
const plotBottom = chartCardY + 438;
|
| 354 |
+
const valueTopY = chartCardY + 486;
|
| 355 |
+
const axisY = chartCardY + 524;
|
| 356 |
+
const xScale = d3.scaleBand()
|
| 357 |
+
.domain(sortedDimensions)
|
| 358 |
+
.range([innerX, innerX + innerWidth])
|
| 359 |
+
.padding(sortedData.length > 10 ? 0.18 : 0.24);
|
| 360 |
+
|
| 361 |
+
const axisLabel = `${String(valueColumn.description || valueColumn.name || valueField).replace(/\s*\([^)]*\)\s*$/, "")}${valueUnit ? ` (${valueUnit})` : ""}`;
|
| 362 |
+
const axisText = axisLabel.toUpperCase();
|
| 363 |
+
const axisTextWidth = Math.min(270, Math.max(145, measureLabelText(axisText, 13, "700", annotationFamily) + 18));
|
| 364 |
+
svg.append("line")
|
| 365 |
+
.attr("class", "reference-axis-line axis-left")
|
| 366 |
+
.attr("x1", innerX + 6)
|
| 367 |
+
.attr("x2", width / 2 - axisTextWidth / 2 - 18)
|
| 368 |
+
.attr("y1", axisY)
|
| 369 |
+
.attr("y2", axisY)
|
| 370 |
+
.attr("stroke", lineColor)
|
| 371 |
+
.attr("stroke-width", 1.1);
|
| 372 |
+
svg.append("line")
|
| 373 |
+
.attr("class", "reference-axis-line axis-right")
|
| 374 |
+
.attr("x1", width / 2 + axisTextWidth / 2 + 18)
|
| 375 |
+
.attr("x2", innerX + innerWidth - 6)
|
| 376 |
+
.attr("y1", axisY)
|
| 377 |
+
.attr("y2", axisY)
|
| 378 |
+
.attr("stroke", lineColor)
|
| 379 |
+
.attr("stroke-width", 1.1);
|
| 380 |
+
svg.append("text")
|
| 381 |
+
.attr("class", "axis-title")
|
| 382 |
+
.attr("x", width / 2)
|
| 383 |
+
.attr("y", axisY + 5)
|
| 384 |
+
.attr("text-anchor", "middle")
|
| 385 |
+
.style("font-family", annotationFamily)
|
| 386 |
+
.style("font-size", "13px")
|
| 387 |
+
.style("font-weight", "750")
|
| 388 |
+
.style("letter-spacing", "0")
|
| 389 |
+
.style("fill", "#49688B")
|
| 390 |
+
.text(axisText);
|
| 391 |
+
|
| 392 |
+
const dotHeight = 8.4;
|
| 393 |
+
const dotGap = 5.0;
|
| 394 |
+
const plotHeight = plotBottom - plotTop;
|
| 395 |
+
const iconRadius = Math.max(20, Math.min(31, xScale.bandwidth() * 0.34));
|
| 396 |
+
const labelMaxWidth = Math.max(72, xScale.bandwidth() * 1.18);
|
| 397 |
+
const labelBaseSize = Math.max(9.2, Math.min(13.5, xScale.bandwidth() / 6.4));
|
| 398 |
+
|
| 399 |
+
for (let i = 1; i < sortedDimensions.length; i++) {
|
| 400 |
+
const prevCenter = xScale(sortedDimensions[i - 1]) + xScale.bandwidth() / 2;
|
| 401 |
+
const nextCenter = xScale(sortedDimensions[i]) + xScale.bandwidth() / 2;
|
| 402 |
+
svg.append("line")
|
| 403 |
+
.attr("class", "column-separator")
|
| 404 |
+
.attr("x1", (prevCenter + nextCenter) / 2)
|
| 405 |
+
.attr("x2", (prevCenter + nextCenter) / 2)
|
| 406 |
+
.attr("y1", chartCardY + 42)
|
| 407 |
+
.attr("y2", valueTopY + 22)
|
| 408 |
+
.attr("stroke", "#DDE7EF")
|
| 409 |
+
.attr("stroke-width", 1.1);
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
sortedData.forEach((dataPoint, dataIndex) => {
|
| 413 |
+
const dimension = dataPoint.__dimension;
|
| 414 |
+
const centerX = xScale(dimension) + xScale.bandwidth() / 2;
|
| 415 |
+
const columnWidth = xScale.bandwidth();
|
| 416 |
+
const barWidth = Math.max(38, Math.min(74, columnWidth * 0.58));
|
| 417 |
+
const barX = centerX - barWidth / 2;
|
| 418 |
+
const displayLabel = displayDimensions[dataIndex] || String(dimension);
|
| 419 |
+
const categoryLines = splitCategoryLines(displayLabel, labelMaxWidth, labelBaseSize);
|
| 420 |
+
const iconLetter = String(displayLabel).trim().charAt(0).toUpperCase() || "?";
|
| 421 |
+
const formattedValue = formatValueWithUnit(dataPoint.__rawValue, valueUnit);
|
| 422 |
+
const valueParts = splitFormattedValue(dataPoint.__rawValue, valueUnit);
|
| 423 |
+
|
| 424 |
+
svg.append("circle")
|
| 425 |
+
.attr("class", "category-icon-slot reference-icon-slot")
|
| 426 |
+
.attr("data-asset-slot", "category-icon")
|
| 427 |
+
.attr("data-slot-policy", "image2_asset_or_neutral_placeholder")
|
| 428 |
+
.attr("data-anchor", "category-column-centerline")
|
| 429 |
+
.attr("data-collision-rule", "shrink-radius-before-overlap-with-neighboring-icon-or-category-label")
|
| 430 |
+
.attr("data-source-index", dataPoint.__index)
|
| 431 |
+
.attr("data-category", String(dimension))
|
| 432 |
+
.attr("data-value", dataPoint.__rawValue)
|
| 433 |
+
.attr("cx", centerX)
|
| 434 |
+
.attr("cy", iconY)
|
| 435 |
+
.attr("r", iconRadius)
|
| 436 |
+
.attr("fill", `url(#vertical-dot-icon-gradient-${dataIndex})`)
|
| 437 |
+
.attr("stroke", "#FFFFFF")
|
| 438 |
+
.attr("stroke-width", 1.4);
|
| 439 |
+
|
| 440 |
+
svg.append("text")
|
| 441 |
+
.attr("class", "category-icon-glyph")
|
| 442 |
+
.attr("x", centerX)
|
| 443 |
+
.attr("y", iconY + 5)
|
| 444 |
+
.attr("text-anchor", "middle")
|
| 445 |
+
.style("font-family", annotationFamily)
|
| 446 |
+
.style("font-size", `${Math.max(12, iconRadius * 0.78)}px`)
|
| 447 |
+
.style("font-weight", "800")
|
| 448 |
+
.style("fill", "#FFFFFF")
|
| 449 |
+
.text(iconLetter);
|
| 450 |
+
|
| 451 |
+
const labelGroup = svg.append("text")
|
| 452 |
+
.attr("class", "label category-label")
|
| 453 |
+
.attr("data-source-index", dataPoint.__index)
|
| 454 |
+
.attr("data-category", String(dimension))
|
| 455 |
+
.attr("data-value", dataPoint.__rawValue)
|
| 456 |
+
.attr("x", centerX)
|
| 457 |
+
.attr("y", labelTopY)
|
| 458 |
+
.attr("text-anchor", "middle")
|
| 459 |
+
.style("font-family", fontFamily)
|
| 460 |
+
.style("font-weight", "650")
|
| 461 |
+
.style("fill", textColor);
|
| 462 |
+
categoryLines.forEach((line, lineIndex) => {
|
| 463 |
+
const lineSize = fittedFontSize(line, labelMaxWidth, labelBaseSize, 5.6, "650", fontFamily);
|
| 464 |
+
const tspan = labelGroup.append("tspan")
|
| 465 |
+
.attr("x", centerX)
|
| 466 |
+
.attr("dy", lineIndex === 0 ? 0 : `${lineSize + 2}px`)
|
| 467 |
+
.style("font-size", `${lineSize}px`);
|
| 468 |
+
applyTextFit(tspan, line, labelMaxWidth, lineSize, "650", fontFamily);
|
| 469 |
+
});
|
| 470 |
+
|
| 471 |
+
svg.append("rect")
|
| 472 |
+
.attr("class", "dot-bar-track")
|
| 473 |
+
.attr("data-source-index", dataPoint.__index)
|
| 474 |
+
.attr("data-category", String(dimension))
|
| 475 |
+
.attr("data-value", dataPoint.__rawValue)
|
| 476 |
+
.attr("x", barX - 2)
|
| 477 |
+
.attr("y", plotTop)
|
| 478 |
+
.attr("width", barWidth + 4)
|
| 479 |
+
.attr("height", plotHeight)
|
| 480 |
+
.attr("rx", (barWidth + 4) / 2)
|
| 481 |
+
.attr("fill", "#FFFFFF")
|
| 482 |
+
.attr("fill-opacity", 0.01);
|
| 483 |
+
|
| 484 |
+
for (let dotIndex = 0; dotIndex < dataPoint.__dotCount; dotIndex++) {
|
| 485 |
+
const y = plotBottom - dotHeight - dotIndex * (dotHeight + dotGap);
|
| 486 |
+
if (y < plotTop) break;
|
| 487 |
+
svg.append("rect")
|
| 488 |
+
.attr("class", "mark dot-pill")
|
| 489 |
+
.attr("data-source-index", dataPoint.__index)
|
| 490 |
+
.attr("data-category", String(dimension))
|
| 491 |
+
.attr("data-value", dataPoint.__rawValue)
|
| 492 |
+
.attr("data-dot-index", dotIndex)
|
| 493 |
+
.attr("x", barX)
|
| 494 |
+
.attr("y", y)
|
| 495 |
+
.attr("width", barWidth)
|
| 496 |
+
.attr("height", dotHeight)
|
| 497 |
+
.attr("rx", dotHeight / 2)
|
| 498 |
+
.attr("ry", dotHeight / 2)
|
| 499 |
+
.attr("fill", `url(#vertical-dot-pill-gradient-${dataIndex})`);
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
const valueSize = fittedFontSize(valueParts.main, Math.max(32, columnWidth * 0.98), 18, 9.5, "800", annotationFamily);
|
| 503 |
+
const valueText = svg.append("text")
|
| 504 |
+
.attr("class", "value value-number")
|
| 505 |
+
.attr("data-source-index", dataPoint.__index)
|
| 506 |
+
.attr("data-category", String(dimension))
|
| 507 |
+
.attr("data-value", dataPoint.__rawValue)
|
| 508 |
+
.attr("x", centerX)
|
| 509 |
+
.attr("y", valueTopY)
|
| 510 |
+
.attr("text-anchor", "middle")
|
| 511 |
+
.style("font-family", annotationFamily)
|
| 512 |
+
.style("font-size", `${valueSize}px`)
|
| 513 |
+
.style("font-weight", "800")
|
| 514 |
+
.style("fill", dataPoint.__darkColor);
|
| 515 |
+
applyTextFit(valueText, valueParts.main, Math.max(32, columnWidth * 0.98), valueSize, "800", annotationFamily);
|
| 516 |
+
|
| 517 |
+
if (valueParts.unit) {
|
| 518 |
+
const unitText = svg.append("text")
|
| 519 |
+
.attr("class", "value-unit")
|
| 520 |
+
.attr("data-source-index", dataPoint.__index)
|
| 521 |
+
.attr("data-category", String(dimension))
|
| 522 |
+
.attr("data-value", dataPoint.__rawValue)
|
| 523 |
+
.attr("x", centerX)
|
| 524 |
+
.attr("y", valueTopY + 19)
|
| 525 |
+
.attr("text-anchor", "middle")
|
| 526 |
+
.style("font-family", annotationFamily)
|
| 527 |
+
.style("font-size", "12px")
|
| 528 |
+
.style("font-weight", "500")
|
| 529 |
+
.style("fill", "#3D5C82");
|
| 530 |
+
applyTextFit(unitText, valueParts.unit, Math.max(30, columnWidth * 0.92), 12, "500", annotationFamily);
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
svg.append("title").text(`${displayLabel}: ${formattedValue}`);
|
| 534 |
+
});
|
| 535 |
+
|
| 536 |
+
return svg.node();
|
| 537 |
+
}
|
modules/chart_engine/template/d3-js/type20_vertical_dot_bar_chart/vertical_dot_bar_plain_chart_02.js
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Vertical Dot Bar Chart",
|
| 5 |
+
"chart_name": "vertical_dot_bar_plain_chart_02",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 6], [3, 20]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": [],
|
| 13 |
+
"required_other_colors": [],
|
| 14 |
+
"supported_effects": ["shadow", "gradient", "stroke", "spacing"],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "none",
|
| 20 |
+
"has_x_axis": "no",
|
| 21 |
+
"has_y_axis": "no"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data;
|
| 28 |
+
const chartData = jsonData.data.data;
|
| 29 |
+
const variables = jsonData.variables || {};
|
| 30 |
+
const typography = jsonData.typography || {
|
| 31 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 32 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 33 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 34 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 35 |
+
};
|
| 36 |
+
const sourceColors = jsonData.colors || {
|
| 37 |
+
text_color: "#17324F",
|
| 38 |
+
other: { primary: "#174EA6", secondary: "#E95A4E" }
|
| 39 |
+
};
|
| 40 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 41 |
+
const fontFamily = typography.label.font_family || "Arial";
|
| 42 |
+
const annotationFamily = typography.annotation.font_family || fontFamily;
|
| 43 |
+
|
| 44 |
+
const measureLabelText = (text, fontSize, fontWeight = "500", family = fontFamily) => {
|
| 45 |
+
const canvas = document.createElement("canvas");
|
| 46 |
+
const ctx = canvas.getContext("2d");
|
| 47 |
+
ctx.font = `${fontWeight} ${fontSize}px ${family}`;
|
| 48 |
+
return chartUtils.text.contextWidth(ctx, String(text));
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const fittedFontSize = (text, maxWidth, baseFontSize, minFontSize = 7, fontWeight = "500", family = fontFamily) => {
|
| 52 |
+
let size = baseFontSize;
|
| 53 |
+
while (size > minFontSize && measureLabelText(text, size, fontWeight, family) > maxWidth) {
|
| 54 |
+
size -= 0.4;
|
| 55 |
+
}
|
| 56 |
+
return Math.max(minFontSize, size);
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const applyTextFit = (selection, text, maxWidth, fontSize, fontWeight = "500", family = fontFamily) => {
|
| 60 |
+
selection.text(text);
|
| 61 |
+
if (measureLabelText(text, fontSize, fontWeight, family) > maxWidth) {
|
| 62 |
+
selection
|
| 63 |
+
.attr("textLength", maxWidth)
|
| 64 |
+
.attr("lengthAdjust", "spacingAndGlyphs");
|
| 65 |
+
}
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
const labelTokens = (text) => String(text)
|
| 69 |
+
.replace(/[()[\]{}]/g, " ")
|
| 70 |
+
.replace(/[,:;/\\|_-]+/g, " ")
|
| 71 |
+
.split(/\s+/)
|
| 72 |
+
.map(token => token.trim())
|
| 73 |
+
.filter(Boolean);
|
| 74 |
+
|
| 75 |
+
const compactCategory = (text, isTemporalLabel) => {
|
| 76 |
+
const str = String(text).trim();
|
| 77 |
+
if (isTemporalLabel) return str;
|
| 78 |
+
const tokens = labelTokens(str);
|
| 79 |
+
if (tokens.length > 4) return `${tokens[0]} ${tokens[tokens.length - 1]}`;
|
| 80 |
+
return str;
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const splitCategoryLines = (text, maxWidth, fontSize) => {
|
| 84 |
+
const str = String(text).trim();
|
| 85 |
+
if (measureLabelText(str, fontSize, "750", fontFamily) <= maxWidth) return [str];
|
| 86 |
+
const tokens = labelTokens(str);
|
| 87 |
+
if (tokens.length <= 1) return [str];
|
| 88 |
+
const lines = [];
|
| 89 |
+
let current = "";
|
| 90 |
+
tokens.forEach(token => {
|
| 91 |
+
const next = current ? `${current} ${token}` : token;
|
| 92 |
+
if (lines.length < 1 && measureLabelText(next, fontSize, "750", fontFamily) <= maxWidth) {
|
| 93 |
+
current = next;
|
| 94 |
+
} else if (lines.length < 1) {
|
| 95 |
+
lines.push(current || token);
|
| 96 |
+
current = current ? token : "";
|
| 97 |
+
} else {
|
| 98 |
+
current = current ? `${current} ${token}` : token;
|
| 99 |
+
}
|
| 100 |
+
});
|
| 101 |
+
if (current) lines.push(current);
|
| 102 |
+
return lines.slice(0, 2);
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
const temporalKey = (value) => {
|
| 106 |
+
if (value instanceof Date) return value.getTime();
|
| 107 |
+
if (typeof value === "number") return value;
|
| 108 |
+
const str = String(value).trim();
|
| 109 |
+
if (/^-?\d+(\.\d+)?$/.test(str)) return +str;
|
| 110 |
+
const seasonOrder = { winter: 0, spring: 1, summer: 2, fall: 3, autumn: 3 };
|
| 111 |
+
const seasonYear = str.match(/^(winter|spring|summer|fall|autumn)\s+(-?\d{1,4})$/i);
|
| 112 |
+
if (seasonYear) return (+seasonYear[2]) * 10 + seasonOrder[seasonYear[1].toLowerCase()];
|
| 113 |
+
const yearSeason = str.match(/^(-?\d{1,4})\s+(winter|spring|summer|fall|autumn)$/i);
|
| 114 |
+
if (yearSeason) return (+yearSeason[1]) * 10 + seasonOrder[yearSeason[2].toLowerCase()];
|
| 115 |
+
const parsed = Date.parse(str);
|
| 116 |
+
if (!Number.isNaN(parsed)) return parsed;
|
| 117 |
+
const yearMatch = str.match(/^-?\d{1,4}/);
|
| 118 |
+
if (yearMatch) return +yearMatch[0];
|
| 119 |
+
return null;
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
const formatLocalValue = (value) => {
|
| 123 |
+
if (Math.abs(value) >= 1000000000) return d3.format("~g")(value / 1000000000) + "B";
|
| 124 |
+
if (Math.abs(value) >= 1000000) return d3.format("~g")(value / 1000000) + "M";
|
| 125 |
+
if (Math.abs(value) >= 1000) return d3.format("~g")(value / 1000) + "K";
|
| 126 |
+
return d3.format("~g")(value);
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
const formatValueWithUnit = (value, unit) => {
|
| 130 |
+
const formatted = formatLocalValue(value);
|
| 131 |
+
const trimmedUnit = String(unit || "").trim();
|
| 132 |
+
if (!trimmedUnit) return formatted;
|
| 133 |
+
if (trimmedUnit === "$") return `$${formatted}`;
|
| 134 |
+
if (trimmedUnit === "%") return `${formatted}%`;
|
| 135 |
+
return `${formatted} ${trimmedUnit}`;
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
const wrapTextLines = (text, maxWidth, fontSize, maxLines, fontWeight = "500", family = fontFamily) => {
|
| 139 |
+
const words = String(text || "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
|
| 140 |
+
const lines = [];
|
| 141 |
+
let current = "";
|
| 142 |
+
words.forEach(word => {
|
| 143 |
+
if (lines.length >= maxLines) return;
|
| 144 |
+
const next = current ? `${current} ${word}` : word;
|
| 145 |
+
if (measureLabelText(next, fontSize, fontWeight, family) <= maxWidth || !current) {
|
| 146 |
+
current = next;
|
| 147 |
+
} else {
|
| 148 |
+
lines.push(current);
|
| 149 |
+
current = word;
|
| 150 |
+
}
|
| 151 |
+
});
|
| 152 |
+
if (current && lines.length < maxLines) lines.push(current);
|
| 153 |
+
return lines;
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
d3.select(containerSelector).html("");
|
| 157 |
+
|
| 158 |
+
const width = Math.max(1000, variables.width || 1000);
|
| 159 |
+
const cardX = 28;
|
| 160 |
+
const cardY = 22;
|
| 161 |
+
const cardWidth = width - cardX * 2;
|
| 162 |
+
const cardHeight = 720;
|
| 163 |
+
const legendHeight = 86;
|
| 164 |
+
const height = cardY + cardHeight + legendHeight + 34;
|
| 165 |
+
|
| 166 |
+
const dimensionChannel = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 });
|
| 167 |
+
const valueChannel = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 });
|
| 168 |
+
const dimensionField = dimensionChannel.raw?.name || dimensionChannel.key || "dimension";
|
| 169 |
+
const valueField = valueChannel.raw?.name || valueChannel.key || "value";
|
| 170 |
+
const dimensionColumn = dimensionChannel.raw || {};
|
| 171 |
+
const valueColumn = valueChannel.raw || {};
|
| 172 |
+
const valueUnit = valueColumn.unit && valueColumn.unit !== "none" ? valueColumn.unit : "";
|
| 173 |
+
const valueCaption = String(valueColumn.name || valueField)
|
| 174 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 175 |
+
.replace(/[_-]+/g, " ")
|
| 176 |
+
.replace(/\s*\([^)]*\)\s*$/, "")
|
| 177 |
+
.trim() || valueField;
|
| 178 |
+
|
| 179 |
+
const normalizedData = chartData.map((d, index) => ({
|
| 180 |
+
...d,
|
| 181 |
+
__index: index,
|
| 182 |
+
__dimension: d[dimensionField],
|
| 183 |
+
__rawValue: Math.max(0, +d[valueField] || 0),
|
| 184 |
+
__temporalKey: temporalKey(d[dimensionField])
|
| 185 |
+
}));
|
| 186 |
+
const validTemporalKeys = normalizedData.filter(d => d.__temporalKey !== null).length;
|
| 187 |
+
const isTemporal = dimensionColumn.data_type === "temporal" || validTemporalKeys === normalizedData.length;
|
| 188 |
+
const sortedData = [...normalizedData].sort((a, b) => {
|
| 189 |
+
if (isTemporal) {
|
| 190 |
+
return d3.ascending(a.__temporalKey, b.__temporalKey) || d3.ascending(a.__index, b.__index);
|
| 191 |
+
}
|
| 192 |
+
return d3.descending(a.__rawValue, b.__rawValue) || d3.ascending(a.__index, b.__index);
|
| 193 |
+
}).slice(0, 6);
|
| 194 |
+
|
| 195 |
+
const sourcePalette = [
|
| 196 |
+
"#0E55C7", "#0A9F9A", "#67B343", "#F68A13", "#7351C7", "#D94A4A"
|
| 197 |
+
];
|
| 198 |
+
sortedData.forEach((d, index) => {
|
| 199 |
+
const baseColor = sourcePalette[index % sourcePalette.length];
|
| 200 |
+
d.__baseColor = baseColor;
|
| 201 |
+
d.__darkColor = chartUtils.color.variant(baseColor, { mode: "darker", amount: 0.20 });
|
| 202 |
+
d.__lightColor = chartUtils.color.variant(baseColor, { mode: "brighter", amount: 0.52 });
|
| 203 |
+
d.__paleColor = d3.interpolateRgb("#FFFFFF", baseColor)(0.10);
|
| 204 |
+
d.__activeUnits = Math.max(0, Math.min(10, Math.round(d.__rawValue)));
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
const displayLabels = sortedData.map(d => compactCategory(d.__dimension, isTemporal));
|
| 208 |
+
const textColor = "#092B62";
|
| 209 |
+
const mutedText = "#536579";
|
| 210 |
+
const panelStroke = "#D9E8F1";
|
| 211 |
+
const inactiveColor = "#D9E3EA";
|
| 212 |
+
|
| 213 |
+
const svg = d3.select(containerSelector)
|
| 214 |
+
.append("svg")
|
| 215 |
+
.attr("width", "100%")
|
| 216 |
+
.attr("height", height)
|
| 217 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 218 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 219 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 220 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 221 |
+
|
| 222 |
+
const defs = svg.append("defs");
|
| 223 |
+
const cardShadow = defs.append("filter")
|
| 224 |
+
.attr("id", "vertical-dot-card-shadow-02")
|
| 225 |
+
.attr("x", "-10%")
|
| 226 |
+
.attr("y", "-10%")
|
| 227 |
+
.attr("width", "120%")
|
| 228 |
+
.attr("height", "125%");
|
| 229 |
+
cardShadow.append("feDropShadow")
|
| 230 |
+
.attr("dx", 0)
|
| 231 |
+
.attr("dy", 8)
|
| 232 |
+
.attr("stdDeviation", 9)
|
| 233 |
+
.attr("flood-color", "#6D879E")
|
| 234 |
+
.attr("flood-opacity", 0.18);
|
| 235 |
+
|
| 236 |
+
const legendShadow = defs.append("filter")
|
| 237 |
+
.attr("id", "vertical-dot-legend-shadow-02")
|
| 238 |
+
.attr("x", "-8%")
|
| 239 |
+
.attr("y", "-20%")
|
| 240 |
+
.attr("width", "116%")
|
| 241 |
+
.attr("height", "150%");
|
| 242 |
+
legendShadow.append("feDropShadow")
|
| 243 |
+
.attr("dx", 0)
|
| 244 |
+
.attr("dy", 5)
|
| 245 |
+
.attr("stdDeviation", 5)
|
| 246 |
+
.attr("flood-color", "#A8BECC")
|
| 247 |
+
.attr("flood-opacity", 0.18);
|
| 248 |
+
|
| 249 |
+
sortedData.forEach((d, index) => {
|
| 250 |
+
const headerGrad = defs.append("linearGradient")
|
| 251 |
+
.attr("id", `vertical-dot-card-header-gradient-02-${index}`)
|
| 252 |
+
.attr("x1", "0%")
|
| 253 |
+
.attr("x2", "100%")
|
| 254 |
+
.attr("y1", "0%")
|
| 255 |
+
.attr("y2", "0%");
|
| 256 |
+
headerGrad.append("stop").attr("offset", "0%").attr("stop-color", d.__darkColor);
|
| 257 |
+
headerGrad.append("stop").attr("offset", "100%").attr("stop-color", d.__lightColor);
|
| 258 |
+
|
| 259 |
+
const iconGrad = defs.append("radialGradient")
|
| 260 |
+
.attr("id", `vertical-dot-icon-gradient-02-${index}`)
|
| 261 |
+
.attr("cx", "38%")
|
| 262 |
+
.attr("cy", "33%")
|
| 263 |
+
.attr("r", "72%");
|
| 264 |
+
iconGrad.append("stop").attr("offset", "0%").attr("stop-color", d.__paleColor);
|
| 265 |
+
iconGrad.append("stop").attr("offset", "100%").attr("stop-color", d3.interpolateRgb("#FFFFFF", d.__baseColor)(0.22));
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
svg.append("rect")
|
| 269 |
+
.attr("class", "chart-canvas-bg")
|
| 270 |
+
.attr("x", 0)
|
| 271 |
+
.attr("y", 0)
|
| 272 |
+
.attr("width", width)
|
| 273 |
+
.attr("height", height)
|
| 274 |
+
.attr("fill", "none");
|
| 275 |
+
|
| 276 |
+
const cardGap = Math.max(10, sortedData.length > 5 ? 10 : 13);
|
| 277 |
+
const categoryCardWidth = (cardWidth - cardGap * (sortedData.length - 1)) / sortedData.length;
|
| 278 |
+
const categoryCardHeight = cardHeight;
|
| 279 |
+
const headerHeight = 78;
|
| 280 |
+
const categorySlotY = cardY + headerHeight + 86;
|
| 281 |
+
const unitGridTop = cardY + headerHeight + 194;
|
| 282 |
+
const valueY = cardY + cardHeight - 124;
|
| 283 |
+
const captionY = cardY + cardHeight - 75;
|
| 284 |
+
const underlineY = cardY + cardHeight - 38;
|
| 285 |
+
|
| 286 |
+
const drawPerson = (parent, cx, cy, scale, fill, opacity, className, datum, unitIndex) => {
|
| 287 |
+
const group = parent.append("g")
|
| 288 |
+
.attr("class", className)
|
| 289 |
+
.attr("data-source-index", datum.__index)
|
| 290 |
+
.attr("data-category", String(datum.__dimension))
|
| 291 |
+
.attr("data-value", datum.__rawValue)
|
| 292 |
+
.attr("data-unit-index", unitIndex)
|
| 293 |
+
.attr("transform", `translate(${cx},${cy}) scale(${scale})`)
|
| 294 |
+
.attr("opacity", opacity);
|
| 295 |
+
group.append("circle")
|
| 296 |
+
.attr("cx", 0)
|
| 297 |
+
.attr("cy", -10)
|
| 298 |
+
.attr("r", 8.2)
|
| 299 |
+
.attr("fill", fill);
|
| 300 |
+
group.append("path")
|
| 301 |
+
.attr("d", "M -14 14 C -13 2 -7 -2 0 -2 C 7 -2 13 2 14 14 Z")
|
| 302 |
+
.attr("fill", fill);
|
| 303 |
+
return group;
|
| 304 |
+
};
|
| 305 |
+
|
| 306 |
+
sortedData.forEach((dataPoint, dataIndex) => {
|
| 307 |
+
const x = cardX + dataIndex * (categoryCardWidth + cardGap);
|
| 308 |
+
const centerX = x + categoryCardWidth / 2;
|
| 309 |
+
const cardRadius = 16;
|
| 310 |
+
const headerRadius = 16;
|
| 311 |
+
const labelText = displayLabels[dataIndex] || String(dataPoint.__dimension);
|
| 312 |
+
const labelLines = splitCategoryLines(labelText, categoryCardWidth - 22, 21);
|
| 313 |
+
const iconRadius = Math.max(37, Math.min(56, categoryCardWidth * 0.30));
|
| 314 |
+
const unitScale = Math.max(1.02, Math.min(1.38, categoryCardWidth / 142));
|
| 315 |
+
const unitColGap = Math.max(36, Math.min(54, categoryCardWidth * 0.30));
|
| 316 |
+
const unitRowGap = 49;
|
| 317 |
+
const gridCenterX = centerX;
|
| 318 |
+
const gridLeft = gridCenterX - unitColGap / 2;
|
| 319 |
+
const gridRight = gridCenterX + unitColGap / 2;
|
| 320 |
+
const formattedValue = formatValueWithUnit(dataPoint.__rawValue, valueUnit);
|
| 321 |
+
const iconLetter = String(labelText).trim().charAt(0).toUpperCase() || "?";
|
| 322 |
+
|
| 323 |
+
svg.append("rect")
|
| 324 |
+
.attr("class", "category-card")
|
| 325 |
+
.attr("data-source-index", dataPoint.__index)
|
| 326 |
+
.attr("data-category", String(dataPoint.__dimension))
|
| 327 |
+
.attr("data-value", dataPoint.__rawValue)
|
| 328 |
+
.attr("x", x)
|
| 329 |
+
.attr("y", cardY)
|
| 330 |
+
.attr("width", categoryCardWidth)
|
| 331 |
+
.attr("height", categoryCardHeight)
|
| 332 |
+
.attr("rx", cardRadius)
|
| 333 |
+
.attr("ry", cardRadius)
|
| 334 |
+
.attr("fill", "#FFFFFF")
|
| 335 |
+
.attr("stroke", panelStroke)
|
| 336 |
+
.attr("stroke-width", 1.2)
|
| 337 |
+
.attr("filter", "url(#vertical-dot-card-shadow-02)");
|
| 338 |
+
|
| 339 |
+
svg.append("path")
|
| 340 |
+
.attr("class", "category-card-header")
|
| 341 |
+
.attr("data-source-index", dataPoint.__index)
|
| 342 |
+
.attr("data-category", String(dataPoint.__dimension))
|
| 343 |
+
.attr("data-value", dataPoint.__rawValue)
|
| 344 |
+
.attr("d", [
|
| 345 |
+
`M ${x} ${cardY + headerRadius}`,
|
| 346 |
+
`Q ${x} ${cardY} ${x + headerRadius} ${cardY}`,
|
| 347 |
+
`L ${x + categoryCardWidth - headerRadius} ${cardY}`,
|
| 348 |
+
`Q ${x + categoryCardWidth} ${cardY} ${x + categoryCardWidth} ${cardY + headerRadius}`,
|
| 349 |
+
`L ${x + categoryCardWidth} ${cardY + headerHeight}`,
|
| 350 |
+
`L ${x} ${cardY + headerHeight}`,
|
| 351 |
+
"Z"
|
| 352 |
+
].join(" "))
|
| 353 |
+
.attr("fill", `url(#vertical-dot-card-header-gradient-02-${dataIndex})`);
|
| 354 |
+
|
| 355 |
+
const headerLabel = svg.append("text")
|
| 356 |
+
.attr("class", "category-header-label")
|
| 357 |
+
.attr("data-source-index", dataPoint.__index)
|
| 358 |
+
.attr("data-category", String(dataPoint.__dimension))
|
| 359 |
+
.attr("data-value", dataPoint.__rawValue)
|
| 360 |
+
.attr("x", centerX)
|
| 361 |
+
.attr("y", cardY + 33)
|
| 362 |
+
.attr("text-anchor", "middle")
|
| 363 |
+
.style("font-family", fontFamily)
|
| 364 |
+
.style("font-weight", "800")
|
| 365 |
+
.style("fill", "#FFFFFF");
|
| 366 |
+
const headerFont = fittedFontSize(labelLines.join(" "), categoryCardWidth - 22, labelLines.length > 1 ? 17 : 21, 10, "800", fontFamily);
|
| 367 |
+
labelLines.forEach((line, lineIndex) => {
|
| 368 |
+
const tspan = headerLabel.append("tspan")
|
| 369 |
+
.attr("x", centerX)
|
| 370 |
+
.attr("dy", lineIndex === 0 ? 0 : `${headerFont + 2}px`)
|
| 371 |
+
.style("font-size", `${headerFont}px`);
|
| 372 |
+
applyTextFit(tspan, line, categoryCardWidth - 22, headerFont, "800", fontFamily);
|
| 373 |
+
});
|
| 374 |
+
|
| 375 |
+
svg.append("circle")
|
| 376 |
+
.attr("class", "reserved-asset-slot category-map-icon-slot")
|
| 377 |
+
.attr("data-asset-slot", "category-map-icon")
|
| 378 |
+
.attr("data-slot-policy", "image2_asset_or_neutral_placeholder")
|
| 379 |
+
.attr("data-asset-source-policy", "image2_asset_or_neutral_placeholder")
|
| 380 |
+
.attr("data-anchor", "category-card-centerline")
|
| 381 |
+
.attr("data-collision-rule", "stay-between-card-header-and-person-unit-grid; shrink-before-overlap")
|
| 382 |
+
.attr("data-bbox", `${(centerX - iconRadius).toFixed(1)},${(categorySlotY - iconRadius).toFixed(1)},${(iconRadius * 2).toFixed(1)},${(iconRadius * 2).toFixed(1)}`)
|
| 383 |
+
.attr("data-mask-policy", "circular mask; downstream asset may replace only inside this circle")
|
| 384 |
+
.attr("data-source-index", dataPoint.__index)
|
| 385 |
+
.attr("data-category", String(dataPoint.__dimension))
|
| 386 |
+
.attr("data-value", dataPoint.__rawValue)
|
| 387 |
+
.attr("cx", centerX)
|
| 388 |
+
.attr("cy", categorySlotY)
|
| 389 |
+
.attr("r", iconRadius)
|
| 390 |
+
.attr("fill", `url(#vertical-dot-icon-gradient-02-${dataIndex})`)
|
| 391 |
+
.attr("stroke", "#D6E7F1")
|
| 392 |
+
.attr("stroke-width", 2.4);
|
| 393 |
+
|
| 394 |
+
svg.append("text")
|
| 395 |
+
.attr("class", "category-map-icon-neutral-glyph")
|
| 396 |
+
.attr("x", centerX)
|
| 397 |
+
.attr("y", categorySlotY + iconRadius * 0.22)
|
| 398 |
+
.attr("text-anchor", "middle")
|
| 399 |
+
.style("font-family", annotationFamily)
|
| 400 |
+
.style("font-size", `${Math.max(20, iconRadius * 0.72)}px`)
|
| 401 |
+
.style("font-weight", "850")
|
| 402 |
+
.style("fill", d3.interpolateRgb(dataPoint.__baseColor, "#13304E")(0.44))
|
| 403 |
+
.text(iconLetter);
|
| 404 |
+
|
| 405 |
+
for (let unitIndex = 0; unitIndex < 10; unitIndex++) {
|
| 406 |
+
const col = unitIndex % 2;
|
| 407 |
+
const row = Math.floor(unitIndex / 2);
|
| 408 |
+
const unitCx = col === 0 ? gridLeft : gridRight;
|
| 409 |
+
const unitCy = unitGridTop + row * unitRowGap;
|
| 410 |
+
const isActive = unitIndex < dataPoint.__activeUnits;
|
| 411 |
+
drawPerson(
|
| 412 |
+
svg,
|
| 413 |
+
unitCx,
|
| 414 |
+
unitCy,
|
| 415 |
+
unitScale,
|
| 416 |
+
isActive ? dataPoint.__baseColor : inactiveColor,
|
| 417 |
+
isActive ? 1 : 0.78,
|
| 418 |
+
isActive ? "mark person-unit active-unit" : "mark person-unit inactive-unit",
|
| 419 |
+
dataPoint,
|
| 420 |
+
unitIndex
|
| 421 |
+
);
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
const valueSize = fittedFontSize(formattedValue, categoryCardWidth - 26, 45, 23, "850", annotationFamily);
|
| 425 |
+
const valueText = svg.append("text")
|
| 426 |
+
.attr("class", "value value-label")
|
| 427 |
+
.attr("data-source-index", dataPoint.__index)
|
| 428 |
+
.attr("data-category", String(dataPoint.__dimension))
|
| 429 |
+
.attr("data-value", dataPoint.__rawValue)
|
| 430 |
+
.attr("x", centerX)
|
| 431 |
+
.attr("y", valueY)
|
| 432 |
+
.attr("text-anchor", "middle")
|
| 433 |
+
.style("font-family", annotationFamily)
|
| 434 |
+
.style("font-size", `${valueSize}px`)
|
| 435 |
+
.style("font-weight", "850")
|
| 436 |
+
.style("fill", dataPoint.__baseColor);
|
| 437 |
+
applyTextFit(valueText, formattedValue, categoryCardWidth - 26, valueSize, "850", annotationFamily);
|
| 438 |
+
|
| 439 |
+
const captionLines = wrapTextLines(valueCaption, categoryCardWidth - 24, 13, 2, "500", annotationFamily);
|
| 440 |
+
const captionText = svg.append("text")
|
| 441 |
+
.attr("class", "value-caption")
|
| 442 |
+
.attr("data-source-index", dataPoint.__index)
|
| 443 |
+
.attr("data-category", String(dataPoint.__dimension))
|
| 444 |
+
.attr("data-value", dataPoint.__rawValue)
|
| 445 |
+
.attr("x", centerX)
|
| 446 |
+
.attr("y", captionY)
|
| 447 |
+
.attr("text-anchor", "middle")
|
| 448 |
+
.style("font-family", annotationFamily)
|
| 449 |
+
.style("font-size", "13px")
|
| 450 |
+
.style("font-weight", "500")
|
| 451 |
+
.style("fill", mutedText);
|
| 452 |
+
captionLines.forEach((line, lineIndex) => {
|
| 453 |
+
const tspan = captionText.append("tspan")
|
| 454 |
+
.attr("x", centerX)
|
| 455 |
+
.attr("dy", lineIndex === 0 ? 0 : "17px");
|
| 456 |
+
applyTextFit(tspan, line, categoryCardWidth - 24, 13, "500", annotationFamily);
|
| 457 |
+
});
|
| 458 |
+
|
| 459 |
+
svg.append("line")
|
| 460 |
+
.attr("class", "card-bottom-accent")
|
| 461 |
+
.attr("x1", centerX - categoryCardWidth * 0.22)
|
| 462 |
+
.attr("x2", centerX + categoryCardWidth * 0.22)
|
| 463 |
+
.attr("y1", underlineY)
|
| 464 |
+
.attr("y2", underlineY)
|
| 465 |
+
.attr("stroke", dataPoint.__baseColor)
|
| 466 |
+
.attr("stroke-width", 4)
|
| 467 |
+
.attr("stroke-linecap", "round");
|
| 468 |
+
|
| 469 |
+
svg.append("title").text(`${labelText}: ${formattedValue}`);
|
| 470 |
+
});
|
| 471 |
+
|
| 472 |
+
const legendX = Math.max(74, width * 0.10);
|
| 473 |
+
const legendY = cardY + cardHeight + 38;
|
| 474 |
+
const legendWidth = width - legendX * 2;
|
| 475 |
+
const legendCenterY = legendY + legendHeight / 2;
|
| 476 |
+
svg.append("rect")
|
| 477 |
+
.attr("class", "unit-legend-strip")
|
| 478 |
+
.attr("data-legend-role", "chart-owned-unit-encoding")
|
| 479 |
+
.attr("data-collision-rule", "stay-below-category-cards; no-source-footer-or-takeaway-prose")
|
| 480 |
+
.attr("x", legendX)
|
| 481 |
+
.attr("y", legendY)
|
| 482 |
+
.attr("width", legendWidth)
|
| 483 |
+
.attr("height", legendHeight)
|
| 484 |
+
.attr("rx", 12)
|
| 485 |
+
.attr("ry", 12)
|
| 486 |
+
.attr("fill", "#F3FAFE")
|
| 487 |
+
.attr("stroke", "#CFE2EE")
|
| 488 |
+
.attr("stroke-width", 1.4)
|
| 489 |
+
.attr("filter", "url(#vertical-dot-legend-shadow-02)");
|
| 490 |
+
|
| 491 |
+
drawPerson(svg, legendX + 68, legendCenterY + 7, 1.15, "#1556B7", 1, "legend-person-unit", { __index: -1, __dimension: "legend", __rawValue: 1 }, 0);
|
| 492 |
+
|
| 493 |
+
svg.append("text")
|
| 494 |
+
.attr("class", "legend-equals")
|
| 495 |
+
.attr("x", legendX + 120)
|
| 496 |
+
.attr("y", legendCenterY + 10)
|
| 497 |
+
.attr("text-anchor", "middle")
|
| 498 |
+
.style("font-family", annotationFamily)
|
| 499 |
+
.style("font-size", "24px")
|
| 500 |
+
.style("font-weight", "750")
|
| 501 |
+
.style("fill", "#33485B")
|
| 502 |
+
.text("= 1%");
|
| 503 |
+
|
| 504 |
+
svg.append("line")
|
| 505 |
+
.attr("class", "legend-divider")
|
| 506 |
+
.attr("x1", legendX + 210)
|
| 507 |
+
.attr("x2", legendX + 210)
|
| 508 |
+
.attr("y1", legendY + 20)
|
| 509 |
+
.attr("y2", legendY + legendHeight - 20)
|
| 510 |
+
.attr("stroke", "#9FB5C7")
|
| 511 |
+
.attr("stroke-width", 1.2);
|
| 512 |
+
|
| 513 |
+
const legendText = `Each person icon represents 1% of ${valueCaption.toLowerCase()}.`;
|
| 514 |
+
const legendLines = wrapTextLines(legendText, legendWidth - 265, 18, 2, "500", annotationFamily);
|
| 515 |
+
const legendTextNode = svg.append("text")
|
| 516 |
+
.attr("class", "unit-legend-text")
|
| 517 |
+
.attr("x", legendX + 250)
|
| 518 |
+
.attr("y", legendCenterY - (legendLines.length > 1 ? 11 : 0))
|
| 519 |
+
.attr("text-anchor", "start")
|
| 520 |
+
.style("font-family", annotationFamily)
|
| 521 |
+
.style("font-size", "18px")
|
| 522 |
+
.style("font-weight", "500")
|
| 523 |
+
.style("fill", "#33485B");
|
| 524 |
+
legendLines.forEach((line, index) => {
|
| 525 |
+
legendTextNode.append("tspan")
|
| 526 |
+
.attr("x", legendX + 250)
|
| 527 |
+
.attr("dy", index === 0 ? 0 : "24px")
|
| 528 |
+
.text(line);
|
| 529 |
+
});
|
| 530 |
+
|
| 531 |
+
return svg.node();
|
| 532 |
+
}
|