Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- modules/chart_engine/template/d3-js/type10_circular_bar_chart/circular_bar_chart_icons_01.js +599 -0
- modules/chart_engine/template/d3-js/type10_circular_bar_chart/circular_bar_chart_icons_02.js +543 -0
- modules/chart_engine/template/d3-js/type10_circular_bar_chart/circular_bar_plain_chart_01.js +255 -0
- modules/chart_engine/template/d3-js/type10_circular_bar_chart/circular_bar_plain_chart_02.js +602 -0
modules/chart_engine/template/d3-js/type10_circular_bar_chart/circular_bar_chart_icons_01.js
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Circular Bar Chart",
|
| 5 |
+
"chart_name": "circular_bar_chart_icons_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 20], [0, "inf"]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": [],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": ["shadow", "radius_corner", "gradient", "stroke"],
|
| 15 |
+
"min_height": 500,
|
| 16 |
+
"min_width": 500,
|
| 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 |
+
|
| 27 |
+
|
| 28 |
+
function makeChart(containerSelector, data) {
|
| 29 |
+
const jsonData = data;
|
| 30 |
+
const rows = (jsonData.data && jsonData.data.data ? jsonData.data.data : []).slice();
|
| 31 |
+
const variables = jsonData.variables || {};
|
| 32 |
+
const typography = jsonData.typography || {
|
| 33 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 34 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 35 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 36 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 37 |
+
};
|
| 38 |
+
const images = jsonData.images || { field: {}, other: {} };
|
| 39 |
+
|
| 40 |
+
d3.select(containerSelector).html("");
|
| 41 |
+
|
| 42 |
+
const rawWidth = +variables.width || 650;
|
| 43 |
+
const rawHeight = +variables.height || 975;
|
| 44 |
+
const width = Math.max(500, rawWidth);
|
| 45 |
+
const height = Math.max(width * 1.42, rawHeight);
|
| 46 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key || "category";
|
| 47 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key || "value";
|
| 48 |
+
const valueChannel = chartUtils.schema.channel(jsonData, "y");
|
| 49 |
+
const valueUnit = valueChannel && valueChannel.unit ? (valueChannel.unit === "B" ? " B" : valueChannel.unit) : "";
|
| 50 |
+
const fontFamily = (typography.label && typography.label.font_family) || "Arial, sans-serif";
|
| 51 |
+
|
| 52 |
+
if (!rows.length) return null;
|
| 53 |
+
|
| 54 |
+
const monthIndex = {
|
| 55 |
+
jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3,
|
| 56 |
+
apr: 4, april: 4, may: 5, jun: 6, june: 6, jul: 7, july: 7,
|
| 57 |
+
aug: 8, august: 8, sep: 9, sept: 9, september: 9, oct: 10,
|
| 58 |
+
october: 10, nov: 11, november: 11, dec: 12, december: 12
|
| 59 |
+
};
|
| 60 |
+
const seasonIndex = { winter: 1, spring: 2, summer: 3, fall: 4, autumn: 4 };
|
| 61 |
+
|
| 62 |
+
function temporalValue(value) {
|
| 63 |
+
if (value == null) return null;
|
| 64 |
+
const text = String(value).trim();
|
| 65 |
+
const lower = text.toLowerCase();
|
| 66 |
+
if (/^\d{4}$/.test(text)) return +text;
|
| 67 |
+
if (monthIndex[lower]) return monthIndex[lower];
|
| 68 |
+
if (seasonIndex[lower]) return seasonIndex[lower];
|
| 69 |
+
const parsed = Date.parse(text);
|
| 70 |
+
return Number.isNaN(parsed) ? null : parsed;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function isTemporalField(field, dataRows) {
|
| 74 |
+
const fieldName = String(field || "").toLowerCase();
|
| 75 |
+
if (/(year|date|month|season|time|quarter|week)/.test(fieldName)) return true;
|
| 76 |
+
const parsed = dataRows.map(d => temporalValue(d[field])).filter(v => v != null);
|
| 77 |
+
return parsed.length >= Math.max(3, Math.ceil(dataRows.length * 0.7));
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const chartData = rows
|
| 81 |
+
.filter(d => Number.isFinite(+d[valueField]))
|
| 82 |
+
.sort((a, b) => {
|
| 83 |
+
if (isTemporalField(dimensionField, rows)) {
|
| 84 |
+
const ta = temporalValue(a[dimensionField]);
|
| 85 |
+
const tb = temporalValue(b[dimensionField]);
|
| 86 |
+
if (ta != null && tb != null) return ta - tb;
|
| 87 |
+
}
|
| 88 |
+
return +b[valueField] - +a[valueField];
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
const totalItems = chartData.length;
|
| 92 |
+
if (!totalItems) return null;
|
| 93 |
+
|
| 94 |
+
const svg = d3.select(containerSelector)
|
| 95 |
+
.append("svg")
|
| 96 |
+
.attr("width", "100%")
|
| 97 |
+
.attr("height", height)
|
| 98 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 99 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 100 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 101 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 102 |
+
.attr("data-chart-variation", "circular_bar_chart_icons_01");
|
| 103 |
+
|
| 104 |
+
const defs = svg.append("defs");
|
| 105 |
+
|
| 106 |
+
const bgGrad = defs.append("linearGradient")
|
| 107 |
+
.attr("id", "circular-icons-bg")
|
| 108 |
+
.attr("x1", "0%").attr("y1", "0%")
|
| 109 |
+
.attr("x2", "0%").attr("y2", "100%");
|
| 110 |
+
bgGrad.append("stop").attr("offset", "0%").attr("stop-color", "#ffffff");
|
| 111 |
+
bgGrad.append("stop").attr("offset", "68%").attr("stop-color", "#fffdf8");
|
| 112 |
+
bgGrad.append("stop").attr("offset", "100%").attr("stop-color", "#f7efe3");
|
| 113 |
+
|
| 114 |
+
const markShadow = defs.append("filter")
|
| 115 |
+
.attr("id", "circular-icons-soft-shadow")
|
| 116 |
+
.attr("x", "-30%").attr("y", "-30%")
|
| 117 |
+
.attr("width", "160%").attr("height", "160%");
|
| 118 |
+
markShadow.append("feDropShadow")
|
| 119 |
+
.attr("dx", 0).attr("dy", 4).attr("stdDeviation", 5)
|
| 120 |
+
.attr("flood-color", "#8f4c34").attr("flood-opacity", 0.18);
|
| 121 |
+
|
| 122 |
+
const slotClipId = "circular-icons-header-slot-clip";
|
| 123 |
+
const headerSlotCx = width * 0.80;
|
| 124 |
+
const headerSlotCy = height * 0.185;
|
| 125 |
+
const headerSlotR = Math.min(width * 0.126, height * 0.086);
|
| 126 |
+
defs.append("clipPath")
|
| 127 |
+
.attr("id", slotClipId)
|
| 128 |
+
.append("circle")
|
| 129 |
+
.attr("cx", headerSlotCx)
|
| 130 |
+
.attr("cy", headerSlotCy)
|
| 131 |
+
.attr("r", headerSlotR);
|
| 132 |
+
|
| 133 |
+
svg.append("rect")
|
| 134 |
+
.attr("x", 0).attr("y", 0)
|
| 135 |
+
.attr("width", width).attr("height", height)
|
| 136 |
+
.attr("fill", "url(#circular-icons-bg)");
|
| 137 |
+
|
| 138 |
+
const titles = jsonData.titles || {};
|
| 139 |
+
const metadata = jsonData.metadata || {};
|
| 140 |
+
const rawTitle = String(titles.main_title || metadata.title || "Circular Bar Chart").trim();
|
| 141 |
+
const subtitle = String(
|
| 142 |
+
titles.sub_title ||
|
| 143 |
+
metadata.description ||
|
| 144 |
+
jsonData.description ||
|
| 145 |
+
""
|
| 146 |
+
).trim();
|
| 147 |
+
let headerLineOne = rawTitle;
|
| 148 |
+
let headerLineTwo = "";
|
| 149 |
+
const byMatch = rawTitle.match(/\s+by\s+/i);
|
| 150 |
+
if (byMatch) {
|
| 151 |
+
const splitAt = byMatch.index;
|
| 152 |
+
headerLineOne = rawTitle.slice(0, splitAt);
|
| 153 |
+
headerLineTwo = rawTitle.slice(splitAt).trim();
|
| 154 |
+
}
|
| 155 |
+
const headerX = width * 0.085;
|
| 156 |
+
const lineOneY = height * 0.085;
|
| 157 |
+
const lineTwoY = height * 0.165;
|
| 158 |
+
const lineOneFont = Math.min(30, Math.max(25, width * 0.044));
|
| 159 |
+
const lineTwoFont = Math.min(50, Math.max(40, width * 0.074));
|
| 160 |
+
|
| 161 |
+
function wrapWords(text, maxChars, maxLines) {
|
| 162 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 163 |
+
const lines = [];
|
| 164 |
+
let current = "";
|
| 165 |
+
words.forEach(word => {
|
| 166 |
+
const trial = current ? `${current} ${word}` : word;
|
| 167 |
+
if (current && trial.length > maxChars) {
|
| 168 |
+
lines.push(current);
|
| 169 |
+
current = word;
|
| 170 |
+
} else {
|
| 171 |
+
current = trial;
|
| 172 |
+
}
|
| 173 |
+
});
|
| 174 |
+
if (current) lines.push(current);
|
| 175 |
+
if (lines.length > maxLines) {
|
| 176 |
+
const kept = lines.slice(0, maxLines);
|
| 177 |
+
kept[maxLines - 1] = `${kept[maxLines - 1].replace(/[.。]+$/, "")}...`;
|
| 178 |
+
return kept;
|
| 179 |
+
}
|
| 180 |
+
return lines.length ? lines : [""];
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function fitHeaderText(text, maxWidth, fontSize, weight, letterSpacing) {
|
| 184 |
+
const clean = String(text || "");
|
| 185 |
+
const spacing = letterSpacing || 0;
|
| 186 |
+
const measuredWidth = value => chartUtils.text.measure(null, value, {
|
| 187 |
+
fontFamily: "Arial, Helvetica, sans-serif",
|
| 188 |
+
fontSize,
|
| 189 |
+
fontWeight: weight
|
| 190 |
+
}).width + Math.max(0, value.length - 1) * spacing;
|
| 191 |
+
if (measuredWidth(clean) <= maxWidth) return clean;
|
| 192 |
+
let out = clean;
|
| 193 |
+
while (out.length > 3 && measuredWidth(`${out}...`) > maxWidth) {
|
| 194 |
+
out = out.slice(0, -1);
|
| 195 |
+
}
|
| 196 |
+
return `${out.replace(/\s+$/, "")}...`;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
const headerLayer = svg.append("g")
|
| 200 |
+
.attr("class", "circular-icons-reference-header")
|
| 201 |
+
.attr("data-role", "title-subtitle-header")
|
| 202 |
+
.attr("data-anchor", "upper-left-page-safe-area")
|
| 203 |
+
.attr("data-collision-rule", "reserve top band before chart and left of header illustration slot");
|
| 204 |
+
|
| 205 |
+
headerLayer.append("text")
|
| 206 |
+
.attr("class", "title-line title-line-kicker")
|
| 207 |
+
.attr("x", headerX)
|
| 208 |
+
.attr("y", lineOneY)
|
| 209 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 210 |
+
.attr("font-size", lineOneFont)
|
| 211 |
+
.attr("font-weight", 900)
|
| 212 |
+
.attr("letter-spacing", 0.4)
|
| 213 |
+
.attr("fill", "#F2A51A")
|
| 214 |
+
.text(fitHeaderText(headerLineOne.toUpperCase(), width - headerX - width * 0.065, lineOneFont, 900, 0.4));
|
| 215 |
+
|
| 216 |
+
if (headerLineTwo) {
|
| 217 |
+
headerLayer.append("text")
|
| 218 |
+
.attr("class", "title-line title-line-main")
|
| 219 |
+
.attr("x", headerX)
|
| 220 |
+
.attr("y", lineTwoY)
|
| 221 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 222 |
+
.attr("font-size", lineTwoFont)
|
| 223 |
+
.attr("font-weight", 900)
|
| 224 |
+
.attr("letter-spacing", 1)
|
| 225 |
+
.attr("fill", "#D95A46")
|
| 226 |
+
.text(fitHeaderText(
|
| 227 |
+
headerLineTwo.toUpperCase(),
|
| 228 |
+
headerSlotCx - headerSlotR - headerX - width * 0.03,
|
| 229 |
+
lineTwoFont,
|
| 230 |
+
900,
|
| 231 |
+
1
|
| 232 |
+
));
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
const subtitleLines = wrapWords(subtitle, 42, 3);
|
| 236 |
+
const subtitleX = width * 0.115;
|
| 237 |
+
const subtitleY = height * 0.225;
|
| 238 |
+
const subtitleFont = Math.min(19, Math.max(15, width * 0.026));
|
| 239 |
+
headerLayer.append("rect")
|
| 240 |
+
.attr("class", "subtitle-accent")
|
| 241 |
+
.attr("x", headerX + 1)
|
| 242 |
+
.attr("y", subtitleY - subtitleFont * 0.92)
|
| 243 |
+
.attr("width", Math.max(4, width * 0.008))
|
| 244 |
+
.attr("height", subtitleLines.length * subtitleFont * 1.45 + 6)
|
| 245 |
+
.attr("fill", "#F2A51A");
|
| 246 |
+
const subtitleText = headerLayer.append("text")
|
| 247 |
+
.attr("class", "subtitle")
|
| 248 |
+
.attr("x", subtitleX)
|
| 249 |
+
.attr("y", subtitleY)
|
| 250 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 251 |
+
.attr("font-size", subtitleFont)
|
| 252 |
+
.attr("font-weight", 500)
|
| 253 |
+
.attr("fill", "#333333");
|
| 254 |
+
subtitleLines.forEach((line, index) => {
|
| 255 |
+
subtitleText.append("tspan")
|
| 256 |
+
.attr("x", subtitleX)
|
| 257 |
+
.attr("dy", index === 0 ? 0 : subtitleFont * 1.45)
|
| 258 |
+
.text(line);
|
| 259 |
+
});
|
| 260 |
+
|
| 261 |
+
const headerSlot = svg.append("g")
|
| 262 |
+
.attr("class", "asset-slot asset-slot-header-illustration")
|
| 263 |
+
.attr("data-asset-slot", "header-illustration")
|
| 264 |
+
.attr("data-bbox", `${headerSlotCx - headerSlotR},${headerSlotCy - headerSlotR},${headerSlotR * 2},${headerSlotR * 2}`)
|
| 265 |
+
.attr("data-anchor", "header-safe-area-upper-right")
|
| 266 |
+
.attr("data-collision-rule", "avoid-title-subtitle-chart-outer-labels-canvas-edge; downstream asset clipped to circular slot")
|
| 267 |
+
.attr("data-mask-rule", "circle-clip")
|
| 268 |
+
.attr("data-no-embedded-raster", "true")
|
| 269 |
+
.attr("data-no-copied-reference-art", "true");
|
| 270 |
+
|
| 271 |
+
headerSlot.append("circle")
|
| 272 |
+
.attr("cx", headerSlotCx)
|
| 273 |
+
.attr("cy", headerSlotCy)
|
| 274 |
+
.attr("r", headerSlotR)
|
| 275 |
+
.attr("fill", "#F8F1E7")
|
| 276 |
+
.attr("fill-opacity", 0.82)
|
| 277 |
+
.attr("stroke", "#D9C9B7")
|
| 278 |
+
.attr("stroke-width", 1.8)
|
| 279 |
+
.attr("stroke-dasharray", "8 7");
|
| 280 |
+
headerSlot.append("circle")
|
| 281 |
+
.attr("cx", headerSlotCx)
|
| 282 |
+
.attr("cy", headerSlotCy)
|
| 283 |
+
.attr("r", headerSlotR * 0.34)
|
| 284 |
+
.attr("fill", "none")
|
| 285 |
+
.attr("stroke", "#D9C9B7")
|
| 286 |
+
.attr("stroke-width", 1.2)
|
| 287 |
+
.attr("opacity", 0.78);
|
| 288 |
+
headerSlot.append("line")
|
| 289 |
+
.attr("x1", headerSlotCx - headerSlotR * 0.52)
|
| 290 |
+
.attr("y1", headerSlotCy)
|
| 291 |
+
.attr("x2", headerSlotCx + headerSlotR * 0.52)
|
| 292 |
+
.attr("y2", headerSlotCy)
|
| 293 |
+
.attr("stroke", "#D9C9B7")
|
| 294 |
+
.attr("stroke-width", 1.1)
|
| 295 |
+
.attr("opacity", 0.66);
|
| 296 |
+
headerSlot.append("text")
|
| 297 |
+
.attr("x", headerSlotCx)
|
| 298 |
+
.attr("y", headerSlotCy + headerSlotR * 0.66)
|
| 299 |
+
.attr("text-anchor", "middle")
|
| 300 |
+
.attr("font-family", fontFamily)
|
| 301 |
+
.attr("font-size", Math.max(9, width * 0.017))
|
| 302 |
+
.attr("fill", "#9c8b7a")
|
| 303 |
+
.text("asset slot");
|
| 304 |
+
|
| 305 |
+
const centerX = width * 0.5;
|
| 306 |
+
const centerY = height * 0.625;
|
| 307 |
+
const radius = Math.min(width * 0.365, height * 0.245);
|
| 308 |
+
const innerRadius = radius * 0.39;
|
| 309 |
+
const maxValue = d3.max(chartData, d => +d[valueField]) || 1;
|
| 310 |
+
const radiusScale = d3.scaleLinear()
|
| 311 |
+
.domain([0, maxValue])
|
| 312 |
+
.range([innerRadius + radius * 0.14, radius]);
|
| 313 |
+
const anglePerItem = (2 * Math.PI) / totalItems;
|
| 314 |
+
|
| 315 |
+
const highColor = "#bc3f31";
|
| 316 |
+
const midColor = "#ef6d3f";
|
| 317 |
+
const lowColor = "#f6b64b";
|
| 318 |
+
const fillScale = d3.scaleLinear()
|
| 319 |
+
.domain([0, Math.max(1, totalItems / 2), Math.max(1, totalItems - 1)])
|
| 320 |
+
.range([highColor, midColor, lowColor]);
|
| 321 |
+
|
| 322 |
+
const leaderLayer = svg.append("g").attr("class", "leader-layer");
|
| 323 |
+
const chartLayer = svg.append("g")
|
| 324 |
+
.attr("class", "radial-chart-layer")
|
| 325 |
+
.attr("transform", `translate(${centerX}, ${centerY})`);
|
| 326 |
+
const leaderDotLayer = svg.append("g").attr("class", "leader-dot-layer");
|
| 327 |
+
const labelLayer = svg.append("g").attr("class", "outer-label-layer");
|
| 328 |
+
const valueLayer = svg.append("g")
|
| 329 |
+
.attr("class", "value-label-layer")
|
| 330 |
+
.attr("transform", `translate(${centerX}, ${centerY})`);
|
| 331 |
+
|
| 332 |
+
const arcGenerator = d3.arc()
|
| 333 |
+
.innerRadius(innerRadius)
|
| 334 |
+
.padAngle(Math.min(0.025, 0.18 / totalItems))
|
| 335 |
+
.padRadius(innerRadius)
|
| 336 |
+
.cornerRadius(Math.max(4, width * 0.012));
|
| 337 |
+
|
| 338 |
+
const itemMeta = chartData.map((d, i) => {
|
| 339 |
+
const value = +d[valueField];
|
| 340 |
+
const startAngle = i * anglePerItem;
|
| 341 |
+
const endAngle = startAngle + anglePerItem;
|
| 342 |
+
const midAngle = startAngle + anglePerItem / 2;
|
| 343 |
+
const outerRadius = radiusScale(value);
|
| 344 |
+
const x = Math.sin(midAngle);
|
| 345 |
+
const y = -Math.cos(midAngle);
|
| 346 |
+
return {
|
| 347 |
+
d, i, value, startAngle, endAngle, midAngle, outerRadius,
|
| 348 |
+
side: x >= 0 ? "right" : "left",
|
| 349 |
+
markX: centerX + x * outerRadius,
|
| 350 |
+
markY: centerY + y * outerRadius,
|
| 351 |
+
rawLabelY: centerY + y * (radius + width * 0.09)
|
| 352 |
+
};
|
| 353 |
+
});
|
| 354 |
+
|
| 355 |
+
function resolveLabelPositions(side) {
|
| 356 |
+
const sideItems = itemMeta
|
| 357 |
+
.filter(m => m.side === side)
|
| 358 |
+
.sort((a, b) => a.rawLabelY - b.rawLabelY);
|
| 359 |
+
const minY = height * 0.36;
|
| 360 |
+
const maxY = height * 0.90;
|
| 361 |
+
const gap = Math.max(42, width * 0.072);
|
| 362 |
+
let y = minY;
|
| 363 |
+
sideItems.forEach(m => {
|
| 364 |
+
m.labelY = Math.max(m.rawLabelY, y);
|
| 365 |
+
y = m.labelY + gap;
|
| 366 |
+
});
|
| 367 |
+
const overflow = sideItems.length ? sideItems[sideItems.length - 1].labelY - maxY : 0;
|
| 368 |
+
if (overflow > 0) {
|
| 369 |
+
for (let i = sideItems.length - 1; i >= 0; i--) {
|
| 370 |
+
const nextY = i === sideItems.length - 1 ? maxY : sideItems[i + 1].labelY - gap;
|
| 371 |
+
sideItems[i].labelY = Math.min(sideItems[i].labelY - overflow, nextY);
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
resolveLabelPositions("left");
|
| 376 |
+
resolveLabelPositions("right");
|
| 377 |
+
|
| 378 |
+
chartLayer.selectAll("path.radial-bar")
|
| 379 |
+
.data(itemMeta)
|
| 380 |
+
.enter()
|
| 381 |
+
.append("path")
|
| 382 |
+
.attr("class", "radial-bar")
|
| 383 |
+
.attr("data-category", m => m.d[dimensionField])
|
| 384 |
+
.attr("data-value", m => m.value)
|
| 385 |
+
.attr("d", m => arcGenerator({
|
| 386 |
+
innerRadius,
|
| 387 |
+
outerRadius: m.outerRadius,
|
| 388 |
+
startAngle: m.startAngle,
|
| 389 |
+
endAngle: m.endAngle
|
| 390 |
+
}))
|
| 391 |
+
.attr("fill", m => fillScale(m.i))
|
| 392 |
+
.attr("stroke", "#fffaf1")
|
| 393 |
+
.attr("stroke-width", Math.max(3, width * 0.009))
|
| 394 |
+
.attr("filter", "url(#circular-icons-soft-shadow)");
|
| 395 |
+
|
| 396 |
+
itemMeta.forEach(m => {
|
| 397 |
+
const valueText = chartUtils.format.autoText(m.value, { unit: valueUnit });
|
| 398 |
+
const midRadius = Math.max(innerRadius + 18, Math.min(m.outerRadius - 18, (innerRadius + m.outerRadius) / 2 + 8));
|
| 399 |
+
const x = Math.sin(m.midAngle) * midRadius;
|
| 400 |
+
const y = -Math.cos(m.midAngle) * midRadius + 4;
|
| 401 |
+
const arcAvailable = Math.max(24, midRadius * anglePerItem * 0.64);
|
| 402 |
+
const radialAvailable = Math.max(30, (m.outerRadius - innerRadius) * 0.78);
|
| 403 |
+
const available = Math.min(arcAvailable, radialAvailable);
|
| 404 |
+
let fontSize = Math.min(27, Math.max(11, width * 0.043));
|
| 405 |
+
let measured = chartUtils.text.measure(null, valueText, {
|
| 406 |
+
fontFamily,
|
| 407 |
+
fontSize,
|
| 408 |
+
fontWeight: 800
|
| 409 |
+
}).width;
|
| 410 |
+
while (measured > available && fontSize > 8) {
|
| 411 |
+
fontSize -= 1;
|
| 412 |
+
measured = chartUtils.text.measure(null, valueText, {
|
| 413 |
+
fontFamily,
|
| 414 |
+
fontSize,
|
| 415 |
+
fontWeight: 800
|
| 416 |
+
}).width;
|
| 417 |
+
}
|
| 418 |
+
const fill = (m.outerRadius - innerRadius) < 42 && m.i > totalItems * 0.55 ? "#3f3b37" : "#ffffff";
|
| 419 |
+
valueLayer.append("text")
|
| 420 |
+
.attr("class", "value-label")
|
| 421 |
+
.attr("data-category", m.d[dimensionField])
|
| 422 |
+
.attr("x", x)
|
| 423 |
+
.attr("y", y)
|
| 424 |
+
.attr("text-anchor", "middle")
|
| 425 |
+
.attr("dominant-baseline", "middle")
|
| 426 |
+
.attr("font-family", fontFamily)
|
| 427 |
+
.attr("font-size", fontSize)
|
| 428 |
+
.attr("font-weight", 800)
|
| 429 |
+
.attr("fill", fill)
|
| 430 |
+
.text(valueText);
|
| 431 |
+
});
|
| 432 |
+
|
| 433 |
+
svg.append("circle")
|
| 434 |
+
.attr("class", "center-hub")
|
| 435 |
+
.attr("cx", centerX)
|
| 436 |
+
.attr("cy", centerY)
|
| 437 |
+
.attr("r", innerRadius + 4)
|
| 438 |
+
.attr("fill", "#fffdf8")
|
| 439 |
+
.attr("stroke", "#ead9c4")
|
| 440 |
+
.attr("stroke-width", 2)
|
| 441 |
+
.attr("filter", "url(#circular-icons-soft-shadow)");
|
| 442 |
+
|
| 443 |
+
const hub = svg.append("g")
|
| 444 |
+
.attr("class", "center-hub-neutral-glyph")
|
| 445 |
+
.attr("transform", `translate(${centerX}, ${centerY})`)
|
| 446 |
+
.attr("data-asset-policy", "reusable-chart-native-neutral-glyph");
|
| 447 |
+
const glyphColor = "#e6cba7";
|
| 448 |
+
hub.append("circle").attr("cx", 0).attr("cy", -14).attr("r", 10).attr("fill", glyphColor);
|
| 449 |
+
hub.append("circle").attr("cx", -20).attr("cy", -10).attr("r", 8).attr("fill", glyphColor).attr("opacity", 0.85);
|
| 450 |
+
hub.append("circle").attr("cx", 20).attr("cy", -10).attr("r", 8).attr("fill", glyphColor).attr("opacity", 0.85);
|
| 451 |
+
hub.append("path").attr("d", "M-22,28c0,-19 44,-19 44,0v7h-44z").attr("fill", glyphColor);
|
| 452 |
+
hub.append("path").attr("d", "M-43,30c1,-16 24,-18 32,-6v11h-32z").attr("fill", glyphColor).attr("opacity", 0.82);
|
| 453 |
+
hub.append("path").attr("d", "M11,24c8,-12 31,-10 32,6v5h-32z").attr("fill", glyphColor).attr("opacity", 0.82);
|
| 454 |
+
|
| 455 |
+
function textWidth(text, fontSize, weight) {
|
| 456 |
+
return chartUtils.text.measure(null, text, {
|
| 457 |
+
fontFamily,
|
| 458 |
+
fontSize,
|
| 459 |
+
fontWeight: weight || 500
|
| 460 |
+
}).width;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
function truncateText(text, maxWidth, fontSize, weight) {
|
| 464 |
+
const clean = String(text == null ? "" : text);
|
| 465 |
+
if (textWidth(clean, fontSize, weight) <= maxWidth) return clean;
|
| 466 |
+
let out = clean;
|
| 467 |
+
while (out.length > 2 && textWidth(`${out}...`, fontSize, weight) > maxWidth) {
|
| 468 |
+
out = out.slice(0, -1);
|
| 469 |
+
}
|
| 470 |
+
return `${out}...`;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
itemMeta.forEach((m, idx) => {
|
| 474 |
+
const category = String(m.d[dimensionField]);
|
| 475 |
+
const valueText = chartUtils.format.autoText(m.value, { unit: valueUnit });
|
| 476 |
+
const sideSign = m.side === "right" ? 1 : -1;
|
| 477 |
+
const iconSize = Math.max(24, Math.min(34, width * (totalItems > 12 ? 0.048 : 0.054)));
|
| 478 |
+
const topRightReferenceLabel = m.side === "right" && m.rawLabelY < centerY - radius * 0.9;
|
| 479 |
+
const radialIconX = centerX + Math.sin(m.midAngle) * (radius + width * 0.08);
|
| 480 |
+
const iconX = topRightReferenceLabel
|
| 481 |
+
? Math.max(width * 0.28, Math.min(width * 0.42, radialIconX))
|
| 482 |
+
: (m.side === "right" ? width - Math.max(108, width * 0.17) : 48);
|
| 483 |
+
const endpointX = m.markX + Math.sin(m.midAngle) * 6;
|
| 484 |
+
const endpointY = m.markY - Math.cos(m.midAngle) * 6;
|
| 485 |
+
const iconY = m.labelY;
|
| 486 |
+
const textX = iconX + iconSize * 0.68;
|
| 487 |
+
const categoryFontSize = Math.max(10, Math.min(14, width * 0.023));
|
| 488 |
+
const valueFontSize = Math.max(10, Math.min(13, width * 0.021));
|
| 489 |
+
const localSafeGap = Math.max(16, iconSize * 0.52);
|
| 490 |
+
const sideColumnOuterGap = Math.max(10, width * 0.018);
|
| 491 |
+
const rightColumnMax = width - textX - sideColumnOuterGap;
|
| 492 |
+
const leftEndpointMax = endpointX - textX - localSafeGap;
|
| 493 |
+
const leftGlobalMax = centerX - radius * 0.58 - textX;
|
| 494 |
+
const maxTextWidth = m.side === "right"
|
| 495 |
+
? Math.max(46, Math.min(132, rightColumnMax))
|
| 496 |
+
: Math.max(46, Math.min(116, leftEndpointMax, leftGlobalMax));
|
| 497 |
+
const categoryText = truncateText(category, maxTextWidth, categoryFontSize, 700);
|
| 498 |
+
const categoryTextWidth = Math.min(maxTextWidth, textWidth(categoryText, categoryFontSize, 700));
|
| 499 |
+
const labelBoxLeft = m.side === "right" ? iconX - iconSize / 2 - 4 : iconX - iconSize / 2 - 4;
|
| 500 |
+
const labelBoxRight = m.side === "right"
|
| 501 |
+
? Math.min(width - sideColumnOuterGap, Math.max(textX + categoryTextWidth, textX + 24))
|
| 502 |
+
: Math.min(endpointX - localSafeGap, textX + categoryTextWidth);
|
| 503 |
+
const leaderEndX = m.side === "right"
|
| 504 |
+
? Math.max(iconX - iconSize * 0.62, Math.min(labelBoxLeft, width - sideColumnOuterGap))
|
| 505 |
+
: Math.min(iconX + iconSize * 0.62, labelBoxRight);
|
| 506 |
+
const c1X = endpointX + sideSign * width * 0.05;
|
| 507 |
+
const c2X = leaderEndX - sideSign * width * 0.05;
|
| 508 |
+
|
| 509 |
+
leaderLayer.append("path")
|
| 510 |
+
.attr("class", "outer-label-leader")
|
| 511 |
+
.attr("data-category", category)
|
| 512 |
+
.attr("d", `M${endpointX},${endpointY} C${c1X},${endpointY} ${c2X},${iconY} ${leaderEndX},${iconY}`)
|
| 513 |
+
.attr("fill", "none")
|
| 514 |
+
.attr("stroke", "#b8afa5")
|
| 515 |
+
.attr("stroke-width", 1.5)
|
| 516 |
+
.attr("stroke-linecap", "round")
|
| 517 |
+
.attr("stroke-dasharray", "2 5");
|
| 518 |
+
leaderDotLayer.append("circle")
|
| 519 |
+
.attr("cx", endpointX)
|
| 520 |
+
.attr("cy", endpointY)
|
| 521 |
+
.attr("r", 2.7)
|
| 522 |
+
.attr("fill", fillScale(idx))
|
| 523 |
+
.attr("stroke", "#ffffff")
|
| 524 |
+
.attr("stroke-width", 1);
|
| 525 |
+
|
| 526 |
+
const labelGroup = labelLayer.append("g")
|
| 527 |
+
.attr("class", "outer-label")
|
| 528 |
+
.attr("data-category", category)
|
| 529 |
+
.attr("transform", `translate(${iconX}, ${iconY})`);
|
| 530 |
+
|
| 531 |
+
labelGroup.append("circle")
|
| 532 |
+
.attr("class", "icon-badge")
|
| 533 |
+
.attr("r", iconSize / 2 + 3)
|
| 534 |
+
.attr("fill", "#fffaf2")
|
| 535 |
+
.attr("stroke", "#ead8c4")
|
| 536 |
+
.attr("stroke-width", 1.2);
|
| 537 |
+
|
| 538 |
+
const iconUrl = images && images.field ? images.field[category] : null;
|
| 539 |
+
if (iconUrl) {
|
| 540 |
+
const clipId = `circular-icons-field-icon-${idx}`;
|
| 541 |
+
defs.append("clipPath")
|
| 542 |
+
.attr("id", clipId)
|
| 543 |
+
.append("circle")
|
| 544 |
+
.attr("cx", 0)
|
| 545 |
+
.attr("cy", 0)
|
| 546 |
+
.attr("r", iconSize / 2);
|
| 547 |
+
labelGroup.append("image")
|
| 548 |
+
.attr("class", "input-field-icon")
|
| 549 |
+
.attr("data-asset-source", "input-field-icon")
|
| 550 |
+
.attr("data-category", category)
|
| 551 |
+
.attr("data-no-copied-reference-art", "true")
|
| 552 |
+
.attr("xlink:href", iconUrl)
|
| 553 |
+
.attr("href", iconUrl)
|
| 554 |
+
.attr("x", -iconSize / 2)
|
| 555 |
+
.attr("y", -iconSize / 2)
|
| 556 |
+
.attr("width", iconSize)
|
| 557 |
+
.attr("height", iconSize)
|
| 558 |
+
.attr("clip-path", `url(#${clipId})`)
|
| 559 |
+
.attr("preserveAspectRatio", "xMidYMid slice");
|
| 560 |
+
} else {
|
| 561 |
+
labelGroup.append("text")
|
| 562 |
+
.attr("text-anchor", "middle")
|
| 563 |
+
.attr("dominant-baseline", "middle")
|
| 564 |
+
.attr("font-family", fontFamily)
|
| 565 |
+
.attr("font-size", iconSize * 0.44)
|
| 566 |
+
.attr("font-weight", 800)
|
| 567 |
+
.attr("fill", "#cc5a38")
|
| 568 |
+
.text(category.slice(0, 1).toUpperCase());
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
labelLayer.append("text")
|
| 572 |
+
.attr("class", "outer-category-label")
|
| 573 |
+
.attr("data-category", category)
|
| 574 |
+
.attr("x", textX)
|
| 575 |
+
.attr("y", iconY - 4)
|
| 576 |
+
.attr("text-anchor", "start")
|
| 577 |
+
.attr("font-family", fontFamily)
|
| 578 |
+
.attr("font-size", categoryFontSize)
|
| 579 |
+
.attr("font-weight", 700)
|
| 580 |
+
.attr("fill", "#2f2b28")
|
| 581 |
+
.attr("data-label-max-width", maxTextWidth.toFixed(1))
|
| 582 |
+
.attr("data-label-safe-boundary-x", (m.side === "right" ? width - sideColumnOuterGap : endpointX - localSafeGap).toFixed(1))
|
| 583 |
+
.text(categoryText);
|
| 584 |
+
|
| 585 |
+
labelLayer.append("text")
|
| 586 |
+
.attr("class", "outer-value-label")
|
| 587 |
+
.attr("data-category", category)
|
| 588 |
+
.attr("x", textX)
|
| 589 |
+
.attr("y", iconY + 14)
|
| 590 |
+
.attr("text-anchor", "start")
|
| 591 |
+
.attr("font-family", fontFamily)
|
| 592 |
+
.attr("font-size", valueFontSize)
|
| 593 |
+
.attr("font-weight", 800)
|
| 594 |
+
.attr("fill", fillScale(Math.max(0, idx - 1)))
|
| 595 |
+
.text(valueText);
|
| 596 |
+
});
|
| 597 |
+
|
| 598 |
+
return svg.node();
|
| 599 |
+
}
|
modules/chart_engine/template/d3-js/type10_circular_bar_chart/circular_bar_chart_icons_02.js
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Circular Bar Chart",
|
| 5 |
+
"chart_name": "circular_bar_chart_icons_02",
|
| 6 |
+
"required_fields": ["x", "y"],
|
| 7 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 8 |
+
"required_fields_range": [[15, 30], [0, 100]],
|
| 9 |
+
"required_fields_icons": ["x"],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": [],
|
| 12 |
+
"required_other_colors": ["primary", "secondary", "background"],
|
| 13 |
+
"supported_effects": ["gradient", "opacity"],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"has_title": 1,
|
| 17 |
+
"background": "light",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "none",
|
| 20 |
+
"has_x_axis": "yes",
|
| 21 |
+
"has_y_axis": "yes"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data;
|
| 28 |
+
const rows = (jsonData.data && jsonData.data.data ? jsonData.data.data : []).slice();
|
| 29 |
+
const variables = jsonData.variables || {};
|
| 30 |
+
const typography = jsonData.typography || {};
|
| 31 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 32 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 33 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 34 |
+
const images = jsonData.images || { field: {}, other: {} };
|
| 35 |
+
|
| 36 |
+
d3.select(containerSelector).html("");
|
| 37 |
+
if (!rows.length) return null;
|
| 38 |
+
|
| 39 |
+
const monthIndex = {
|
| 40 |
+
jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3,
|
| 41 |
+
apr: 4, april: 4, may: 5, jun: 6, june: 6, jul: 7, july: 7,
|
| 42 |
+
aug: 8, august: 8, sep: 9, sept: 9, september: 9, oct: 10,
|
| 43 |
+
october: 10, nov: 11, november: 11, dec: 12, december: 12
|
| 44 |
+
};
|
| 45 |
+
const seasonIndex = { winter: 1, spring: 2, summer: 3, fall: 4, autumn: 4 };
|
| 46 |
+
|
| 47 |
+
function temporalValue(value) {
|
| 48 |
+
if (value == null) return null;
|
| 49 |
+
const text = String(value).trim();
|
| 50 |
+
const lower = text.toLowerCase();
|
| 51 |
+
if (/^\d{4}$/.test(text)) return +text;
|
| 52 |
+
if (monthIndex[lower]) return monthIndex[lower];
|
| 53 |
+
if (seasonIndex[lower]) return seasonIndex[lower];
|
| 54 |
+
const parsed = Date.parse(text);
|
| 55 |
+
return Number.isNaN(parsed) ? null : parsed;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function isTemporalField(field, dataRows) {
|
| 59 |
+
const fieldName = String(field || "").toLowerCase();
|
| 60 |
+
if (/(year|date|month|season|time|quarter|week)/.test(fieldName)) return true;
|
| 61 |
+
const parsed = dataRows.map(d => temporalValue(d[field])).filter(v => v != null);
|
| 62 |
+
return parsed.length >= Math.max(3, Math.ceil(dataRows.length * 0.7));
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const isTemporal = isTemporalField(xField, rows);
|
| 66 |
+
const chartData = rows
|
| 67 |
+
.filter(d => Number.isFinite(+d[yField]))
|
| 68 |
+
.sort((a, b) => {
|
| 69 |
+
if (isTemporal) {
|
| 70 |
+
const ta = temporalValue(a[xField]);
|
| 71 |
+
const tb = temporalValue(b[xField]);
|
| 72 |
+
if (ta != null && tb != null) return ta - tb;
|
| 73 |
+
}
|
| 74 |
+
return +b[yField] - +a[yField];
|
| 75 |
+
});
|
| 76 |
+
if (!chartData.length) return null;
|
| 77 |
+
|
| 78 |
+
const width = Math.max(800, +variables.width || 800);
|
| 79 |
+
const height = Math.max(1200, +variables.height || 1200);
|
| 80 |
+
const fontFamily = (typography.label && typography.label.font_family) || "Arial, Helvetica, sans-serif";
|
| 81 |
+
const maxValue = d3.max(chartData, d => +d[yField]) || 1;
|
| 82 |
+
|
| 83 |
+
function fmtValue(value) {
|
| 84 |
+
return chartUtils.format.number(value).text;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function wrapWords(text, maxChars, maxLines) {
|
| 88 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 89 |
+
const lines = [];
|
| 90 |
+
let current = "";
|
| 91 |
+
words.forEach(word => {
|
| 92 |
+
const trial = current ? `${current} ${word}` : word;
|
| 93 |
+
if (current && trial.length > maxChars) {
|
| 94 |
+
lines.push(current);
|
| 95 |
+
current = word;
|
| 96 |
+
} else {
|
| 97 |
+
current = trial;
|
| 98 |
+
}
|
| 99 |
+
});
|
| 100 |
+
if (current) lines.push(current);
|
| 101 |
+
if (lines.length > maxLines) {
|
| 102 |
+
const kept = lines.slice(0, maxLines);
|
| 103 |
+
kept[maxLines - 1] = `${kept[maxLines - 1].replace(/[.。]+$/, "")}...`;
|
| 104 |
+
return kept;
|
| 105 |
+
}
|
| 106 |
+
return lines;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function shorten(text, maxChars) {
|
| 110 |
+
const clean = String(text == null ? "" : text);
|
| 111 |
+
if (clean.length <= maxChars) return clean;
|
| 112 |
+
return `${clean.slice(0, Math.max(1, maxChars - 3)).replace(/\s+$/, "")}...`;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function compactCategory(text) {
|
| 116 |
+
const clean = String(text == null ? "" : text).trim();
|
| 117 |
+
const map = {
|
| 118 |
+
"Czech Republic": "Czech",
|
| 119 |
+
"United Kingdom": "UK",
|
| 120 |
+
"Netherlands": "Neth.",
|
| 121 |
+
"Germany": "Ger.",
|
| 122 |
+
"Denmark": "Den.",
|
| 123 |
+
"Belgium": "Bel.",
|
| 124 |
+
"France": "Fra.",
|
| 125 |
+
"Ireland": "Ire.",
|
| 126 |
+
"Romania": "Rom.",
|
| 127 |
+
"Greece": "Gre.",
|
| 128 |
+
"Hungary": "Hun.",
|
| 129 |
+
"Poland": "Pol.",
|
| 130 |
+
"Slovakia": "Svk.",
|
| 131 |
+
"Portugal": "Por.",
|
| 132 |
+
"Sweden": "Swe.",
|
| 133 |
+
"Spain": "Spa.",
|
| 134 |
+
"Italy": "Ita."
|
| 135 |
+
};
|
| 136 |
+
if (map[clean]) return map[clean];
|
| 137 |
+
return shorten(clean, 5);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const svg = d3.select(containerSelector)
|
| 141 |
+
.append("svg")
|
| 142 |
+
.attr("width", "100%")
|
| 143 |
+
.attr("height", height)
|
| 144 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 145 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 146 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 147 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 148 |
+
.attr("data-chart-variation", "circular_bar_chart_icons_02");
|
| 149 |
+
|
| 150 |
+
const defs = svg.append("defs");
|
| 151 |
+
|
| 152 |
+
const bgGradient = defs.append("linearGradient")
|
| 153 |
+
.attr("id", "cbc2-page-bg")
|
| 154 |
+
.attr("x1", "0%").attr("y1", "0%")
|
| 155 |
+
.attr("x2", "100%").attr("y2", "100%");
|
| 156 |
+
bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#f8fbfb");
|
| 157 |
+
bgGradient.append("stop").attr("offset", "52%").attr("stop-color", "#eef8fb");
|
| 158 |
+
bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#d9f0f6");
|
| 159 |
+
|
| 160 |
+
const panelGradient = defs.append("linearGradient")
|
| 161 |
+
.attr("id", "cbc2-title-panel")
|
| 162 |
+
.attr("x1", "0%").attr("y1", "0%")
|
| 163 |
+
.attr("x2", "100%").attr("y2", "100%");
|
| 164 |
+
panelGradient.append("stop").attr("offset", "0%").attr("stop-color", "#0f8b6a");
|
| 165 |
+
panelGradient.append("stop").attr("offset", "58%").attr("stop-color", "#006e5d");
|
| 166 |
+
panelGradient.append("stop").attr("offset", "100%").attr("stop-color", "#004f47");
|
| 167 |
+
|
| 168 |
+
const softShadow = defs.append("filter")
|
| 169 |
+
.attr("id", "cbc2-soft-shadow")
|
| 170 |
+
.attr("x", "-30%").attr("y", "-30%")
|
| 171 |
+
.attr("width", "160%").attr("height", "160%");
|
| 172 |
+
softShadow.append("feDropShadow")
|
| 173 |
+
.attr("dx", 0).attr("dy", 5).attr("stdDeviation", 5)
|
| 174 |
+
.attr("flood-color", "#0a5146").attr("flood-opacity", 0.22);
|
| 175 |
+
|
| 176 |
+
svg.append("rect")
|
| 177 |
+
.attr("width", width)
|
| 178 |
+
.attr("height", height)
|
| 179 |
+
.attr("fill", "url(#cbc2-page-bg)");
|
| 180 |
+
|
| 181 |
+
svg.append("circle")
|
| 182 |
+
.attr("cx", width * 0.45)
|
| 183 |
+
.attr("cy", height * 0.47)
|
| 184 |
+
.attr("r", height * 0.43)
|
| 185 |
+
.attr("fill", "#e6f3ea")
|
| 186 |
+
.attr("opacity", 0.56);
|
| 187 |
+
|
| 188 |
+
const assetSlot = svg.append("g")
|
| 189 |
+
.attr("class", "asset-slot asset-slot-bottom-vehicle-city")
|
| 190 |
+
.attr("data-asset-slot", "bottom-vehicle-city-illustration")
|
| 191 |
+
.attr("data-bbox", `${width * 0.02},${height * 0.835},${width * 0.36},${height * 0.085}`)
|
| 192 |
+
.attr("data-anchor", "lower-left-under-circular-bars")
|
| 193 |
+
.attr("data-collision-rule", "avoid-bars-icons-rank-badges-inline-labels-title-panel-footer-free-bottom")
|
| 194 |
+
.attr("data-mask-rule", "soft-landscape-rectangle")
|
| 195 |
+
.attr("data-no-embedded-raster", "true")
|
| 196 |
+
.attr("data-no-copied-reference-art", "true");
|
| 197 |
+
assetSlot.append("rect")
|
| 198 |
+
.attr("x", width * 0.02)
|
| 199 |
+
.attr("y", height * 0.835)
|
| 200 |
+
.attr("width", width * 0.36)
|
| 201 |
+
.attr("height", height * 0.085)
|
| 202 |
+
.attr("rx", 22)
|
| 203 |
+
.attr("fill", "#c8eadb")
|
| 204 |
+
.attr("opacity", 0.32);
|
| 205 |
+
assetSlot.append("path")
|
| 206 |
+
.attr("d", `M0,${height * 0.935} C${width * 0.12},${height * 0.89} ${width * 0.24},${height * 0.95} ${width * 0.39},${height * 0.90} L${width * 0.39},${height} L0,${height} Z`)
|
| 207 |
+
.attr("fill", "#8bcc9d")
|
| 208 |
+
.attr("opacity", 0.42);
|
| 209 |
+
assetSlot.append("text")
|
| 210 |
+
.attr("x", width * 0.13)
|
| 211 |
+
.attr("y", height * 0.89)
|
| 212 |
+
.attr("font-family", fontFamily)
|
| 213 |
+
.attr("font-size", 14)
|
| 214 |
+
.attr("fill", "#4f8f78")
|
| 215 |
+
.attr("opacity", 0.72)
|
| 216 |
+
.text("asset slot");
|
| 217 |
+
|
| 218 |
+
const centerX = width * 0.62;
|
| 219 |
+
const centerY = height * 0.515;
|
| 220 |
+
const backgroundRadius = height * 0.385;
|
| 221 |
+
const panelRadius = height * 0.18;
|
| 222 |
+
const panelRight = width * 1.04;
|
| 223 |
+
const panelTop = centerY - panelRadius;
|
| 224 |
+
const panelBottom = centerY + panelRadius;
|
| 225 |
+
const panelPath = `
|
| 226 |
+
M ${centerX},${panelTop}
|
| 227 |
+
L ${panelRight},${panelTop}
|
| 228 |
+
L ${panelRight},${panelBottom}
|
| 229 |
+
L ${centerX},${panelBottom}
|
| 230 |
+
A ${panelRadius},${panelRadius} 0 0,1 ${centerX},${panelTop}
|
| 231 |
+
Z
|
| 232 |
+
`;
|
| 233 |
+
|
| 234 |
+
svg.append("path")
|
| 235 |
+
.attr("class", "title-panel")
|
| 236 |
+
.attr("d", panelPath)
|
| 237 |
+
.attr("fill", "url(#cbc2-title-panel)")
|
| 238 |
+
.attr("filter", "url(#cbc2-soft-shadow)")
|
| 239 |
+
.attr("data-anchor", "right-panel-tangent-to-inner-rank-arc");
|
| 240 |
+
|
| 241 |
+
const startAngle = Math.PI / 2;
|
| 242 |
+
const endAngle = -Math.PI / 2;
|
| 243 |
+
const angleStep = chartData.length > 1 ? (startAngle - endAngle) / (chartData.length - 1) : 0;
|
| 244 |
+
const barWidth = Math.max(21, width * 0.036);
|
| 245 |
+
const innerRadius = panelRadius * 0.98;
|
| 246 |
+
const barScale = d3.scaleLinear()
|
| 247 |
+
.domain([0, maxValue])
|
| 248 |
+
.range([0, backgroundRadius - innerRadius - width * 0.09]);
|
| 249 |
+
const rankRadius = innerRadius + 12;
|
| 250 |
+
|
| 251 |
+
chartData.forEach((d, i) => {
|
| 252 |
+
const angle = startAngle - i * angleStep;
|
| 253 |
+
const gradientId = `cbc2-bar-gradient-${i}`;
|
| 254 |
+
const x1 = centerX - innerRadius * Math.cos(angle);
|
| 255 |
+
const y1 = centerY - innerRadius * Math.sin(angle);
|
| 256 |
+
const x2 = centerX - (innerRadius + barScale(+d[yField])) * Math.cos(angle);
|
| 257 |
+
const y2 = centerY - (innerRadius + barScale(+d[yField])) * Math.sin(angle);
|
| 258 |
+
const gradient = defs.append("linearGradient")
|
| 259 |
+
.attr("id", gradientId)
|
| 260 |
+
.attr("gradientUnits", "userSpaceOnUse")
|
| 261 |
+
.attr("x1", x1).attr("y1", y1)
|
| 262 |
+
.attr("x2", x2).attr("y2", y2);
|
| 263 |
+
gradient.append("stop").attr("offset", "0%").attr("stop-color", "#aee2b2");
|
| 264 |
+
gradient.append("stop").attr("offset", "48%").attr("stop-color", "#4cc481");
|
| 265 |
+
gradient.append("stop").attr("offset", "100%").attr("stop-color", i < 4 ? "#006e61" : "#0d9f70");
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
const chartLayer = svg.append("g").attr("class", "semicircular-bar-layer");
|
| 269 |
+
|
| 270 |
+
chartData.forEach((d, i) => {
|
| 271 |
+
const angle = startAngle - i * angleStep;
|
| 272 |
+
const trackStartX = centerX - innerRadius * Math.cos(angle);
|
| 273 |
+
const trackStartY = centerY - innerRadius * Math.sin(angle);
|
| 274 |
+
const trackEndX = centerX - (backgroundRadius - width * 0.04) * Math.cos(angle);
|
| 275 |
+
const trackEndY = centerY - (backgroundRadius - width * 0.04) * Math.sin(angle);
|
| 276 |
+
chartLayer.append("line")
|
| 277 |
+
.attr("class", "bar-track")
|
| 278 |
+
.attr("x1", trackStartX).attr("y1", trackStartY)
|
| 279 |
+
.attr("x2", trackEndX).attr("y2", trackEndY)
|
| 280 |
+
.attr("stroke", "#dfe8df")
|
| 281 |
+
.attr("stroke-width", barWidth)
|
| 282 |
+
.attr("stroke-linecap", "round")
|
| 283 |
+
.attr("opacity", 0.78);
|
| 284 |
+
});
|
| 285 |
+
|
| 286 |
+
chartData.forEach((d, i) => {
|
| 287 |
+
const angle = startAngle - i * angleStep;
|
| 288 |
+
const value = +d[yField];
|
| 289 |
+
const barLength = barScale(value);
|
| 290 |
+
const x1 = centerX - innerRadius * Math.cos(angle);
|
| 291 |
+
const y1 = centerY - innerRadius * Math.sin(angle);
|
| 292 |
+
const x2 = centerX - (innerRadius + barLength) * Math.cos(angle);
|
| 293 |
+
const y2 = centerY - (innerRadius + barLength) * Math.sin(angle);
|
| 294 |
+
const category = String(d[xField]);
|
| 295 |
+
const iconSize = Math.max(28, Math.min(39, barWidth * 1.15));
|
| 296 |
+
|
| 297 |
+
chartLayer.append("line")
|
| 298 |
+
.attr("class", "radial-bar")
|
| 299 |
+
.attr("data-category", category)
|
| 300 |
+
.attr("data-value", value)
|
| 301 |
+
.attr("x1", x1).attr("y1", y1)
|
| 302 |
+
.attr("x2", x2).attr("y2", y2)
|
| 303 |
+
.attr("stroke", `url(#cbc2-bar-gradient-${i})`)
|
| 304 |
+
.attr("stroke-width", barWidth)
|
| 305 |
+
.attr("stroke-linecap", "round")
|
| 306 |
+
.attr("filter", "url(#cbc2-soft-shadow)");
|
| 307 |
+
|
| 308 |
+
const rankX = centerX - rankRadius * Math.cos(angle);
|
| 309 |
+
const rankY = centerY - rankRadius * Math.sin(angle);
|
| 310 |
+
chartLayer.append("circle")
|
| 311 |
+
.attr("class", "rank-badge")
|
| 312 |
+
.attr("data-category", category)
|
| 313 |
+
.attr("cx", rankX).attr("cy", rankY)
|
| 314 |
+
.attr("r", 18)
|
| 315 |
+
.attr("fill", "#fffefa")
|
| 316 |
+
.attr("stroke", "#087464")
|
| 317 |
+
.attr("stroke-width", 2.2);
|
| 318 |
+
chartLayer.append("text")
|
| 319 |
+
.attr("class", "rank-label")
|
| 320 |
+
.attr("x", rankX).attr("y", rankY + 1)
|
| 321 |
+
.attr("font-family", fontFamily)
|
| 322 |
+
.attr("font-size", 20)
|
| 323 |
+
.attr("font-weight", 800)
|
| 324 |
+
.attr("fill", "#075f55")
|
| 325 |
+
.attr("text-anchor", "middle")
|
| 326 |
+
.attr("dominant-baseline", "middle")
|
| 327 |
+
.text(i + 1);
|
| 328 |
+
|
| 329 |
+
const iconGroup = chartLayer.append("g")
|
| 330 |
+
.attr("class", "bar-end-icon")
|
| 331 |
+
.attr("data-category", category)
|
| 332 |
+
.attr("transform", `translate(${x2},${y2})`);
|
| 333 |
+
iconGroup.append("circle")
|
| 334 |
+
.attr("r", iconSize / 2 + 4)
|
| 335 |
+
.attr("fill", "#fffefa")
|
| 336 |
+
.attr("stroke", "#dfe8df")
|
| 337 |
+
.attr("stroke-width", 2)
|
| 338 |
+
.attr("filter", "url(#cbc2-soft-shadow)");
|
| 339 |
+
const iconUrl = images && images.field ? images.field[category] : null;
|
| 340 |
+
if (iconUrl) {
|
| 341 |
+
const clipId = `cbc2-icon-clip-${i}`;
|
| 342 |
+
defs.append("clipPath")
|
| 343 |
+
.attr("id", clipId)
|
| 344 |
+
.append("circle")
|
| 345 |
+
.attr("cx", 0).attr("cy", 0)
|
| 346 |
+
.attr("r", iconSize / 2);
|
| 347 |
+
iconGroup.append("image")
|
| 348 |
+
.attr("class", "input-field-icon")
|
| 349 |
+
.attr("data-asset-source", "input-field-icon")
|
| 350 |
+
.attr("data-category", category)
|
| 351 |
+
.attr("data-no-copied-reference-art", "true")
|
| 352 |
+
.attr("href", iconUrl)
|
| 353 |
+
.attr("xlink:href", iconUrl)
|
| 354 |
+
.attr("x", -iconSize / 2)
|
| 355 |
+
.attr("y", -iconSize / 2)
|
| 356 |
+
.attr("width", iconSize)
|
| 357 |
+
.attr("height", iconSize)
|
| 358 |
+
.attr("clip-path", `url(#${clipId})`)
|
| 359 |
+
.attr("preserveAspectRatio", "xMidYMid slice");
|
| 360 |
+
} else {
|
| 361 |
+
iconGroup.append("text")
|
| 362 |
+
.attr("text-anchor", "middle")
|
| 363 |
+
.attr("dominant-baseline", "middle")
|
| 364 |
+
.attr("font-family", fontFamily)
|
| 365 |
+
.attr("font-size", iconSize * 0.48)
|
| 366 |
+
.attr("font-weight", 800)
|
| 367 |
+
.attr("fill", "#087464")
|
| 368 |
+
.text(category.slice(0, 1).toUpperCase());
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
const rotation = angle * 180 / Math.PI;
|
| 372 |
+
const labelRadius = innerRadius + Math.max(barLength * 0.68, Math.min(barLength - iconSize * 1.55, width * 0.11));
|
| 373 |
+
const labelX = centerX - labelRadius * Math.cos(angle);
|
| 374 |
+
const labelY = centerY - labelRadius * Math.sin(angle);
|
| 375 |
+
const labelText = chartData.length > 14
|
| 376 |
+
? `${compactCategory(category)} ${fmtValue(value)}`
|
| 377 |
+
: `${shorten(category, 13)}: ${fmtValue(value)}`;
|
| 378 |
+
const labelInside = barLength > width * 0.20;
|
| 379 |
+
const labelFill = labelInside ? "#073d39" : "#075f55";
|
| 380 |
+
const labelFont = Math.max(9, Math.min(12.5, width * 0.016));
|
| 381 |
+
chartLayer.append("text")
|
| 382 |
+
.attr("class", "inline-bar-label")
|
| 383 |
+
.attr("data-category", category)
|
| 384 |
+
.attr("x", labelX)
|
| 385 |
+
.attr("y", labelY + 1)
|
| 386 |
+
.attr("font-family", fontFamily)
|
| 387 |
+
.attr("font-size", labelFont)
|
| 388 |
+
.attr("font-weight", 800)
|
| 389 |
+
.attr("fill", labelFill)
|
| 390 |
+
.attr("text-anchor", "middle")
|
| 391 |
+
.attr("dominant-baseline", "middle")
|
| 392 |
+
.attr("transform", `rotate(${rotation}, ${labelX}, ${labelY})`)
|
| 393 |
+
.text(labelText);
|
| 394 |
+
});
|
| 395 |
+
|
| 396 |
+
const scaleCard = svg.append("g")
|
| 397 |
+
.attr("class", "scale-card")
|
| 398 |
+
.attr("data-relationship", "independent-floating-upper-right");
|
| 399 |
+
const scaleX = width * 0.815;
|
| 400 |
+
const scaleY = height * 0.052;
|
| 401 |
+
const scaleW = width * 0.14;
|
| 402 |
+
const scaleH = height * 0.265;
|
| 403 |
+
scaleCard.append("rect")
|
| 404 |
+
.attr("x", scaleX)
|
| 405 |
+
.attr("y", scaleY)
|
| 406 |
+
.attr("width", scaleW)
|
| 407 |
+
.attr("height", scaleH)
|
| 408 |
+
.attr("rx", 16)
|
| 409 |
+
.attr("fill", "#ffffff")
|
| 410 |
+
.attr("fill-opacity", 0.78)
|
| 411 |
+
.attr("stroke", "#ffffff")
|
| 412 |
+
.attr("stroke-width", 3);
|
| 413 |
+
scaleCard.append("text")
|
| 414 |
+
.attr("x", scaleX + scaleW * 0.16)
|
| 415 |
+
.attr("y", scaleY + 33)
|
| 416 |
+
.attr("font-family", fontFamily)
|
| 417 |
+
.attr("font-size", 16)
|
| 418 |
+
.attr("font-weight", 900)
|
| 419 |
+
.attr("fill", "#064942")
|
| 420 |
+
.text("AVERAGE");
|
| 421 |
+
scaleCard.append("text")
|
| 422 |
+
.attr("x", scaleX + scaleW * 0.16)
|
| 423 |
+
.attr("y", scaleY + 58)
|
| 424 |
+
.attr("font-family", fontFamily)
|
| 425 |
+
.attr("font-size", 16)
|
| 426 |
+
.attr("font-weight", 900)
|
| 427 |
+
.attr("fill", "#064942")
|
| 428 |
+
.text("AGE (YEARS)");
|
| 429 |
+
const tickValues = [16, 13, 10, 6, 3, 0];
|
| 430 |
+
const tickTop = scaleY + 93;
|
| 431 |
+
const tickBottom = scaleY + scaleH - 30;
|
| 432 |
+
scaleCard.append("line")
|
| 433 |
+
.attr("x1", scaleX + scaleW * 0.58)
|
| 434 |
+
.attr("y1", tickTop)
|
| 435 |
+
.attr("x2", scaleX + scaleW * 0.58)
|
| 436 |
+
.attr("y2", tickBottom)
|
| 437 |
+
.attr("stroke", "#087464")
|
| 438 |
+
.attr("stroke-width", 1.4)
|
| 439 |
+
.attr("stroke-dasharray", "2 8")
|
| 440 |
+
.attr("opacity", 0.85);
|
| 441 |
+
tickValues.forEach((tick, idx) => {
|
| 442 |
+
const y = tickTop + (tickBottom - tickTop) * idx / (tickValues.length - 1);
|
| 443 |
+
scaleCard.append("text")
|
| 444 |
+
.attr("x", scaleX + scaleW * 0.28)
|
| 445 |
+
.attr("y", y + 5)
|
| 446 |
+
.attr("font-family", fontFamily)
|
| 447 |
+
.attr("font-size", 18)
|
| 448 |
+
.attr("font-weight", 500)
|
| 449 |
+
.attr("fill", "#064942")
|
| 450 |
+
.attr("text-anchor", "end")
|
| 451 |
+
.text(tick);
|
| 452 |
+
scaleCard.append("line")
|
| 453 |
+
.attr("x1", scaleX + scaleW * 0.60)
|
| 454 |
+
.attr("x2", scaleX + scaleW * 0.79)
|
| 455 |
+
.attr("y1", y)
|
| 456 |
+
.attr("y2", y)
|
| 457 |
+
.attr("stroke", "#087464")
|
| 458 |
+
.attr("stroke-width", 1.3);
|
| 459 |
+
});
|
| 460 |
+
|
| 461 |
+
const titleGroup = svg.append("g")
|
| 462 |
+
.attr("class", "title-panel-text")
|
| 463 |
+
.attr("data-role", "title-subtitle-panel")
|
| 464 |
+
.attr("data-collision-rule", "inside-title-panel-clear-of-rank-badges-and-bars");
|
| 465 |
+
const gaugeX = centerX + width * 0.12;
|
| 466 |
+
const gaugeY = centerY - panelRadius * 0.48;
|
| 467 |
+
titleGroup.append("path")
|
| 468 |
+
.attr("class", "chart-native-gauge-glyph")
|
| 469 |
+
.attr("data-asset-policy", "reusable-chart-native-svg")
|
| 470 |
+
.attr("d", `M${gaugeX - 42},${gaugeY + 22} A42,42 0 0,1 ${gaugeX + 42},${gaugeY + 22}`)
|
| 471 |
+
.attr("fill", "none")
|
| 472 |
+
.attr("stroke", "#a8e15f")
|
| 473 |
+
.attr("stroke-width", 8)
|
| 474 |
+
.attr("stroke-linecap", "round");
|
| 475 |
+
[-36, -18, 0, 18, 36].forEach(offset => {
|
| 476 |
+
const angle = (-120 + (offset + 36) / 72 * 120) * Math.PI / 180;
|
| 477 |
+
const x1 = gaugeX + Math.cos(angle) * 28;
|
| 478 |
+
const y1 = gaugeY + 22 + Math.sin(angle) * 28;
|
| 479 |
+
const x2 = gaugeX + Math.cos(angle) * 36;
|
| 480 |
+
const y2 = gaugeY + 22 + Math.sin(angle) * 36;
|
| 481 |
+
titleGroup.append("line")
|
| 482 |
+
.attr("x1", x1).attr("y1", y1)
|
| 483 |
+
.attr("x2", x2).attr("y2", y2)
|
| 484 |
+
.attr("stroke", "#dff3e4")
|
| 485 |
+
.attr("stroke-width", 2);
|
| 486 |
+
});
|
| 487 |
+
titleGroup.append("line")
|
| 488 |
+
.attr("x1", gaugeX)
|
| 489 |
+
.attr("y1", gaugeY + 22)
|
| 490 |
+
.attr("x2", gaugeX + 31)
|
| 491 |
+
.attr("y2", gaugeY + 2)
|
| 492 |
+
.attr("stroke", "#dff3e4")
|
| 493 |
+
.attr("stroke-width", 4)
|
| 494 |
+
.attr("stroke-linecap", "round");
|
| 495 |
+
titleGroup.append("circle")
|
| 496 |
+
.attr("cx", gaugeX)
|
| 497 |
+
.attr("cy", gaugeY + 22)
|
| 498 |
+
.attr("r", 7)
|
| 499 |
+
.attr("fill", "#dff3e4");
|
| 500 |
+
|
| 501 |
+
const mainTitle = String((jsonData.titles && jsonData.titles.main_title) || "Average Age of Vehicle Fleets Varies by Country");
|
| 502 |
+
const subTitle = String((jsonData.titles && jsonData.titles.sub_title) || jsonData.description || "");
|
| 503 |
+
const titleLines = wrapWords(mainTitle, 18, 3);
|
| 504 |
+
const titleX = centerX + width * 0.075;
|
| 505 |
+
const titleY = centerY - panelRadius * 0.13;
|
| 506 |
+
const titleText = titleGroup.append("text")
|
| 507 |
+
.attr("x", titleX)
|
| 508 |
+
.attr("y", titleY)
|
| 509 |
+
.attr("font-family", fontFamily)
|
| 510 |
+
.attr("font-size", 29)
|
| 511 |
+
.attr("font-weight", 900)
|
| 512 |
+
.attr("fill", "#ffffff");
|
| 513 |
+
titleLines.forEach((line, idx) => {
|
| 514 |
+
titleText.append("tspan")
|
| 515 |
+
.attr("x", titleX)
|
| 516 |
+
.attr("dy", idx === 0 ? 0 : 37)
|
| 517 |
+
.text(line);
|
| 518 |
+
});
|
| 519 |
+
titleGroup.append("line")
|
| 520 |
+
.attr("x1", titleX)
|
| 521 |
+
.attr("x2", titleX + width * 0.15)
|
| 522 |
+
.attr("y1", titleY + titleLines.length * 37 + 8)
|
| 523 |
+
.attr("y2", titleY + titleLines.length * 37 + 8)
|
| 524 |
+
.attr("stroke", "#a8e15f")
|
| 525 |
+
.attr("stroke-width", 4)
|
| 526 |
+
.attr("stroke-linecap", "round");
|
| 527 |
+
const subtitleLines = wrapWords(subTitle, 29, 3);
|
| 528 |
+
const subtitleText = titleGroup.append("text")
|
| 529 |
+
.attr("x", titleX)
|
| 530 |
+
.attr("y", titleY + titleLines.length * 37 + 40)
|
| 531 |
+
.attr("font-family", fontFamily)
|
| 532 |
+
.attr("font-size", 18)
|
| 533 |
+
.attr("font-weight", 500)
|
| 534 |
+
.attr("fill", "#e8fff3");
|
| 535 |
+
subtitleLines.forEach((line, idx) => {
|
| 536 |
+
subtitleText.append("tspan")
|
| 537 |
+
.attr("x", titleX)
|
| 538 |
+
.attr("dy", idx === 0 ? 0 : 30)
|
| 539 |
+
.text(line);
|
| 540 |
+
});
|
| 541 |
+
|
| 542 |
+
return svg.node();
|
| 543 |
+
}
|
modules/chart_engine/template/d3-js/type10_circular_bar_chart/circular_bar_plain_chart_01.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Circular Bar Chart",
|
| 5 |
+
"chart_name": "circular_bar_plain_chart_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 20], [0, "inf"]],
|
| 10 |
+
"required_fields_icons": [],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": [],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": ["shadow", "radius_corner", "gradient", "stroke"],
|
| 15 |
+
"min_height": 500,
|
| 16 |
+
"min_width": 500,
|
| 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 colorResolver = chartUtils.color.resolver(data);
|
| 28 |
+
// ---------- 1. 数据准备阶段 ----------
|
| 29 |
+
|
| 30 |
+
const jsonData = data;
|
| 31 |
+
const chartData = jsonData.data.data;
|
| 32 |
+
const variables = jsonData.variables || {};
|
| 33 |
+
const typography = jsonData.typography || {
|
| 34 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 35 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 36 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 37 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 38 |
+
};
|
| 39 |
+
const colors = jsonData.colors || {
|
| 40 |
+
text_color: "#333333",
|
| 41 |
+
other: { primary: "#084594" }
|
| 42 |
+
};
|
| 43 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 44 |
+
|
| 45 |
+
// 视觉效果默认值
|
| 46 |
+
variables.has_rounded_corners = variables.has_rounded_corners || false;
|
| 47 |
+
variables.has_shadow = variables.has_shadow || false;
|
| 48 |
+
variables.has_gradient = variables.has_gradient || false;
|
| 49 |
+
variables.has_stroke = variables.has_stroke || false;
|
| 50 |
+
|
| 51 |
+
// 清空容器
|
| 52 |
+
d3.select(containerSelector).html("");
|
| 53 |
+
|
| 54 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 55 |
+
|
| 56 |
+
const width = variables.width || 800;
|
| 57 |
+
const height = variables.height || 800;
|
| 58 |
+
const size = Math.min(width, height);
|
| 59 |
+
|
| 60 |
+
const margin = {
|
| 61 |
+
top: 90,
|
| 62 |
+
right: 50,
|
| 63 |
+
bottom: 60,
|
| 64 |
+
left: 50
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const innerWidth = size - margin.left - margin.right;
|
| 68 |
+
const innerHeight = size - margin.top - margin.bottom;
|
| 69 |
+
|
| 70 |
+
const centerX = margin.left + innerWidth / 2;
|
| 71 |
+
const centerY = margin.top + innerHeight / 2;
|
| 72 |
+
const radius = Math.min(innerWidth, innerHeight) / 2;
|
| 73 |
+
|
| 74 |
+
// ---------- 3. 提取字段名和单位 ----------
|
| 75 |
+
|
| 76 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key || "category";
|
| 77 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key || "value";
|
| 78 |
+
|
| 79 |
+
// 单位
|
| 80 |
+
let valueUnit = "";
|
| 81 |
+
const valueChannel = chartUtils.schema.channel(jsonData, "y");
|
| 82 |
+
if (valueChannel.unit) {
|
| 83 |
+
valueUnit = valueChannel.unit === "B" ? " B" : valueChannel.unit;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// ---------- 4. 数据处理 ----------
|
| 87 |
+
|
| 88 |
+
// 数值格式化函数
|
| 89 |
+
|
| 90 |
+
// 按值降序排序数据
|
| 91 |
+
chartData.sort((a, b) => +b[valueField] - +a[valueField]);
|
| 92 |
+
|
| 93 |
+
const totalItems = chartData.length;
|
| 94 |
+
if (totalItems === 0) return;
|
| 95 |
+
|
| 96 |
+
const anglePerItem = (2 * Math.PI) / totalItems;
|
| 97 |
+
const maxValue = d3.max(chartData, d => +d[valueField]);
|
| 98 |
+
const minValue = 0;
|
| 99 |
+
|
| 100 |
+
// ---------- 5. 创建SVG和效果定义 ----------
|
| 101 |
+
|
| 102 |
+
const svg = d3.select(containerSelector)
|
| 103 |
+
.append("svg")
|
| 104 |
+
.attr("width", "100%")
|
| 105 |
+
.attr("height", size)
|
| 106 |
+
.attr("viewBox", `0 0 ${size} ${size}`)
|
| 107 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 108 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 109 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 110 |
+
|
| 111 |
+
const defs = svg.append("defs");
|
| 112 |
+
|
| 113 |
+
// 阴影滤镜
|
| 114 |
+
if (variables.has_shadow) {
|
| 115 |
+
const filter = defs.append("filter")
|
| 116 |
+
.attr("id", "shadow")
|
| 117 |
+
.attr("filterUnits", "userSpaceOnUse")
|
| 118 |
+
.attr("x", -innerWidth/2).attr("y", -innerHeight/2)
|
| 119 |
+
.attr("width", innerWidth*2).attr("height", innerHeight*2);
|
| 120 |
+
filter.append("feGaussianBlur").attr("in", "SourceAlpha").attr("stdDeviation", 4);
|
| 121 |
+
filter.append("feOffset").attr("dx", 3).attr("dy", 3).attr("result", "offsetblur");
|
| 122 |
+
filter.append("feFlood").attr("flood-color", "#000").attr("flood-opacity", 0.3);
|
| 123 |
+
filter.append("feComposite").attr("in2", "offsetblur").attr("operator", "in");
|
| 124 |
+
const feMerge = filter.append("feMerge");
|
| 125 |
+
feMerge.append("feMergeNode");
|
| 126 |
+
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// 定义主颜色
|
| 130 |
+
const primaryColor = colorResolver.other("primary").value;
|
| 131 |
+
|
| 132 |
+
// ---------- 6. 创建比例尺 ----------
|
| 133 |
+
|
| 134 |
+
const centralCircleRadius = radius * 0.25;
|
| 135 |
+
const radiusScale = d3.scaleLinear()
|
| 136 |
+
.domain([minValue, maxValue])
|
| 137 |
+
.range([centralCircleRadius + 20, radius * 0.9]);
|
| 138 |
+
|
| 139 |
+
// 颜色比例尺 - 值越高颜色越深
|
| 140 |
+
const lightColor = colorResolver.variant(primaryColor, { mode: "brighter", amount: 0.5 });
|
| 141 |
+
const darkColor = colorResolver.variant(primaryColor, { mode: "darker", amount: 0.5 });
|
| 142 |
+
const colorInterpolator = d3.interpolateRgb(lightColor, darkColor);
|
| 143 |
+
const colorScale = d3.scaleSequential(colorInterpolator)
|
| 144 |
+
.domain([totalItems - 1, 0]);
|
| 145 |
+
|
| 146 |
+
// ---------- 7. 创建中心圆和背景 ----------
|
| 147 |
+
|
| 148 |
+
svg.append("circle")
|
| 149 |
+
.attr("class", "background")
|
| 150 |
+
.attr("cx", centerX)
|
| 151 |
+
.attr("cy", centerY)
|
| 152 |
+
.attr("r", centralCircleRadius)
|
| 153 |
+
.attr("fill", "#ffffff")
|
| 154 |
+
.attr("stroke", "#aaaaaa")
|
| 155 |
+
.attr("stroke-width", 1.5);
|
| 156 |
+
|
| 157 |
+
// ---------- 8. 创建扇形、端点圆和标签 ----------
|
| 158 |
+
|
| 159 |
+
const arcGenerator = d3.arc()
|
| 160 |
+
.innerRadius(centralCircleRadius)
|
| 161 |
+
.padAngle(0.02);
|
| 162 |
+
|
| 163 |
+
const sectorsGroup = svg.append("g")
|
| 164 |
+
.attr("transform", `translate(${centerX}, ${centerY})`);
|
| 165 |
+
|
| 166 |
+
const labelsGroup = svg.append("g")
|
| 167 |
+
.attr("transform", `translate(${centerX}, ${centerY})`);
|
| 168 |
+
|
| 169 |
+
// 遍历排序后的数据来创建元素
|
| 170 |
+
chartData.forEach((d, i) => {
|
| 171 |
+
const value = +d[valueField];
|
| 172 |
+
const category = d[dimensionField];
|
| 173 |
+
|
| 174 |
+
const startAngle = (i * anglePerItem);
|
| 175 |
+
const endAngle = startAngle + anglePerItem;
|
| 176 |
+
const midAngle = startAngle + anglePerItem / 2;
|
| 177 |
+
|
| 178 |
+
const currentOuterRadius = radiusScale(value);
|
| 179 |
+
|
| 180 |
+
// --- 8a. 绘制扇形 ---
|
| 181 |
+
sectorsGroup.append("path")
|
| 182 |
+
.datum({
|
| 183 |
+
innerRadius: centralCircleRadius,
|
| 184 |
+
outerRadius: currentOuterRadius,
|
| 185 |
+
startAngle: startAngle,
|
| 186 |
+
endAngle: endAngle
|
| 187 |
+
})
|
| 188 |
+
.attr("class", "mark")
|
| 189 |
+
.attr("d", arcGenerator)
|
| 190 |
+
.attr("fill", colorScale(i))
|
| 191 |
+
.attr("stroke", variables.has_stroke ? colorResolver.variant(colorScale(i), { mode: "darker", amount: 0.5 }) : "none")
|
| 192 |
+
.attr("stroke-width", variables.has_stroke ? 1 : 0)
|
| 193 |
+
.attr("style", variables.has_shadow ? "filter: url(#shadow)" : null)
|
| 194 |
+
.on("mouseover", function() { d3.select(this).attr("opacity", 0.8); })
|
| 195 |
+
.on("mouseout", function() { d3.select(this).attr("opacity", 1); });
|
| 196 |
+
|
| 197 |
+
// --- 8b. 计算末端圆圈位置 ---
|
| 198 |
+
const endCircleX = Math.sin(midAngle) * currentOuterRadius;
|
| 199 |
+
const endCircleY = -Math.cos(midAngle) * currentOuterRadius;
|
| 200 |
+
const endCircleRadius = Math.max(12, Math.min(30, radius * 0.1 * (value / maxValue * 2 + 0.5)));
|
| 201 |
+
|
| 202 |
+
// --- 8c. 绘制末端圆圈 ---
|
| 203 |
+
sectorsGroup.append("circle")
|
| 204 |
+
.attr("class", "background")
|
| 205 |
+
.attr("cx", endCircleX)
|
| 206 |
+
.attr("cy", endCircleY)
|
| 207 |
+
.attr("r", endCircleRadius)
|
| 208 |
+
.attr("fill", colorScale(i))
|
| 209 |
+
.attr("stroke", "#ffffff")
|
| 210 |
+
.attr("stroke-width", 1)
|
| 211 |
+
.attr("style", variables.has_shadow ? "filter: url(#shadow)" : null);
|
| 212 |
+
|
| 213 |
+
// --- 8d. 在末端圆圈内添加数值文本 ---
|
| 214 |
+
const valueText = chartUtils.format.autoText(value, { unit: valueUnit });
|
| 215 |
+
let valueFontSize = endCircleRadius * 0.9;
|
| 216 |
+
const maxValueTextWidth = endCircleRadius * 1.8;
|
| 217 |
+
const valueTextWidth = chartUtils.text.measure(null, valueText, { fontSize: valueFontSize }).width;
|
| 218 |
+
if (valueTextWidth > maxValueTextWidth) {
|
| 219 |
+
valueFontSize *= maxValueTextWidth / valueTextWidth;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
sectorsGroup.append("text")
|
| 223 |
+
.attr("class", "value")
|
| 224 |
+
.attr("x", endCircleX)
|
| 225 |
+
.attr("y", endCircleY)
|
| 226 |
+
.attr("text-anchor", "middle")
|
| 227 |
+
.attr("dominant-baseline", "middle")
|
| 228 |
+
.attr("font-family", typography.annotation.font_family)
|
| 229 |
+
.attr("font-size", `${valueFontSize}px`)
|
| 230 |
+
.attr("font-weight", "bold")
|
| 231 |
+
.attr("fill", "#ffffff")
|
| 232 |
+
.text(valueText);
|
| 233 |
+
|
| 234 |
+
// --- 8e. 计算外部标签位置 ---
|
| 235 |
+
const labelPadding = 20;
|
| 236 |
+
const labelRadius = currentOuterRadius + endCircleRadius + labelPadding;
|
| 237 |
+
const labelX = Math.sin(midAngle) * labelRadius;
|
| 238 |
+
const labelY = -Math.cos(midAngle) * labelRadius;
|
| 239 |
+
|
| 240 |
+
// --- 8f. 添加类别文本标签 ---
|
| 241 |
+
labelsGroup.append("text")
|
| 242 |
+
.attr("class", "label")
|
| 243 |
+
.attr("x", labelX)
|
| 244 |
+
.attr("y", labelY)
|
| 245 |
+
.attr("text-anchor", "middle")
|
| 246 |
+
.attr("dominant-baseline", "middle")
|
| 247 |
+
.attr("font-family", typography.label.font_family)
|
| 248 |
+
.attr("font-size", typography.label.font_size)
|
| 249 |
+
.attr("font-weight", typography.label.font_weight)
|
| 250 |
+
.attr("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 251 |
+
.text(category);
|
| 252 |
+
});
|
| 253 |
+
|
| 254 |
+
return svg.node();
|
| 255 |
+
}
|
modules/chart_engine/template/d3-js/type10_circular_bar_chart/circular_bar_plain_chart_02.js
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Circular Bar Chart",
|
| 5 |
+
"chart_name": "circular_bar_plain_chart_02",
|
| 6 |
+
"required_fields": ["x", "y"],
|
| 7 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 8 |
+
"required_fields_range": [[15, 30], [0, 100]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": [],
|
| 12 |
+
"required_other_colors": ["primary", "secondary", "background"],
|
| 13 |
+
"supported_effects": ["gradient", "opacity", "shadow", "stroke"],
|
| 14 |
+
"min_height": 720,
|
| 15 |
+
"min_width": 560,
|
| 16 |
+
"has_title": 1,
|
| 17 |
+
"background": "light",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "none",
|
| 20 |
+
"has_x_axis": "yes",
|
| 21 |
+
"has_y_axis": "yes"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data || {};
|
| 28 |
+
const sourceData = jsonData.data?.data || [];
|
| 29 |
+
const variables = jsonData.variables || {};
|
| 30 |
+
const typography = jsonData.typography || {};
|
| 31 |
+
const sourceColors = jsonData.colors || {};
|
| 32 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 33 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 34 |
+
const chartUtilsFormatSample = chartUtils.format.autoText;
|
| 35 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 36 |
+
const chartUtilsStandard = {
|
| 37 |
+
schema: chartUtils.schema,
|
| 38 |
+
format: chartUtils.format,
|
| 39 |
+
text: chartUtils.text,
|
| 40 |
+
color: chartUtils.color,
|
| 41 |
+
legendLayout: chartUtils.legend.layout,
|
| 42 |
+
legendDraw: chartUtils.legend.draw,
|
| 43 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 44 |
+
};
|
| 45 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 46 |
+
x: { fallbackIndex: 0 },
|
| 47 |
+
y: { fallbackIndex: 1 },
|
| 48 |
+
y2: { fallbackIndex: 2 },
|
| 49 |
+
y3: { fallbackIndex: 3 },
|
| 50 |
+
size: { fallbackIndex: 2 },
|
| 51 |
+
group: { fallbackIndex: 2 },
|
| 52 |
+
group2: { fallbackIndex: 3 },
|
| 53 |
+
group3: { fallbackIndex: 4 }
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
d3.select(containerSelector).html("");
|
| 57 |
+
|
| 58 |
+
const roleColumn = role => Array.from(dataColumns).find(col => col.role === role);
|
| 59 |
+
const xColumn = roleColumn("x") || dataColumns[0] || {};
|
| 60 |
+
const yColumn = roleColumn("y") || dataColumns[1] || {};
|
| 61 |
+
const xField = xColumn.name;
|
| 62 |
+
const yField = yColumn.name;
|
| 63 |
+
const valueUnit = (yColumn.unit || "").trim();
|
| 64 |
+
|
| 65 |
+
const width = Number(variables.width) || 600;
|
| 66 |
+
const height = Math.max(Number(variables.height) || 600, Math.round(width * 1.24));
|
| 67 |
+
const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
|
| 68 |
+
const textColor = sourceColors.text_color || "#0c2238";
|
| 69 |
+
const mutedText = "#64747c";
|
| 70 |
+
const primary = sourceColors.other?.primary || sourceColors.primary || "#0060d9";
|
| 71 |
+
const secondary = sourceColors.other?.secondary || chartUtils.color.variant(primary, { mode: "brighter", amount: 1.2 });
|
| 72 |
+
const canvasColor = "#fbf7f4";
|
| 73 |
+
const mintWash = "#e8f3ef";
|
| 74 |
+
const cardFill = "#ffffff";
|
| 75 |
+
const tealDark = d3.interpolateRgb(primary, "#006b55")(0.72);
|
| 76 |
+
const tealMid = d3.interpolateRgb(primary, "#1ea784")(0.72);
|
| 77 |
+
const tealLight = "#a8d7cb";
|
| 78 |
+
const lineColor = "#d7e3df";
|
| 79 |
+
const background = sourceColors.background_color || cardFill;
|
| 80 |
+
|
| 81 |
+
const readableName = value => String(value || "")
|
| 82 |
+
.replace(/_/g, " ")
|
| 83 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 84 |
+
.replace(/\s+/g, " ")
|
| 85 |
+
.trim();
|
| 86 |
+
|
| 87 |
+
const compactNumber = value => {
|
| 88 |
+
const abs = Math.abs(value);
|
| 89 |
+
if (abs >= 1000000000) return `${d3.format(".2~f")(value / 1000000000)}B`;
|
| 90 |
+
if (abs >= 1000000) return `${d3.format(".2~f")(value / 1000000)}M`;
|
| 91 |
+
if (abs >= 1000) return `${d3.format(".2~f")(value / 1000)}K`;
|
| 92 |
+
if (abs >= 100) return d3.format(",.0f")(value);
|
| 93 |
+
if (abs >= 10) return d3.format(",.1f")(value).replace(/\.0$/, "");
|
| 94 |
+
if (abs >= 1) return d3.format(",.2f")(value).replace(/\.?0+$/, "");
|
| 95 |
+
return d3.format(",.3f")(value).replace(/\.?0+$/, "");
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const formatLocalValue = value => {
|
| 99 |
+
const formatted = compactNumber(value);
|
| 100 |
+
const currencyWithSuffix = valueUnit.match(/^([£$€¥])\s*([A-Za-z]+)$/);
|
| 101 |
+
if (valueUnit === "%") return `${formatted}%`;
|
| 102 |
+
if (currencyWithSuffix) return `${currencyWithSuffix[1]}${formatted}${currencyWithSuffix[2]}`;
|
| 103 |
+
if (/^[£$€¥]$/.test(valueUnit)) return `${valueUnit}${formatted}`;
|
| 104 |
+
if (valueUnit) return `${formatted} ${valueUnit}`;
|
| 105 |
+
return formatted;
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
const labelTokens = value => String(value || "")
|
| 109 |
+
.replace(/[()[\]{}]/g, " ")
|
| 110 |
+
.replace(/[,:;/\\|_-]+/g, " ")
|
| 111 |
+
.split(/\s+/)
|
| 112 |
+
.map(token => token.trim())
|
| 113 |
+
.filter(Boolean);
|
| 114 |
+
|
| 115 |
+
const compactLabel = (value, maxTokens = 3) => {
|
| 116 |
+
const label = String(value || "").trim();
|
| 117 |
+
if (label.length <= 24) return label;
|
| 118 |
+
const parentheticalWithSuffix = label.match(/\(([^()]{2,})\)\s*[-–—]\s*(.{2,})$/);
|
| 119 |
+
if (parentheticalWithSuffix) return `${parentheticalWithSuffix[1].trim()} ${parentheticalWithSuffix[2].trim()}`;
|
| 120 |
+
const parenthetical = label.match(/\(([^()]{2,})\)\s*$/);
|
| 121 |
+
if (parenthetical) return parenthetical[1].trim();
|
| 122 |
+
const tokens = labelTokens(label);
|
| 123 |
+
if (tokens.length <= maxTokens) return tokens.join(" ");
|
| 124 |
+
return tokens.slice(0, maxTokens).join(" ");
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
const monthOrder = {
|
| 128 |
+
jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4,
|
| 129 |
+
may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8,
|
| 130 |
+
sep: 9, sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const categoryRank = value => {
|
| 134 |
+
const label = String(value || "").trim();
|
| 135 |
+
const lower = label.toLowerCase();
|
| 136 |
+
if (monthOrder[lower] != null) return monthOrder[lower];
|
| 137 |
+
const year = label.match(/^\d{4}$/);
|
| 138 |
+
if (year) return Number(label);
|
| 139 |
+
const yearMonth = label.match(/^(\d{4})[-/](\d{1,2})$/);
|
| 140 |
+
if (yearMonth) return Number(yearMonth[1]) * 100 + Number(yearMonth[2]);
|
| 141 |
+
const quarter = lower.match(/^q([1-4])\s+(\d{4})$/);
|
| 142 |
+
if (quarter) return Number(quarter[2]) * 10 + Number(quarter[1]);
|
| 143 |
+
return null;
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
const svg = d3.select(containerSelector)
|
| 147 |
+
.append("svg")
|
| 148 |
+
.attr("width", "100%")
|
| 149 |
+
.attr("height", height)
|
| 150 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 151 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 152 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 153 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 154 |
+
.attr("class", "circular-bar-chart-root");
|
| 155 |
+
|
| 156 |
+
if (!xField || !yField) {
|
| 157 |
+
svg.append("text")
|
| 158 |
+
.attr("x", width / 2)
|
| 159 |
+
.attr("y", height / 2)
|
| 160 |
+
.attr("text-anchor", "middle")
|
| 161 |
+
.attr("fill", textColor)
|
| 162 |
+
.style("font-family", fontFamily)
|
| 163 |
+
.style("font-size", "16px")
|
| 164 |
+
.text("Missing circular bar fields");
|
| 165 |
+
return svg.node();
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
const rows = sourceData
|
| 169 |
+
.map(row => ({
|
| 170 |
+
category: String(row[xField]),
|
| 171 |
+
value: Number(row[yField])
|
| 172 |
+
}))
|
| 173 |
+
.filter(row => row.category && Number.isFinite(row.value) && row.value >= 0);
|
| 174 |
+
|
| 175 |
+
if (rows.length === 0) {
|
| 176 |
+
svg.append("text")
|
| 177 |
+
.attr("x", width / 2)
|
| 178 |
+
.attr("y", height / 2)
|
| 179 |
+
.attr("text-anchor", "middle")
|
| 180 |
+
.attr("fill", textColor)
|
| 181 |
+
.style("font-family", fontFamily)
|
| 182 |
+
.style("font-size", "16px")
|
| 183 |
+
.text("Not enough data");
|
| 184 |
+
return svg.node();
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
const rankedRows = rows.map((row, index) => ({ ...row, index, rank: categoryRank(row.category) }));
|
| 188 |
+
const isTemporalOrder = rankedRows.every(row => row.rank != null);
|
| 189 |
+
const orderedData = rankedRows
|
| 190 |
+
.sort((a, b) => {
|
| 191 |
+
if (isTemporalOrder) return d3.ascending(a.rank, b.rank);
|
| 192 |
+
return d3.descending(a.value, b.value) || d3.ascending(a.index, b.index);
|
| 193 |
+
})
|
| 194 |
+
.map((row, index) => ({ ...row, displayIndex: index + 1 }));
|
| 195 |
+
|
| 196 |
+
const defs = svg.append("defs");
|
| 197 |
+
const barGradient = defs.append("linearGradient")
|
| 198 |
+
.attr("id", "circularBar02Gradient")
|
| 199 |
+
.attr("x1", "0%")
|
| 200 |
+
.attr("y1", "0%")
|
| 201 |
+
.attr("x2", "100%")
|
| 202 |
+
.attr("y2", "100%");
|
| 203 |
+
barGradient.append("stop").attr("offset", "0%").attr("stop-color", tealLight);
|
| 204 |
+
barGradient.append("stop").attr("offset", "58%").attr("stop-color", tealMid);
|
| 205 |
+
barGradient.append("stop").attr("offset", "100%").attr("stop-color", tealDark);
|
| 206 |
+
|
| 207 |
+
const cardShadow = defs.append("filter")
|
| 208 |
+
.attr("id", "circularBar02CardShadow")
|
| 209 |
+
.attr("x", "-16%")
|
| 210 |
+
.attr("y", "-16%")
|
| 211 |
+
.attr("width", "132%")
|
| 212 |
+
.attr("height", "132%");
|
| 213 |
+
cardShadow.append("feDropShadow")
|
| 214 |
+
.attr("dx", 0)
|
| 215 |
+
.attr("dy", 4)
|
| 216 |
+
.attr("stdDeviation", 5)
|
| 217 |
+
.attr("flood-color", "#31504a")
|
| 218 |
+
.attr("flood-opacity", 0.16);
|
| 219 |
+
|
| 220 |
+
const measure = svg.append("g").attr("visibility", "hidden");
|
| 221 |
+
const measureLabelText = (text, fontSize, weight = 500) => {
|
| 222 |
+
const node = measure.append("text")
|
| 223 |
+
.style("font-family", fontFamily)
|
| 224 |
+
.style("font-size", `${fontSize}px`)
|
| 225 |
+
.style("font-weight", weight)
|
| 226 |
+
.text(String(text ?? ""))
|
| 227 |
+
.node();
|
| 228 |
+
const estimate = node ? node.textContent.length * 7 : 0;
|
| 229 |
+
measure.selectAll("text").remove();
|
| 230 |
+
return Math.max(estimate || 0, String(text ?? "").length * fontSize * 0.52);
|
| 231 |
+
};
|
| 232 |
+
const appendFittedText = (parent, text, options) => {
|
| 233 |
+
const {
|
| 234 |
+
x, y, maxWidth, fontSize, minFontSize = 6.5,
|
| 235 |
+
fill = textColor, anchor = "start", weight = 600,
|
| 236 |
+
className = "text", baseline = "middle"
|
| 237 |
+
} = options;
|
| 238 |
+
let size = fontSize;
|
| 239 |
+
while (size > minFontSize && measureLabelText(text, size, weight) > maxWidth) {
|
| 240 |
+
size -= 0.5;
|
| 241 |
+
}
|
| 242 |
+
const node = parent.append("text")
|
| 243 |
+
.attr("class", className)
|
| 244 |
+
.attr("x", x)
|
| 245 |
+
.attr("y", y)
|
| 246 |
+
.attr("text-anchor", anchor)
|
| 247 |
+
.attr("dominant-baseline", baseline)
|
| 248 |
+
.attr("fill", fill)
|
| 249 |
+
.style("font-family", fontFamily)
|
| 250 |
+
.style("font-size", `${size}px`)
|
| 251 |
+
.style("font-weight", weight)
|
| 252 |
+
.text(String(text ?? ""));
|
| 253 |
+
if (measureLabelText(text, size, weight) > maxWidth) {
|
| 254 |
+
node.attr("textLength", maxWidth).attr("lengthAdjust", "spacingAndGlyphs");
|
| 255 |
+
}
|
| 256 |
+
return node;
|
| 257 |
+
};
|
| 258 |
+
|
| 259 |
+
svg.append("rect")
|
| 260 |
+
.attr("class", "chart-canvas")
|
| 261 |
+
.attr("x", 0)
|
| 262 |
+
.attr("y", 0)
|
| 263 |
+
.attr("width", width)
|
| 264 |
+
.attr("height", height)
|
| 265 |
+
.attr("fill", canvasColor);
|
| 266 |
+
|
| 267 |
+
svg.append("circle")
|
| 268 |
+
.attr("class", "decorative-wash-circle")
|
| 269 |
+
.attr("cx", width * 0.25)
|
| 270 |
+
.attr("cy", height * 0.13)
|
| 271 |
+
.attr("r", Math.min(width, height) * 0.16)
|
| 272 |
+
.attr("fill", mintWash)
|
| 273 |
+
.attr("opacity", 0.86);
|
| 274 |
+
|
| 275 |
+
const dotArc = svg.append("g")
|
| 276 |
+
.attr("class", "decorative-dot-arc")
|
| 277 |
+
.attr("opacity", 0.26);
|
| 278 |
+
d3.range(32).forEach(i => {
|
| 279 |
+
const angle = -0.08 - i * 0.064;
|
| 280 |
+
const radius = Math.min(width, height) * 0.40;
|
| 281 |
+
dotArc.append("circle")
|
| 282 |
+
.attr("cx", width * 0.62 + Math.sin(angle) * radius)
|
| 283 |
+
.attr("cy", height * 0.59 - Math.cos(angle) * radius)
|
| 284 |
+
.attr("r", 1.25)
|
| 285 |
+
.attr("fill", "#96bdb4");
|
| 286 |
+
});
|
| 287 |
+
|
| 288 |
+
const maxValue = d3.max(orderedData, d => d.value) || 1;
|
| 289 |
+
const niceMax = d3.scaleLinear().domain([0, maxValue]).nice(4).domain()[1];
|
| 290 |
+
const itemCount = orderedData.length;
|
| 291 |
+
const sortMode = isTemporalOrder ? "temporal" : "value_desc";
|
| 292 |
+
const centerX = width * 0.62;
|
| 293 |
+
const centerY = height * 0.58;
|
| 294 |
+
const outerRadius = Math.min(width * 0.50, height * 0.43);
|
| 295 |
+
const innerRadius = Math.max(82, outerRadius * 0.36);
|
| 296 |
+
const angleStart = -0.22;
|
| 297 |
+
const angleEnd = -2.64;
|
| 298 |
+
const angleStep = itemCount > 1 ? (angleStart - angleEnd) / (itemCount - 1) : 0;
|
| 299 |
+
const barBandwidth = Math.max(0.035, Math.min(0.105, angleStep * 0.58 || 0.09));
|
| 300 |
+
const radiusScale = d3.scaleLinear()
|
| 301 |
+
.domain([0, niceMax])
|
| 302 |
+
.range([innerRadius + 12, outerRadius]);
|
| 303 |
+
const colorScale = d3.scaleLinear()
|
| 304 |
+
.domain([0, Math.max(1, itemCount - 1)])
|
| 305 |
+
.range([tealDark, tealLight]);
|
| 306 |
+
const arc = d3.arc();
|
| 307 |
+
const ticks = radiusScale.ticks(4).filter(tick => tick > 0);
|
| 308 |
+
|
| 309 |
+
const chartGroup = svg.append("g")
|
| 310 |
+
.attr("class", "circular-bar-plot")
|
| 311 |
+
.attr("data-sort-mode", sortMode)
|
| 312 |
+
.attr("data-count", itemCount)
|
| 313 |
+
.attr("data-max-value", maxValue)
|
| 314 |
+
.attr("transform", `translate(${centerX}, ${centerY})`);
|
| 315 |
+
|
| 316 |
+
ticks.forEach(tick => {
|
| 317 |
+
chartGroup.append("path")
|
| 318 |
+
.attr("class", "radial-grid-ring")
|
| 319 |
+
.attr("data-value", tick)
|
| 320 |
+
.attr("d", arc({
|
| 321 |
+
innerRadius: radiusScale(tick),
|
| 322 |
+
outerRadius: radiusScale(tick) + 0.6,
|
| 323 |
+
startAngle: angleEnd - 0.07,
|
| 324 |
+
endAngle: angleStart + 0.07
|
| 325 |
+
}))
|
| 326 |
+
.attr("fill", lineColor)
|
| 327 |
+
.attr("opacity", 0.82);
|
| 328 |
+
|
| 329 |
+
const labelAngle = angleStart + 0.1;
|
| 330 |
+
chartGroup.append("text")
|
| 331 |
+
.attr("class", "radial-grid-label")
|
| 332 |
+
.attr("x", Math.sin(labelAngle) * radiusScale(tick) + 4)
|
| 333 |
+
.attr("y", -Math.cos(labelAngle) * radiusScale(tick))
|
| 334 |
+
.attr("fill", mutedText)
|
| 335 |
+
.style("font-family", fontFamily)
|
| 336 |
+
.style("font-size", "8px")
|
| 337 |
+
.text(formatLocalValue(tick));
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
const calloutRadius = Math.max(62, innerRadius - 18);
|
| 341 |
+
chartGroup.append("circle")
|
| 342 |
+
.attr("class", "center-label-background")
|
| 343 |
+
.attr("r", calloutRadius)
|
| 344 |
+
.attr("fill", cardFill)
|
| 345 |
+
.attr("stroke", "#edf2ef")
|
| 346 |
+
.attr("stroke-width", 7)
|
| 347 |
+
.attr("filter", "url(#circularBar02CardShadow)");
|
| 348 |
+
|
| 349 |
+
chartGroup.append("path")
|
| 350 |
+
.attr("class", "center-icon-line")
|
| 351 |
+
.attr("d", `M${-22},${-32} H22 M${-22},${-10} H22 M${-22},12 H22 M${-7},-32 V34 M7,-32 V34`)
|
| 352 |
+
.attr("fill", "none")
|
| 353 |
+
.attr("stroke", tealDark)
|
| 354 |
+
.attr("stroke-width", 1.8)
|
| 355 |
+
.attr("stroke-linecap", "round")
|
| 356 |
+
.attr("opacity", 0.88);
|
| 357 |
+
|
| 358 |
+
const calloutTitle = readableName(yField);
|
| 359 |
+
appendFittedText(chartGroup, calloutTitle, {
|
| 360 |
+
x: 0,
|
| 361 |
+
y: 34,
|
| 362 |
+
maxWidth: calloutRadius * 1.36,
|
| 363 |
+
fontSize: 11.5,
|
| 364 |
+
minFontSize: 7,
|
| 365 |
+
fill: textColor,
|
| 366 |
+
anchor: "middle",
|
| 367 |
+
weight: 800,
|
| 368 |
+
className: "axis-title"
|
| 369 |
+
});
|
| 370 |
+
appendFittedText(chartGroup, isTemporalOrder ? "ordered by time" : "highest values lead", {
|
| 371 |
+
x: 0,
|
| 372 |
+
y: 50,
|
| 373 |
+
maxWidth: calloutRadius * 1.28,
|
| 374 |
+
fontSize: 8.5,
|
| 375 |
+
minFontSize: 6.5,
|
| 376 |
+
fill: mutedText,
|
| 377 |
+
anchor: "middle",
|
| 378 |
+
weight: 600,
|
| 379 |
+
className: "order-note"
|
| 380 |
+
});
|
| 381 |
+
|
| 382 |
+
orderedData.forEach((row, index) => {
|
| 383 |
+
const midAngle = angleStart - index * angleStep;
|
| 384 |
+
const startAngle = midAngle - barBandwidth / 2;
|
| 385 |
+
const endAngle = midAngle + barBandwidth / 2;
|
| 386 |
+
const outer = radiusScale(row.value);
|
| 387 |
+
const color = colorScale(index);
|
| 388 |
+
const labelRadius = outerRadius + 16;
|
| 389 |
+
const labelX = Math.sin(midAngle) * labelRadius;
|
| 390 |
+
const labelY = -Math.cos(midAngle) * labelRadius;
|
| 391 |
+
const valueRadius = Math.max(innerRadius + 22, outer - Math.max(16, (outer - innerRadius) * 0.30));
|
| 392 |
+
const valueX = Math.sin(midAngle) * valueRadius;
|
| 393 |
+
const valueY = -Math.cos(midAngle) * valueRadius;
|
| 394 |
+
|
| 395 |
+
chartGroup.append("path")
|
| 396 |
+
.attr("class", "bar-track")
|
| 397 |
+
.attr("data-category", row.category)
|
| 398 |
+
.attr("data-value", row.value)
|
| 399 |
+
.attr("data-index", row.index)
|
| 400 |
+
.attr("data-rank", row.displayIndex)
|
| 401 |
+
.attr("data-sort-mode", sortMode)
|
| 402 |
+
.attr("d", arc({
|
| 403 |
+
innerRadius,
|
| 404 |
+
outerRadius,
|
| 405 |
+
startAngle,
|
| 406 |
+
endAngle
|
| 407 |
+
}))
|
| 408 |
+
.attr("fill", "#e9efec")
|
| 409 |
+
.attr("opacity", 0.86);
|
| 410 |
+
|
| 411 |
+
chartGroup.append("path")
|
| 412 |
+
.attr("class", "mark circular-bar-mark")
|
| 413 |
+
.attr("data-tag", "mark")
|
| 414 |
+
.attr("data-category", row.category)
|
| 415 |
+
.attr("data-value", row.value)
|
| 416 |
+
.attr("data-index", row.index)
|
| 417 |
+
.attr("data-rank", row.displayIndex)
|
| 418 |
+
.attr("data-sort-mode", sortMode)
|
| 419 |
+
.attr("data-start-angle", startAngle)
|
| 420 |
+
.attr("data-end-angle", endAngle)
|
| 421 |
+
.attr("data-inner-radius", innerRadius)
|
| 422 |
+
.attr("data-outer-radius", outer)
|
| 423 |
+
.attr("d", arc({
|
| 424 |
+
innerRadius,
|
| 425 |
+
outerRadius: outer,
|
| 426 |
+
startAngle,
|
| 427 |
+
endAngle
|
| 428 |
+
}))
|
| 429 |
+
.attr("fill", variables.has_gradient ? "url(#circularBar02Gradient)" : color)
|
| 430 |
+
.attr("stroke", variables.has_stroke ? chartUtils.color.variant(tealDark, { mode: "darker", amount: 0.4 }) : "none")
|
| 431 |
+
.attr("stroke-width", variables.has_stroke ? 0.8 : 0)
|
| 432 |
+
.attr("opacity", variables.has_opacity ? 0.9 : 0.96);
|
| 433 |
+
|
| 434 |
+
chartGroup.append("circle")
|
| 435 |
+
.attr("class", "index-dot")
|
| 436 |
+
.attr("data-category", row.category)
|
| 437 |
+
.attr("data-value", row.value)
|
| 438 |
+
.attr("data-index", row.index)
|
| 439 |
+
.attr("data-rank", row.displayIndex)
|
| 440 |
+
.attr("cx", Math.sin(midAngle) * (innerRadius - 4))
|
| 441 |
+
.attr("cy", -Math.cos(midAngle) * (innerRadius - 4))
|
| 442 |
+
.attr("r", itemCount > 24 ? 6 : 7.5)
|
| 443 |
+
.attr("fill", cardFill)
|
| 444 |
+
.attr("stroke", color)
|
| 445 |
+
.attr("stroke-width", 1.35);
|
| 446 |
+
|
| 447 |
+
chartGroup.append("text")
|
| 448 |
+
.attr("class", "index-label")
|
| 449 |
+
.attr("x", Math.sin(midAngle) * (innerRadius - 4))
|
| 450 |
+
.attr("y", -Math.cos(midAngle) * (innerRadius - 4))
|
| 451 |
+
.attr("text-anchor", "middle")
|
| 452 |
+
.attr("dominant-baseline", "middle")
|
| 453 |
+
.attr("fill", textColor)
|
| 454 |
+
.style("font-family", fontFamily)
|
| 455 |
+
.style("font-size", itemCount > 24 ? "6.2px" : "7.2px")
|
| 456 |
+
.style("font-weight", "800")
|
| 457 |
+
.text(row.displayIndex);
|
| 458 |
+
|
| 459 |
+
chartGroup.append("text")
|
| 460 |
+
.attr("class", "value-label")
|
| 461 |
+
.attr("data-category", row.category)
|
| 462 |
+
.attr("data-value", row.value)
|
| 463 |
+
.attr("data-index", row.index)
|
| 464 |
+
.attr("data-rank", row.displayIndex)
|
| 465 |
+
.attr("x", valueX)
|
| 466 |
+
.attr("y", valueY)
|
| 467 |
+
.attr("text-anchor", "middle")
|
| 468 |
+
.attr("dominant-baseline", "middle")
|
| 469 |
+
.attr("fill", tealDark)
|
| 470 |
+
.style("font-family", fontFamily)
|
| 471 |
+
.style("font-size", itemCount > 24 ? "7.2px" : "8.8px")
|
| 472 |
+
.style("font-weight", "800")
|
| 473 |
+
.text(formatLocalValue(row.value));
|
| 474 |
+
|
| 475 |
+
const legend = chartGroup.append("g")
|
| 476 |
+
.attr("class", "legend-row")
|
| 477 |
+
.attr("data-category", row.category)
|
| 478 |
+
.attr("data-value", row.value)
|
| 479 |
+
.attr("data-index", row.index)
|
| 480 |
+
.attr("data-rank", row.displayIndex)
|
| 481 |
+
.attr("data-sort-mode", sortMode);
|
| 482 |
+
legend.append("line")
|
| 483 |
+
.attr("class", "leader-line")
|
| 484 |
+
.attr("x1", labelX + 4)
|
| 485 |
+
.attr("y1", labelY)
|
| 486 |
+
.attr("x2", Math.sin(midAngle) * (outerRadius + 4))
|
| 487 |
+
.attr("y2", -Math.cos(midAngle) * (outerRadius + 4))
|
| 488 |
+
.attr("stroke", lineColor)
|
| 489 |
+
.attr("stroke-width", 1);
|
| 490 |
+
appendFittedText(legend, compactLabel(row.category, itemCount > 24 ? 2 : 3), {
|
| 491 |
+
x: labelX,
|
| 492 |
+
y: labelY,
|
| 493 |
+
maxWidth: Math.min(width * 0.31, Math.max(94, labelX + centerX - 14)),
|
| 494 |
+
fontSize: itemCount > 24 ? 7.2 : 8.8,
|
| 495 |
+
minFontSize: 5.8,
|
| 496 |
+
fill: textColor,
|
| 497 |
+
anchor: "end",
|
| 498 |
+
weight: 700,
|
| 499 |
+
className: "category-label"
|
| 500 |
+
});
|
| 501 |
+
});
|
| 502 |
+
|
| 503 |
+
const scaleGroup = svg.append("g")
|
| 504 |
+
.attr("class", "scale-ladder")
|
| 505 |
+
.attr("transform", `translate(${width - 58}, ${height * 0.27})`);
|
| 506 |
+
scaleGroup.append("line")
|
| 507 |
+
.attr("class", "scale-ladder-line")
|
| 508 |
+
.attr("x1", 0)
|
| 509 |
+
.attr("x2", 0)
|
| 510 |
+
.attr("y1", 0)
|
| 511 |
+
.attr("y2", height * 0.32)
|
| 512 |
+
.attr("stroke", "#a8bbb5")
|
| 513 |
+
.attr("stroke-width", 1);
|
| 514 |
+
const ladderTicks = d3.scaleLinear().domain([0, niceMax]).range([height * 0.32, 0]).ticks(4);
|
| 515 |
+
ladderTicks.forEach(tick => {
|
| 516 |
+
scaleGroup.append("circle")
|
| 517 |
+
.attr("class", "scale-ladder-dot")
|
| 518 |
+
.attr("cx", 0)
|
| 519 |
+
.attr("cy", d3.scaleLinear().domain([0, niceMax]).range([height * 0.32, 0])(tick))
|
| 520 |
+
.attr("r", 4.2)
|
| 521 |
+
.attr("fill", tealDark);
|
| 522 |
+
scaleGroup.append("text")
|
| 523 |
+
.attr("class", "scale-ladder-label")
|
| 524 |
+
.attr("x", 13)
|
| 525 |
+
.attr("y", d3.scaleLinear().domain([0, niceMax]).range([height * 0.32, 0])(tick))
|
| 526 |
+
.attr("dominant-baseline", "middle")
|
| 527 |
+
.attr("fill", tealDark)
|
| 528 |
+
.style("font-family", fontFamily)
|
| 529 |
+
.style("font-size", "9px")
|
| 530 |
+
.style("font-weight", "700")
|
| 531 |
+
.text(formatLocalValue(tick));
|
| 532 |
+
});
|
| 533 |
+
appendFittedText(scaleGroup, readableName(yField), {
|
| 534 |
+
x: -12,
|
| 535 |
+
y: -22,
|
| 536 |
+
maxWidth: Math.min(122, width * 0.23),
|
| 537 |
+
fontSize: 9.6,
|
| 538 |
+
minFontSize: 6.5,
|
| 539 |
+
fill: textColor,
|
| 540 |
+
anchor: "start",
|
| 541 |
+
weight: 800,
|
| 542 |
+
className: "scale-ladder-title"
|
| 543 |
+
});
|
| 544 |
+
|
| 545 |
+
const legendCard = svg.append("g")
|
| 546 |
+
.attr("class", "measure-legend-card")
|
| 547 |
+
.attr("transform", `translate(${width - 178}, ${height - 70})`);
|
| 548 |
+
legendCard.append("rect")
|
| 549 |
+
.attr("class", "measure-legend-bg")
|
| 550 |
+
.attr("x", 0)
|
| 551 |
+
.attr("y", 0)
|
| 552 |
+
.attr("width", 154)
|
| 553 |
+
.attr("height", 44)
|
| 554 |
+
.attr("rx", 8)
|
| 555 |
+
.attr("fill", "rgba(255,255,255,0.82)")
|
| 556 |
+
.attr("stroke", "#ffffff")
|
| 557 |
+
.attr("stroke-width", 1.2);
|
| 558 |
+
legendCard.append("rect")
|
| 559 |
+
.attr("class", "measure-legend-swatch")
|
| 560 |
+
.attr("x", 14)
|
| 561 |
+
.attr("y", 13)
|
| 562 |
+
.attr("width", 18)
|
| 563 |
+
.attr("height", 18)
|
| 564 |
+
.attr("rx", 4)
|
| 565 |
+
.attr("fill", "url(#circularBar02Gradient)");
|
| 566 |
+
appendFittedText(legendCard, readableName(yField), {
|
| 567 |
+
x: 42,
|
| 568 |
+
y: 21,
|
| 569 |
+
maxWidth: 96,
|
| 570 |
+
fontSize: 9.4,
|
| 571 |
+
minFontSize: 6.4,
|
| 572 |
+
fill: textColor,
|
| 573 |
+
anchor: "start",
|
| 574 |
+
weight: 700,
|
| 575 |
+
className: "legend-measure-label"
|
| 576 |
+
});
|
| 577 |
+
|
| 578 |
+
svg.append("text")
|
| 579 |
+
.attr("class", "scale-note")
|
| 580 |
+
.attr("x", 34)
|
| 581 |
+
.attr("y", height - 18)
|
| 582 |
+
.attr("fill", mutedText)
|
| 583 |
+
.style("font-family", fontFamily)
|
| 584 |
+
.style("font-size", "8.8px")
|
| 585 |
+
.text(`${readableName(yField)} scale: 0-${formatLocalValue(niceMax)}`);
|
| 586 |
+
|
| 587 |
+
svg.append("text")
|
| 588 |
+
.attr("class", "category-note")
|
| 589 |
+
.attr("x", width - 34)
|
| 590 |
+
.attr("y", height - 18)
|
| 591 |
+
.attr("text-anchor", "end")
|
| 592 |
+
.attr("fill", mutedText)
|
| 593 |
+
.style("font-family", fontFamily)
|
| 594 |
+
.style("font-size", "8.8px")
|
| 595 |
+
.text(isTemporalOrder
|
| 596 |
+
? `${readableName(xField)} order: ${orderedData[0].category} to ${orderedData[orderedData.length - 1].category}`
|
| 597 |
+
: `${readableName(xField)} order: highest to lowest`);
|
| 598 |
+
|
| 599 |
+
measure.remove();
|
| 600 |
+
|
| 601 |
+
return svg.node();
|
| 602 |
+
}
|