Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- modules/chart_engine/template/d3-js/type15_lollipop_chart/lollipop_icon_chart_01.js +379 -0
- modules/chart_engine/template/d3-js/type15_lollipop_chart/lollipop_icon_chart_02.js +600 -0
- modules/chart_engine/template/d3-js/type15_lollipop_chart/lollipop_plain_chart_01.js +633 -0
- modules/chart_engine/template/d3-js/type15_lollipop_chart/lollipop_plain_chart_02.js +606 -0
modules/chart_engine/template/d3-js/type15_lollipop_chart/lollipop_icon_chart_01.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Lollipop Chart",
|
| 5 |
+
"chart_name": "lollipop_icon_chart_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 30], [1, "inf"]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": [],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": [],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "side",
|
| 20 |
+
"has_x_axis": "no",
|
| 21 |
+
"has_y_axis": "no"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
// Reference-style ranked lollipop icon chart.
|
| 28 |
+
function makeChart(containerSelector, data) {
|
| 29 |
+
const jsonData = data;
|
| 30 |
+
const chartData = jsonData.data.data;
|
| 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: "14px", font_weight: "bold" },
|
| 35 |
+
description: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 36 |
+
annotation: { font_family: "Arial", font_size: "16px", font_weight: "bold" }
|
| 37 |
+
};
|
| 38 |
+
const colors = jsonData.colors || { text_color: "#333333" };
|
| 39 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 40 |
+
const images = jsonData.images || { field: {}, other: {} };
|
| 41 |
+
|
| 42 |
+
d3.select(containerSelector).html("");
|
| 43 |
+
|
| 44 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key || "dimension";
|
| 45 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key || "value";
|
| 46 |
+
let valueUnit = "";
|
| 47 |
+
if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
|
| 48 |
+
valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const sortedData = [...chartData].sort((a, b) => +b[valueField] - +a[valueField]);
|
| 52 |
+
const rowCount = sortedData.length;
|
| 53 |
+
const width = Math.max(760, Math.round((variables.width || 600) * 1.28));
|
| 54 |
+
const rowStep = rowCount > 20 ? 42 : rowCount > 15 ? 48 : 54;
|
| 55 |
+
const height = Math.max(760, 168 + rowCount * rowStep);
|
| 56 |
+
const panel = {
|
| 57 |
+
x: 18,
|
| 58 |
+
y: 18,
|
| 59 |
+
width: width - 36,
|
| 60 |
+
height: height - 36,
|
| 61 |
+
radius: 18
|
| 62 |
+
};
|
| 63 |
+
const content = {
|
| 64 |
+
x: panel.x + 34,
|
| 65 |
+
y: panel.y + 76,
|
| 66 |
+
width: panel.width - 68,
|
| 67 |
+
height: panel.height - 128
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
const svg = d3.select(containerSelector)
|
| 71 |
+
.append("svg")
|
| 72 |
+
.attr("width", width)
|
| 73 |
+
.attr("height", height)
|
| 74 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 75 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 76 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 77 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 78 |
+
|
| 79 |
+
const defs = svg.append("defs");
|
| 80 |
+
const panelGradient = defs.append("linearGradient")
|
| 81 |
+
.attr("id", "lollipop01-panel-gradient")
|
| 82 |
+
.attr("x1", "0%")
|
| 83 |
+
.attr("x2", "100%")
|
| 84 |
+
.attr("y1", "0%")
|
| 85 |
+
.attr("y2", "100%");
|
| 86 |
+
panelGradient.append("stop").attr("offset", "0%").attr("stop-color", "#FFFFFF");
|
| 87 |
+
panelGradient.append("stop").attr("offset", "100%").attr("stop-color", "#F1F8FF");
|
| 88 |
+
|
| 89 |
+
const barGradient = defs.append("linearGradient")
|
| 90 |
+
.attr("id", "lollipop01-bar-gradient")
|
| 91 |
+
.attr("x1", "0%")
|
| 92 |
+
.attr("x2", "100%")
|
| 93 |
+
.attr("y1", "0%")
|
| 94 |
+
.attr("y2", "0%");
|
| 95 |
+
barGradient.append("stop").attr("offset", "0%").attr("stop-color", "#0056AF");
|
| 96 |
+
barGradient.append("stop").attr("offset", "100%").attr("stop-color", "#2E88FF");
|
| 97 |
+
|
| 98 |
+
svg.append("rect")
|
| 99 |
+
.attr("class", "lollipop01-chart-panel")
|
| 100 |
+
.attr("data-role", "chart_panel")
|
| 101 |
+
.attr("x", panel.x)
|
| 102 |
+
.attr("y", panel.y)
|
| 103 |
+
.attr("width", panel.width)
|
| 104 |
+
.attr("height", panel.height)
|
| 105 |
+
.attr("rx", panel.radius)
|
| 106 |
+
.attr("fill", "url(#lollipop01-panel-gradient)")
|
| 107 |
+
.attr("stroke", "#D6E6F5")
|
| 108 |
+
.attr("stroke-width", 1.2);
|
| 109 |
+
|
| 110 |
+
const tabWidth = Math.min(360, Math.max(250, content.width * 0.42));
|
| 111 |
+
svg.append("rect")
|
| 112 |
+
.attr("class", "section-tab")
|
| 113 |
+
.attr("data-role", "chart_section_tab")
|
| 114 |
+
.attr("x", content.x)
|
| 115 |
+
.attr("y", panel.y + 30)
|
| 116 |
+
.attr("width", tabWidth)
|
| 117 |
+
.attr("height", 38)
|
| 118 |
+
.attr("rx", 4)
|
| 119 |
+
.attr("fill", "#073263");
|
| 120 |
+
svg.append("text")
|
| 121 |
+
.attr("class", "section-tab-label")
|
| 122 |
+
.attr("x", content.x + 18)
|
| 123 |
+
.attr("y", panel.y + 55)
|
| 124 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 125 |
+
.attr("font-size", 15)
|
| 126 |
+
.attr("font-weight", 800)
|
| 127 |
+
.attr("fill", "#FFFFFF")
|
| 128 |
+
.text(`${valueField.replace(/([a-z])([A-Z])/g, "$1 $2").toUpperCase()}${valueUnit ? ` (${valueUnit})` : ""}`);
|
| 129 |
+
|
| 130 |
+
const rankX = content.x + 20;
|
| 131 |
+
const labelX = content.x + 58;
|
| 132 |
+
const iconX = content.x + Math.min(250, Math.max(205, content.width * 0.25));
|
| 133 |
+
const barStart = iconX + 46;
|
| 134 |
+
const reservedSlot = {
|
| 135 |
+
x: content.x + content.width * 0.68,
|
| 136 |
+
y: content.y + content.height * 0.49,
|
| 137 |
+
width: content.width * 0.24,
|
| 138 |
+
height: content.height * 0.35
|
| 139 |
+
};
|
| 140 |
+
const barMaxEnd = content.x + content.width - 110;
|
| 141 |
+
const rowHeight = content.height / Math.max(1, rowCount);
|
| 142 |
+
const barHeight = Math.max(10, Math.min(20, rowHeight * 0.38));
|
| 143 |
+
const iconSize = Math.max(24, Math.min(34, rowHeight * 0.70));
|
| 144 |
+
const primaryColor = colorResolver.other("primary", { fallback: "#0B72D9" }).value;
|
| 145 |
+
const textColor = colorResolver.text({ fallback: "#0B1B3D" }).value || "#0B1B3D";
|
| 146 |
+
|
| 147 |
+
const xScale = d3.scaleLinear()
|
| 148 |
+
.domain([0, d3.max(sortedData, d => +d[valueField]) * 1.08])
|
| 149 |
+
.range([barStart, barMaxEnd]);
|
| 150 |
+
|
| 151 |
+
const tickValues = xScale.ticks(5);
|
| 152 |
+
tickValues.forEach(value => {
|
| 153 |
+
const x = xScale(value);
|
| 154 |
+
svg.append("line")
|
| 155 |
+
.attr("class", "gridline")
|
| 156 |
+
.attr("x1", x)
|
| 157 |
+
.attr("y1", content.y - 12)
|
| 158 |
+
.attr("x2", x)
|
| 159 |
+
.attr("y2", content.y + content.height + 14)
|
| 160 |
+
.attr("stroke", "#CFE0F1")
|
| 161 |
+
.attr("stroke-width", 1)
|
| 162 |
+
.attr("stroke-dasharray", "3,4")
|
| 163 |
+
.attr("opacity", 0.72);
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
const artSlot = svg.append("g")
|
| 167 |
+
.attr("class", "reserved-asset-slot right-gallery-art-slot")
|
| 168 |
+
.attr("data-asset-slot", "right_gallery_art_slot")
|
| 169 |
+
.attr("data-reserved-slot", "right_gallery_art_slot")
|
| 170 |
+
.attr("data-slot-policy", "image2_asset")
|
| 171 |
+
.attr("data-anchor", "lower-right-chart-panel-safe-area")
|
| 172 |
+
.attr("data-collision-rule", "avoid value labels bars row labels and bottom axis")
|
| 173 |
+
.attr("data-no-embedded-raster", "true")
|
| 174 |
+
.attr("data-no-copied-reference-art", "true")
|
| 175 |
+
.attr("data-bbox", `${reservedSlot.x.toFixed(1)} ${reservedSlot.y.toFixed(1)} ${reservedSlot.width.toFixed(1)} ${reservedSlot.height.toFixed(1)}`)
|
| 176 |
+
.attr("opacity", 0.62);
|
| 177 |
+
artSlot.append("rect")
|
| 178 |
+
.attr("x", reservedSlot.x)
|
| 179 |
+
.attr("y", reservedSlot.y)
|
| 180 |
+
.attr("width", reservedSlot.width)
|
| 181 |
+
.attr("height", reservedSlot.height)
|
| 182 |
+
.attr("rx", 16)
|
| 183 |
+
.attr("fill", "#E8F3FF")
|
| 184 |
+
.attr("stroke", "#B8D7F3")
|
| 185 |
+
.attr("stroke-dasharray", "6,6");
|
| 186 |
+
artSlot.append("circle")
|
| 187 |
+
.attr("cx", reservedSlot.x + reservedSlot.width * 0.30)
|
| 188 |
+
.attr("cy", reservedSlot.y + reservedSlot.height * 0.34)
|
| 189 |
+
.attr("r", Math.min(reservedSlot.width, reservedSlot.height) * 0.12)
|
| 190 |
+
.attr("fill", "#2E88FF")
|
| 191 |
+
.attr("opacity", 0.22);
|
| 192 |
+
artSlot.append("rect")
|
| 193 |
+
.attr("x", reservedSlot.x + reservedSlot.width * 0.45)
|
| 194 |
+
.attr("y", reservedSlot.y + reservedSlot.height * 0.28)
|
| 195 |
+
.attr("width", reservedSlot.width * 0.24)
|
| 196 |
+
.attr("height", reservedSlot.height * 0.18)
|
| 197 |
+
.attr("rx", 5)
|
| 198 |
+
.attr("fill", "#0B72D9")
|
| 199 |
+
.attr("opacity", 0.16);
|
| 200 |
+
|
| 201 |
+
sortedData.forEach((d, i) => {
|
| 202 |
+
const dimension = d[dimensionField];
|
| 203 |
+
const value = +d[valueField];
|
| 204 |
+
const rowY = content.y + rowHeight * i + rowHeight / 2;
|
| 205 |
+
const barEnd = xScale(value);
|
| 206 |
+
|
| 207 |
+
svg.append("line")
|
| 208 |
+
.attr("class", "row-divider")
|
| 209 |
+
.attr("x1", content.x)
|
| 210 |
+
.attr("x2", content.x + content.width)
|
| 211 |
+
.attr("y1", rowY + rowHeight / 2 - 1)
|
| 212 |
+
.attr("y2", rowY + rowHeight / 2 - 1)
|
| 213 |
+
.attr("stroke", "#DCEAF7")
|
| 214 |
+
.attr("stroke-width", 1);
|
| 215 |
+
|
| 216 |
+
svg.append("circle")
|
| 217 |
+
.attr("class", "rank-circle")
|
| 218 |
+
.attr("data-role", "rank_marker")
|
| 219 |
+
.attr("data-category", dimension)
|
| 220 |
+
.attr("cx", rankX)
|
| 221 |
+
.attr("cy", rowY)
|
| 222 |
+
.attr("r", Math.max(14, Math.min(18, rowHeight * 0.34)))
|
| 223 |
+
.attr("fill", primaryColor);
|
| 224 |
+
|
| 225 |
+
svg.append("text")
|
| 226 |
+
.attr("class", "rank-label")
|
| 227 |
+
.attr("x", rankX)
|
| 228 |
+
.attr("y", rowY)
|
| 229 |
+
.attr("dy", "0.36em")
|
| 230 |
+
.attr("text-anchor", "middle")
|
| 231 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 232 |
+
.attr("font-size", Math.max(11, Math.min(15, rowHeight * 0.28)))
|
| 233 |
+
.attr("font-weight", 800)
|
| 234 |
+
.attr("fill", "#FFFFFF")
|
| 235 |
+
.text(i + 1);
|
| 236 |
+
|
| 237 |
+
const label = String(dimension);
|
| 238 |
+
const labelFontSize = Math.max(13, Math.min(18, rowHeight * 0.34));
|
| 239 |
+
const availableLabelWidth = Math.max(70, iconX - iconSize / 2 - labelX - 10);
|
| 240 |
+
const estimatedLabelWidth = label.length * labelFontSize * 0.56;
|
| 241 |
+
const canCompressLabel = label.length <= 20 && estimatedLabelWidth <= availableLabelWidth * 1.30;
|
| 242 |
+
const maxChars = rowCount > 18 ? 12 : 16;
|
| 243 |
+
const displayLabel = canCompressLabel || label.length <= maxChars ? label : `${label.slice(0, maxChars - 1)}...`;
|
| 244 |
+
const labelText = svg.append("text")
|
| 245 |
+
.attr("class", "label")
|
| 246 |
+
.attr("data-role", "category_label")
|
| 247 |
+
.attr("data-category", dimension)
|
| 248 |
+
.attr("x", labelX)
|
| 249 |
+
.attr("y", rowY)
|
| 250 |
+
.attr("dy", "0.36em")
|
| 251 |
+
.attr("text-anchor", "start")
|
| 252 |
+
.attr("font-family", typography.label.font_family || "Arial, Helvetica, sans-serif")
|
| 253 |
+
.attr("font-size", labelFontSize)
|
| 254 |
+
.attr("font-weight", 650)
|
| 255 |
+
.attr("fill", textColor)
|
| 256 |
+
.text(displayLabel);
|
| 257 |
+
if (canCompressLabel && estimatedLabelWidth > availableLabelWidth) {
|
| 258 |
+
labelText
|
| 259 |
+
.attr("textLength", availableLabelWidth)
|
| 260 |
+
.attr("lengthAdjust", "spacingAndGlyphs");
|
| 261 |
+
}
|
| 262 |
+
labelText.append("title").text(label);
|
| 263 |
+
|
| 264 |
+
const iconGroup = svg.append("g")
|
| 265 |
+
.attr("class", "reserved-asset-slot category-icon-slot")
|
| 266 |
+
.attr("data-asset-slot", "category_icon_slot")
|
| 267 |
+
.attr("data-reserved-slot", "category_icon_slot")
|
| 268 |
+
.attr("data-slot-policy", "neutral_placeholder")
|
| 269 |
+
.attr("data-anchor", "row-centerline")
|
| 270 |
+
.attr("data-category", dimension)
|
| 271 |
+
.attr("data-collision-rule", "avoid category label and bar start")
|
| 272 |
+
.attr("data-no-embedded-raster", "true")
|
| 273 |
+
.attr("data-no-copied-reference-art", "true")
|
| 274 |
+
.attr("data-bbox", `${(iconX - iconSize / 2).toFixed(1)} ${(rowY - iconSize / 2).toFixed(1)} ${iconSize.toFixed(1)} ${iconSize.toFixed(1)}`);
|
| 275 |
+
|
| 276 |
+
iconGroup.append("rect")
|
| 277 |
+
.attr("x", iconX - iconSize / 2)
|
| 278 |
+
.attr("y", rowY - iconSize / 2)
|
| 279 |
+
.attr("width", iconSize)
|
| 280 |
+
.attr("height", iconSize)
|
| 281 |
+
.attr("rx", iconSize * 0.26)
|
| 282 |
+
.attr("fill", "#FFFFFF")
|
| 283 |
+
.attr("stroke", "#B9D2EC")
|
| 284 |
+
.attr("stroke-width", 1.1);
|
| 285 |
+
|
| 286 |
+
iconGroup.append("circle")
|
| 287 |
+
.attr("class", "category-icon-neutral-placeholder")
|
| 288 |
+
.attr("data-placeholder-role", "neutral_row_asset_slot_marker")
|
| 289 |
+
.attr("cx", iconX)
|
| 290 |
+
.attr("cy", rowY)
|
| 291 |
+
.attr("r", iconSize * 0.16)
|
| 292 |
+
.attr("fill", primaryColor)
|
| 293 |
+
.attr("opacity", 0.18);
|
| 294 |
+
iconGroup.append("line")
|
| 295 |
+
.attr("class", "category-icon-neutral-placeholder")
|
| 296 |
+
.attr("data-placeholder-role", "neutral_row_asset_slot_marker")
|
| 297 |
+
.attr("x1", iconX - iconSize * 0.16)
|
| 298 |
+
.attr("x2", iconX + iconSize * 0.16)
|
| 299 |
+
.attr("y1", rowY)
|
| 300 |
+
.attr("y2", rowY)
|
| 301 |
+
.attr("stroke", primaryColor)
|
| 302 |
+
.attr("stroke-width", 1.2)
|
| 303 |
+
.attr("stroke-linecap", "round")
|
| 304 |
+
.attr("opacity", 0.20);
|
| 305 |
+
|
| 306 |
+
svg.append("rect")
|
| 307 |
+
.attr("class", "mark lollipop-bar")
|
| 308 |
+
.attr("data-role", "lollipop_bar")
|
| 309 |
+
.attr("data-category", dimension)
|
| 310 |
+
.attr("data-value", value)
|
| 311 |
+
.attr("x", barStart)
|
| 312 |
+
.attr("y", rowY - barHeight / 2)
|
| 313 |
+
.attr("width", Math.max(2, barEnd - barStart))
|
| 314 |
+
.attr("height", barHeight)
|
| 315 |
+
.attr("rx", barHeight / 2)
|
| 316 |
+
.attr("fill", "url(#lollipop01-bar-gradient)");
|
| 317 |
+
|
| 318 |
+
const formattedValue = `${chartUtils.format.scaledText(value)}${valueUnit ? ` ${valueUnit}` : ''}`;
|
| 319 |
+
const valueX = Math.min(barEnd + 18, content.x + content.width - 18);
|
| 320 |
+
svg.append("text")
|
| 321 |
+
.attr("class", "value")
|
| 322 |
+
.attr("data-role", "endpoint_value_label")
|
| 323 |
+
.attr("data-category", dimension)
|
| 324 |
+
.attr("data-value", value)
|
| 325 |
+
.attr("x", valueX)
|
| 326 |
+
.attr("y", rowY)
|
| 327 |
+
.attr("dy", "0.35em")
|
| 328 |
+
.attr("text-anchor", "start")
|
| 329 |
+
.attr("font-family", typography.annotation.font_family || "Arial, Helvetica, sans-serif")
|
| 330 |
+
.attr("font-size", Math.max(12, Math.min(18, rowHeight * 0.32)))
|
| 331 |
+
.attr("font-weight", 800)
|
| 332 |
+
.attr("fill", "#0758B8")
|
| 333 |
+
.text(formattedValue);
|
| 334 |
+
});
|
| 335 |
+
|
| 336 |
+
const axisY = content.y + content.height + 34;
|
| 337 |
+
svg.append("line")
|
| 338 |
+
.attr("class", "axis-baseline")
|
| 339 |
+
.attr("x1", barStart)
|
| 340 |
+
.attr("x2", barMaxEnd)
|
| 341 |
+
.attr("y1", axisY)
|
| 342 |
+
.attr("y2", axisY)
|
| 343 |
+
.attr("stroke", "#B9CCE2")
|
| 344 |
+
.attr("stroke-width", 1.2);
|
| 345 |
+
|
| 346 |
+
tickValues.forEach(value => {
|
| 347 |
+
const x = xScale(value);
|
| 348 |
+
svg.append("line")
|
| 349 |
+
.attr("class", "axis-tick")
|
| 350 |
+
.attr("x1", x)
|
| 351 |
+
.attr("x2", x)
|
| 352 |
+
.attr("y1", axisY)
|
| 353 |
+
.attr("y2", axisY + 8)
|
| 354 |
+
.attr("stroke", "#B9CCE2")
|
| 355 |
+
.attr("stroke-width", 1);
|
| 356 |
+
svg.append("text")
|
| 357 |
+
.attr("class", "axis-tick-label")
|
| 358 |
+
.attr("x", x)
|
| 359 |
+
.attr("y", axisY + 26)
|
| 360 |
+
.attr("text-anchor", "middle")
|
| 361 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 362 |
+
.attr("font-size", 13)
|
| 363 |
+
.attr("fill", "#465A76")
|
| 364 |
+
.text(chartUtils.format.scaledText(value));
|
| 365 |
+
});
|
| 366 |
+
|
| 367 |
+
svg.append("text")
|
| 368 |
+
.attr("class", "axis-title")
|
| 369 |
+
.attr("x", (barStart + barMaxEnd) / 2)
|
| 370 |
+
.attr("y", axisY + 56)
|
| 371 |
+
.attr("text-anchor", "middle")
|
| 372 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 373 |
+
.attr("font-size", 14)
|
| 374 |
+
.attr("font-weight", 650)
|
| 375 |
+
.attr("fill", "#0758B8")
|
| 376 |
+
.text(`${valueField.replace(/([a-z])([A-Z])/g, "$1 $2")}${valueUnit ? ` (${valueUnit})` : ""}`);
|
| 377 |
+
|
| 378 |
+
return svg.node();
|
| 379 |
+
}
|
modules/chart_engine/template/d3-js/type15_lollipop_chart/lollipop_icon_chart_02.js
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Lollipop Chart",
|
| 5 |
+
"chart_name": "lollipop_icon_chart_02",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 12], [0, "inf"]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": [],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": [],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "overlay",
|
| 19 |
+
"icon_label": "side",
|
| 20 |
+
"has_x_axis": "no",
|
| 21 |
+
"has_y_axis": "no"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data;
|
| 28 |
+
const chartData = jsonData.data.data;
|
| 29 |
+
const variables = jsonData.variables || {};
|
| 30 |
+
const typography = jsonData.typography || {
|
| 31 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 32 |
+
label: { font_family: "Arial", font_size: "14px", font_weight: "bold" },
|
| 33 |
+
description: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 34 |
+
annotation: { font_family: "Arial", font_size: "16px", font_weight: "bold" }
|
| 35 |
+
};
|
| 36 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 37 |
+
const images = jsonData.images || { field: {}, other: {} };
|
| 38 |
+
|
| 39 |
+
d3.select(containerSelector).html("");
|
| 40 |
+
|
| 41 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key || "dimension";
|
| 42 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key || "value";
|
| 43 |
+
let valueUnit = "";
|
| 44 |
+
if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
|
| 45 |
+
valueUnit = chartUtils.schema.channel(jsonData, "y").unit;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const temporalOrdering = getTemporalOrdering(chartData, dimensionField);
|
| 49 |
+
const sortedData = temporalOrdering.isTemporal
|
| 50 |
+
? [...chartData].sort((a, b) => temporalOrdering.key(a[dimensionField]) - temporalOrdering.key(b[dimensionField]))
|
| 51 |
+
: [...chartData].sort((a, b) => +b[valueField] - +a[valueField]);
|
| 52 |
+
const rowCount = sortedData.length;
|
| 53 |
+
const width = Math.max(820, Math.round((variables.width || 600) * 1.42));
|
| 54 |
+
const height = Math.max(860, Math.round(width * 0.98));
|
| 55 |
+
const margin = { top: 74, right: 38, bottom: 126, left: 86 };
|
| 56 |
+
const plotWidth = width - margin.left - margin.right;
|
| 57 |
+
const plotHeight = height - margin.top - margin.bottom;
|
| 58 |
+
const baselineY = margin.top + plotHeight;
|
| 59 |
+
const primaryColor = "#214D2B";
|
| 60 |
+
const deepAccent = "#214D2B";
|
| 61 |
+
const forest = "#193C25";
|
| 62 |
+
const mutedText = "#45413C";
|
| 63 |
+
const gridColor = "#D9CEC1";
|
| 64 |
+
|
| 65 |
+
const svg = d3.select(containerSelector)
|
| 66 |
+
.append("svg")
|
| 67 |
+
.attr("width", width)
|
| 68 |
+
.attr("height", height)
|
| 69 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 70 |
+
.attr("data-temporal-dimension", temporalOrdering.isTemporal ? "true" : "false")
|
| 71 |
+
.attr("data-temporal-order-rule", temporalOrdering.isTemporal ? temporalOrdering.rule : "not-temporal-ranked-by-value")
|
| 72 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 73 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 74 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 75 |
+
|
| 76 |
+
const defs = svg.append("defs");
|
| 77 |
+
const gradientId = "lollipop02-bar-gradient";
|
| 78 |
+
const barGradient = defs.append("linearGradient")
|
| 79 |
+
.attr("id", gradientId)
|
| 80 |
+
.attr("x1", "0%")
|
| 81 |
+
.attr("x2", "0%")
|
| 82 |
+
.attr("y1", "100%")
|
| 83 |
+
.attr("y2", "0%");
|
| 84 |
+
barGradient.append("stop").attr("offset", "0%").attr("stop-color", "#18361F");
|
| 85 |
+
barGradient.append("stop").attr("offset", "62%").attr("stop-color", primaryColor);
|
| 86 |
+
barGradient.append("stop").attr("offset", "100%").attr("stop-color", "#315F3A");
|
| 87 |
+
|
| 88 |
+
const shadow = defs.append("filter")
|
| 89 |
+
.attr("id", "lollipop02-soft-shadow")
|
| 90 |
+
.attr("x", "-20%")
|
| 91 |
+
.attr("y", "-20%")
|
| 92 |
+
.attr("width", "140%")
|
| 93 |
+
.attr("height", "140%");
|
| 94 |
+
shadow.append("feDropShadow")
|
| 95 |
+
.attr("dx", 0)
|
| 96 |
+
.attr("dy", 1.5)
|
| 97 |
+
.attr("stdDeviation", 1.6)
|
| 98 |
+
.attr("flood-color", "#5A422C")
|
| 99 |
+
.attr("flood-opacity", 0.16);
|
| 100 |
+
|
| 101 |
+
svg.append("rect")
|
| 102 |
+
.attr("class", "lollipop02-chart-surface")
|
| 103 |
+
.attr("data-role", "chart_surface")
|
| 104 |
+
.attr("x", 0)
|
| 105 |
+
.attr("y", 0)
|
| 106 |
+
.attr("width", width)
|
| 107 |
+
.attr("height", height)
|
| 108 |
+
.attr("fill", "transparent");
|
| 109 |
+
|
| 110 |
+
const maxValue = d3.max(sortedData, d => +d[valueField]) || 1;
|
| 111 |
+
const yScale = d3.scaleLinear()
|
| 112 |
+
.domain([0, maxValue * 1.20])
|
| 113 |
+
.nice()
|
| 114 |
+
.range([baselineY, margin.top]);
|
| 115 |
+
|
| 116 |
+
const xScale = d3.scaleBand()
|
| 117 |
+
.domain(sortedData.map(d => d[dimensionField]))
|
| 118 |
+
.range([margin.left, margin.left + plotWidth])
|
| 119 |
+
.padding(rowCount >= 11 ? 0.19 : 0.25);
|
| 120 |
+
|
| 121 |
+
const bandwidth = xScale.bandwidth();
|
| 122 |
+
const barWidth = Math.max(22, Math.min(44, bandwidth * 0.68));
|
| 123 |
+
const iconRadius = Math.max(16, Math.min(28, bandwidth * 0.42));
|
| 124 |
+
const valueFont = Math.max(15, Math.min(24, bandwidth * 0.38));
|
| 125 |
+
const labelFont = rowCount >= 11 ? 9.6 : 11;
|
| 126 |
+
const labelMaxWidth = bandwidth * 1.04;
|
| 127 |
+
|
| 128 |
+
const tickValues = yScale.ticks(5);
|
| 129 |
+
tickValues.forEach(value => {
|
| 130 |
+
const y = yScale(value);
|
| 131 |
+
svg.append("line")
|
| 132 |
+
.attr("class", "gridline")
|
| 133 |
+
.attr("data-role", "y_gridline")
|
| 134 |
+
.attr("x1", margin.left)
|
| 135 |
+
.attr("x2", margin.left + plotWidth)
|
| 136 |
+
.attr("y1", y)
|
| 137 |
+
.attr("y2", y)
|
| 138 |
+
.attr("stroke", gridColor)
|
| 139 |
+
.attr("stroke-width", 1.2)
|
| 140 |
+
.attr("stroke-dasharray", "5,5")
|
| 141 |
+
.attr("opacity", 0.82);
|
| 142 |
+
|
| 143 |
+
svg.append("text")
|
| 144 |
+
.attr("class", "axis-tick-label")
|
| 145 |
+
.attr("data-role", "y_axis_tick")
|
| 146 |
+
.attr("x", margin.left - 18)
|
| 147 |
+
.attr("y", y)
|
| 148 |
+
.attr("dy", "0.35em")
|
| 149 |
+
.attr("text-anchor", "end")
|
| 150 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 151 |
+
.attr("font-size", 16)
|
| 152 |
+
.attr("font-weight", 500)
|
| 153 |
+
.attr("fill", mutedText)
|
| 154 |
+
.text(chartUtils.format.scaledText(value));
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
svg.append("line")
|
| 158 |
+
.attr("class", "axis y-axis")
|
| 159 |
+
.attr("data-role", "left_y_axis")
|
| 160 |
+
.attr("x1", margin.left)
|
| 161 |
+
.attr("x2", margin.left)
|
| 162 |
+
.attr("y1", margin.top)
|
| 163 |
+
.attr("y2", baselineY)
|
| 164 |
+
.attr("stroke", "#B8AA9C")
|
| 165 |
+
.attr("stroke-width", 1.4);
|
| 166 |
+
|
| 167 |
+
svg.append("line")
|
| 168 |
+
.attr("class", "axis baseline")
|
| 169 |
+
.attr("data-role", "zero_baseline")
|
| 170 |
+
.attr("x1", margin.left)
|
| 171 |
+
.attr("x2", margin.left + plotWidth)
|
| 172 |
+
.attr("y1", baselineY)
|
| 173 |
+
.attr("y2", baselineY)
|
| 174 |
+
.attr("stroke", "#5B554E")
|
| 175 |
+
.attr("stroke-width", 1.4);
|
| 176 |
+
|
| 177 |
+
const measureLabel = displayFieldName(valueField, valueUnit).toUpperCase();
|
| 178 |
+
const pillPrimaryLabel = /incident|count/i.test(measureLabel)
|
| 179 |
+
? "CONFLICT INCIDENTS"
|
| 180 |
+
: measureLabel;
|
| 181 |
+
const pillSecondaryLabel = valueUnit ? `(${valueUnit.toUpperCase()})` : (/incident|count/i.test(measureLabel) ? "(COUNT)" : "");
|
| 182 |
+
const pillFontSize = pillPrimaryLabel.length > 24 ? 13 : 15;
|
| 183 |
+
const pillSubFontSize = 13;
|
| 184 |
+
const pillIconSize = 42;
|
| 185 |
+
const maxPillWidth = Math.min(plotWidth * 0.56, width - margin.left - 54);
|
| 186 |
+
const pillTextWidth = Math.max(
|
| 187 |
+
pillPrimaryLabel.length * pillFontSize * 0.58,
|
| 188 |
+
pillSecondaryLabel.length * pillSubFontSize * 0.58
|
| 189 |
+
);
|
| 190 |
+
const pillWidth = Math.min(maxPillWidth, Math.max(210, pillIconSize + pillTextWidth + 52));
|
| 191 |
+
const pillHeight = pillSecondaryLabel ? 48 : 40;
|
| 192 |
+
const pillX = margin.left - 44;
|
| 193 |
+
const pillY = margin.top - 62;
|
| 194 |
+
svg.append("rect")
|
| 195 |
+
.attr("class", "incident-axis-pill")
|
| 196 |
+
.attr("data-role", "incident_axis_pill")
|
| 197 |
+
.attr("x", pillX)
|
| 198 |
+
.attr("y", pillY)
|
| 199 |
+
.attr("width", pillWidth)
|
| 200 |
+
.attr("height", pillHeight)
|
| 201 |
+
.attr("rx", 10)
|
| 202 |
+
.attr("fill", forest);
|
| 203 |
+
|
| 204 |
+
const pillIconCx = pillX + pillIconSize / 2 + 4;
|
| 205 |
+
const pillIconCy = pillY + pillHeight / 2;
|
| 206 |
+
const measureIconSlot = svg.append("g")
|
| 207 |
+
.attr("class", "reserved-asset-slot measure-pill-icon-slot")
|
| 208 |
+
.attr("data-asset-slot", "measure_pill_icon_slot")
|
| 209 |
+
.attr("data-reserved-slot", "measure_pill_icon_slot")
|
| 210 |
+
.attr("data-slot-policy", "image2_asset_or_neutral_placeholder")
|
| 211 |
+
.attr("data-asset-source-policy", "image2_asset_or_neutral_placeholder")
|
| 212 |
+
.attr("data-anchor", "measure-pill-left-center")
|
| 213 |
+
.attr("data-collision-rule", "stay inside measure pill and avoid measure text")
|
| 214 |
+
.attr("data-bbox", `${(pillIconCx - pillIconSize / 2).toFixed(1)} ${(pillIconCy - pillIconSize / 2).toFixed(1)} ${pillIconSize.toFixed(1)} ${pillIconSize.toFixed(1)}`)
|
| 215 |
+
.attr("data-bbox-policy", "circle centered in measure pill defines visible reserved bbox")
|
| 216 |
+
.attr("data-mask-policy", "downstream image2 may replace only inside the circular slot")
|
| 217 |
+
.attr("data-no-embedded-raster", "true")
|
| 218 |
+
.attr("data-no-copied-reference-art", "true");
|
| 219 |
+
measureIconSlot.append("circle")
|
| 220 |
+
.attr("cx", pillIconCx)
|
| 221 |
+
.attr("cy", pillIconCy)
|
| 222 |
+
.attr("r", pillIconSize / 2)
|
| 223 |
+
.attr("fill", "#F8F2E8")
|
| 224 |
+
.attr("stroke", forest)
|
| 225 |
+
.attr("stroke-width", 2);
|
| 226 |
+
measureIconSlot.append("circle")
|
| 227 |
+
.attr("cx", pillIconCx)
|
| 228 |
+
.attr("cy", pillIconCy)
|
| 229 |
+
.attr("r", pillIconSize * 0.17)
|
| 230 |
+
.attr("fill", forest)
|
| 231 |
+
.attr("opacity", 0.24);
|
| 232 |
+
|
| 233 |
+
const pillText = svg.append("text")
|
| 234 |
+
.attr("class", "incident-axis-pill-label")
|
| 235 |
+
.attr("x", pillX + pillIconSize + 18)
|
| 236 |
+
.attr("y", pillY + (pillSecondaryLabel ? 19 : 25))
|
| 237 |
+
.attr("text-anchor", "start")
|
| 238 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 239 |
+
.attr("font-size", pillFontSize)
|
| 240 |
+
.attr("font-weight", 800)
|
| 241 |
+
.attr("fill", "#FFFFFF");
|
| 242 |
+
pillText.append("tspan")
|
| 243 |
+
.attr("x", pillX + pillIconSize + 18)
|
| 244 |
+
.text(pillPrimaryLabel);
|
| 245 |
+
if (pillSecondaryLabel) {
|
| 246 |
+
pillText.append("tspan")
|
| 247 |
+
.attr("x", pillX + pillIconSize + 18)
|
| 248 |
+
.attr("dy", pillSubFontSize + 2)
|
| 249 |
+
.attr("font-size", pillSubFontSize)
|
| 250 |
+
.attr("font-weight", 700)
|
| 251 |
+
.text(pillSecondaryLabel);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
const artSlot = {
|
| 255 |
+
x: margin.left + plotWidth * 0.51,
|
| 256 |
+
y: margin.top - 18,
|
| 257 |
+
width: plotWidth * 0.43,
|
| 258 |
+
height: plotHeight * 0.45
|
| 259 |
+
};
|
| 260 |
+
const hasPrimaryImage = Boolean(images.other && images.other.primary);
|
| 261 |
+
const artGroup = svg.append("g")
|
| 262 |
+
.attr("class", "reserved-asset-slot right-wildlife-art-slot")
|
| 263 |
+
.attr("data-asset-slot", "right_wildlife_art_slot")
|
| 264 |
+
.attr("data-reserved-slot", "right_wildlife_art_slot")
|
| 265 |
+
.attr("data-slot-policy", "input_asset_or_image2_asset")
|
| 266 |
+
.attr("data-asset-source-policy", "input_asset_or_image2_asset")
|
| 267 |
+
.attr("data-anchor", "upper-right-body-safe-area")
|
| 268 |
+
.attr("data-collision-rule", "avoid title subtitle y-axis endpoint values bars and category labels")
|
| 269 |
+
.attr("data-bbox", `${artSlot.x.toFixed(1)} ${artSlot.y.toFixed(1)} ${artSlot.width.toFixed(1)} ${artSlot.height.toFixed(1)}`)
|
| 270 |
+
.attr("data-bbox-policy", "rect x/y/width/height define the visible reserved bbox")
|
| 271 |
+
.attr("data-mask-policy", "downstream input/image2 asset may replace only inside this reserved slot")
|
| 272 |
+
.attr("data-no-embedded-raster", "true")
|
| 273 |
+
.attr("data-no-copied-reference-art", "true")
|
| 274 |
+
.attr("opacity", hasPrimaryImage ? 0.56 : 0.72);
|
| 275 |
+
artGroup.append("rect")
|
| 276 |
+
.attr("x", artSlot.x)
|
| 277 |
+
.attr("y", artSlot.y)
|
| 278 |
+
.attr("width", artSlot.width)
|
| 279 |
+
.attr("height", artSlot.height)
|
| 280 |
+
.attr("rx", 30)
|
| 281 |
+
.attr("fill", "#E6EBDD")
|
| 282 |
+
.attr("stroke", "#9AAE8B")
|
| 283 |
+
.attr("stroke-width", 2.2)
|
| 284 |
+
.attr("stroke-dasharray", "9,6");
|
| 285 |
+
artGroup.append("rect")
|
| 286 |
+
.attr("x", artSlot.x + artSlot.width * 0.07)
|
| 287 |
+
.attr("y", artSlot.y + artSlot.height * 0.10)
|
| 288 |
+
.attr("width", artSlot.width * 0.82)
|
| 289 |
+
.attr("height", artSlot.height * 0.70)
|
| 290 |
+
.attr("rx", 42)
|
| 291 |
+
.attr("fill", "#CAD6BE")
|
| 292 |
+
.attr("opacity", 0.46);
|
| 293 |
+
artGroup.append("rect")
|
| 294 |
+
.attr("x", artSlot.x + artSlot.width * 0.19)
|
| 295 |
+
.attr("y", artSlot.y + artSlot.height * 0.22)
|
| 296 |
+
.attr("width", artSlot.width * 0.58)
|
| 297 |
+
.attr("height", artSlot.height * 0.48)
|
| 298 |
+
.attr("rx", 36)
|
| 299 |
+
.attr("fill", "#9FB390")
|
| 300 |
+
.attr("opacity", 0.34);
|
| 301 |
+
const hatchCount = 9;
|
| 302 |
+
for (let h = 0; h < hatchCount; h += 1) {
|
| 303 |
+
const x0 = artSlot.x + artSlot.width * (0.06 + h * 0.105);
|
| 304 |
+
artGroup.append("line")
|
| 305 |
+
.attr("x1", x0)
|
| 306 |
+
.attr("y1", artSlot.y + artSlot.height * 0.84)
|
| 307 |
+
.attr("x2", x0 + artSlot.width * 0.16)
|
| 308 |
+
.attr("y2", artSlot.y + artSlot.height * 0.52)
|
| 309 |
+
.attr("stroke", "#607A5E")
|
| 310 |
+
.attr("stroke-width", 1.8)
|
| 311 |
+
.attr("opacity", 0.32);
|
| 312 |
+
}
|
| 313 |
+
artGroup.append("rect")
|
| 314 |
+
.attr("x", artSlot.x + artSlot.width * 0.12)
|
| 315 |
+
.attr("y", artSlot.y + artSlot.height * 0.78)
|
| 316 |
+
.attr("width", artSlot.width * 0.72)
|
| 317 |
+
.attr("height", artSlot.height * 0.08)
|
| 318 |
+
.attr("rx", 12)
|
| 319 |
+
.attr("fill", "#5C7656")
|
| 320 |
+
.attr("opacity", 0.22);
|
| 321 |
+
|
| 322 |
+
const targetSlotSize = Math.min(104, artSlot.width * 0.34);
|
| 323 |
+
const targetCx = artSlot.x + artSlot.width * 0.93;
|
| 324 |
+
const targetCy = artSlot.y + artSlot.height * 0.76;
|
| 325 |
+
const targetSlot = svg.append("g")
|
| 326 |
+
.attr("class", "reserved-asset-slot right-target-badge-slot")
|
| 327 |
+
.attr("data-asset-slot", "right_target_badge_slot")
|
| 328 |
+
.attr("data-reserved-slot", "right_target_badge_slot")
|
| 329 |
+
.attr("data-slot-policy", "image2_asset_or_neutral_placeholder")
|
| 330 |
+
.attr("data-asset-source-policy", "image2_asset_or_neutral_placeholder")
|
| 331 |
+
.attr("data-anchor", "right_wildlife_art_slot_lower_right")
|
| 332 |
+
.attr("data-collision-rule", "avoid endpoint values and bars")
|
| 333 |
+
.attr("data-bbox", `${(targetCx - targetSlotSize / 2).toFixed(1)} ${(targetCy - targetSlotSize / 2).toFixed(1)} ${targetSlotSize.toFixed(1)} ${targetSlotSize.toFixed(1)}`)
|
| 334 |
+
.attr("data-bbox-policy", "circle bbox defines visible reserved badge footprint")
|
| 335 |
+
.attr("data-mask-policy", "downstream image2 may replace only inside circular badge slot")
|
| 336 |
+
.attr("data-no-embedded-raster", "true")
|
| 337 |
+
.attr("data-no-copied-reference-art", "true")
|
| 338 |
+
.attr("opacity", 0.88);
|
| 339 |
+
targetSlot.append("circle")
|
| 340 |
+
.attr("cx", targetCx)
|
| 341 |
+
.attr("cy", targetCy)
|
| 342 |
+
.attr("r", targetSlotSize / 2)
|
| 343 |
+
.attr("fill", "#F8F2E8")
|
| 344 |
+
.attr("stroke", "#B94B43")
|
| 345 |
+
.attr("stroke-width", 5);
|
| 346 |
+
targetSlot.append("circle")
|
| 347 |
+
.attr("cx", targetCx)
|
| 348 |
+
.attr("cy", targetCy)
|
| 349 |
+
.attr("r", targetSlotSize * 0.34)
|
| 350 |
+
.attr("fill", "none")
|
| 351 |
+
.attr("stroke", "#B94B43")
|
| 352 |
+
.attr("stroke-width", 2.4)
|
| 353 |
+
.attr("stroke-dasharray", "5,6")
|
| 354 |
+
.attr("opacity", 0.76);
|
| 355 |
+
targetSlot.append("rect")
|
| 356 |
+
.attr("x", targetCx - targetSlotSize * 0.13)
|
| 357 |
+
.attr("y", targetCy - targetSlotSize * 0.13)
|
| 358 |
+
.attr("width", targetSlotSize * 0.26)
|
| 359 |
+
.attr("height", targetSlotSize * 0.26)
|
| 360 |
+
.attr("rx", 6)
|
| 361 |
+
.attr("fill", "#B94B43")
|
| 362 |
+
.attr("opacity", 0.42);
|
| 363 |
+
|
| 364 |
+
sortedData.forEach((d, i) => {
|
| 365 |
+
const dimension = d[dimensionField];
|
| 366 |
+
const value = +d[valueField];
|
| 367 |
+
const x = xScale(dimension) + bandwidth / 2;
|
| 368 |
+
const y = yScale(value);
|
| 369 |
+
const barTop = Math.min(y, baselineY - 2);
|
| 370 |
+
|
| 371 |
+
svg.append("rect")
|
| 372 |
+
.attr("class", "mark lollipop-bar")
|
| 373 |
+
.attr("data-role", "vertical_lollipop_bar")
|
| 374 |
+
.attr("data-category", dimension)
|
| 375 |
+
.attr("data-value", value)
|
| 376 |
+
.attr("x", x - barWidth / 2)
|
| 377 |
+
.attr("y", barTop)
|
| 378 |
+
.attr("width", barWidth)
|
| 379 |
+
.attr("height", Math.max(2, baselineY - barTop))
|
| 380 |
+
.attr("rx", 0)
|
| 381 |
+
.attr("data-endcap-shape", "flat-square-baseline-value-edge-capped-by-endpoint-badge")
|
| 382 |
+
.attr("fill", `url(#${gradientId})`)
|
| 383 |
+
.attr("filter", "url(#lollipop02-soft-shadow)");
|
| 384 |
+
|
| 385 |
+
const iconGroup = svg.append("g")
|
| 386 |
+
.attr("class", "reserved-asset-slot category-icon-slot endpoint-icon-badge")
|
| 387 |
+
.attr("data-asset-slot", "category_icon_slot")
|
| 388 |
+
.attr("data-reserved-slot", "category_icon_slot")
|
| 389 |
+
.attr("data-slot-policy", images.field && images.field[dimension] ? "input_asset" : "neutral_placeholder")
|
| 390 |
+
.attr("data-asset-source-policy", images.field && images.field[dimension] ? "input_asset" : "neutral_placeholder")
|
| 391 |
+
.attr("data-anchor", "bar-endpoint-centerline")
|
| 392 |
+
.attr("data-category", dimension)
|
| 393 |
+
.attr("data-collision-rule", "avoid neighboring endpoint badges and value labels")
|
| 394 |
+
.attr("data-bbox", `${(x - iconRadius).toFixed(1)} ${(y - iconRadius).toFixed(1)} ${(iconRadius * 2).toFixed(1)} ${(iconRadius * 2).toFixed(1)}`)
|
| 395 |
+
.attr("data-bbox-policy", "circle centered at bar endpoint defines visible reserved bbox")
|
| 396 |
+
.attr("data-mask-policy", "input/retrieved icon is clipped to endpoint circle")
|
| 397 |
+
.attr("data-no-embedded-raster", images.field && images.field[dimension] ? "input_asset_only" : "true")
|
| 398 |
+
.attr("data-no-copied-reference-art", "true");
|
| 399 |
+
|
| 400 |
+
const clipId = `lollipop02-icon-clip-${i}`;
|
| 401 |
+
defs.append("clipPath")
|
| 402 |
+
.attr("id", clipId)
|
| 403 |
+
.append("circle")
|
| 404 |
+
.attr("cx", x)
|
| 405 |
+
.attr("cy", y)
|
| 406 |
+
.attr("r", iconRadius * 0.74);
|
| 407 |
+
|
| 408 |
+
iconGroup.append("circle")
|
| 409 |
+
.attr("class", "endpoint-badge-bg")
|
| 410 |
+
.attr("cx", x)
|
| 411 |
+
.attr("cy", y)
|
| 412 |
+
.attr("r", iconRadius)
|
| 413 |
+
.attr("fill", "#FFFDF8")
|
| 414 |
+
.attr("stroke", forest)
|
| 415 |
+
.attr("stroke-width", 2)
|
| 416 |
+
.attr("filter", "url(#lollipop02-soft-shadow)");
|
| 417 |
+
|
| 418 |
+
if (images.field && images.field[dimension]) {
|
| 419 |
+
iconGroup.append("image")
|
| 420 |
+
.attr("class", "image")
|
| 421 |
+
.attr("data-type", "input_category_icon")
|
| 422 |
+
.attr("data-asset-source", "input_asset")
|
| 423 |
+
.attr("data-no-copied-reference-art", "true")
|
| 424 |
+
.attr("x", x - iconRadius * 0.72)
|
| 425 |
+
.attr("y", y - iconRadius * 0.72)
|
| 426 |
+
.attr("width", iconRadius * 1.44)
|
| 427 |
+
.attr("height", iconRadius * 1.44)
|
| 428 |
+
.attr("clip-path", `url(#${clipId})`)
|
| 429 |
+
.attr("preserveAspectRatio", "xMidYMid meet")
|
| 430 |
+
.attr("href", images.field[dimension])
|
| 431 |
+
.attr("xlink:href", images.field[dimension]);
|
| 432 |
+
} else {
|
| 433 |
+
iconGroup.append("circle")
|
| 434 |
+
.attr("cx", x)
|
| 435 |
+
.attr("cy", y)
|
| 436 |
+
.attr("r", iconRadius * 0.30)
|
| 437 |
+
.attr("fill", primaryColor)
|
| 438 |
+
.attr("opacity", 0.28);
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
const compactValue = chartUtils.format.scaledText(value);
|
| 442 |
+
const formattedValue = `${compactValue}${valueUnit ? ` ${valueUnit}` : ""}`;
|
| 443 |
+
const splitLongUnit = Boolean(valueUnit && String(valueUnit).length > 5);
|
| 444 |
+
const valueLabelFont = splitLongUnit ? Math.min(valueFont, 19) : valueFont;
|
| 445 |
+
const valueLabelMaxWidth = Math.min(Math.max(bandwidth * 1.18, barWidth * 2.1), 150);
|
| 446 |
+
const valueText = svg.append("text")
|
| 447 |
+
.attr("class", "value endpoint-value")
|
| 448 |
+
.attr("data-role", "endpoint_value_label")
|
| 449 |
+
.attr("data-category", dimension)
|
| 450 |
+
.attr("data-value", value)
|
| 451 |
+
.attr("data-label-layout", splitLongUnit ? "stacked-value-unit" : "single-line")
|
| 452 |
+
.attr("x", x)
|
| 453 |
+
.attr("y", y - iconRadius - (splitLongUnit ? 24 : 12))
|
| 454 |
+
.attr("text-anchor", "middle")
|
| 455 |
+
.attr("font-family", typography.annotation.font_family || "Arial, Helvetica, sans-serif")
|
| 456 |
+
.attr("font-size", valueLabelFont)
|
| 457 |
+
.attr("font-weight", 800)
|
| 458 |
+
.attr("fill", deepAccent);
|
| 459 |
+
if (splitLongUnit) {
|
| 460 |
+
valueText.append("tspan")
|
| 461 |
+
.attr("x", x)
|
| 462 |
+
.text(compactValue);
|
| 463 |
+
valueText.append("tspan")
|
| 464 |
+
.attr("x", x)
|
| 465 |
+
.attr("dy", 15)
|
| 466 |
+
.attr("font-size", Math.max(10, Math.min(13, valueLabelFont - 4)))
|
| 467 |
+
.attr("font-weight", 700)
|
| 468 |
+
.text(valueUnit);
|
| 469 |
+
} else {
|
| 470 |
+
const estimatedValueWidth = formattedValue.length * valueLabelFont * 0.58;
|
| 471 |
+
if (estimatedValueWidth > valueLabelMaxWidth) {
|
| 472 |
+
valueText
|
| 473 |
+
.attr("textLength", valueLabelMaxWidth)
|
| 474 |
+
.attr("lengthAdjust", "spacingAndGlyphs");
|
| 475 |
+
}
|
| 476 |
+
valueText.text(formattedValue);
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
const label = String(dimension);
|
| 480 |
+
const labelLines = wrapLabel(label, Math.max(7, Math.floor(bandwidth / 6.0)), 3);
|
| 481 |
+
const text = svg.append("text")
|
| 482 |
+
.attr("class", "label category-label")
|
| 483 |
+
.attr("data-role", "bottom_category_label")
|
| 484 |
+
.attr("data-category", dimension)
|
| 485 |
+
.attr("x", x)
|
| 486 |
+
.attr("y", baselineY + 27)
|
| 487 |
+
.attr("text-anchor", "middle")
|
| 488 |
+
.attr("font-family", typography.label.font_family || "Arial, Helvetica, sans-serif")
|
| 489 |
+
.attr("font-size", labelFont)
|
| 490 |
+
.attr("font-weight", 600)
|
| 491 |
+
.attr("fill", "#1F1F1F");
|
| 492 |
+
labelLines.forEach((line, lineIndex) => {
|
| 493 |
+
const estimatedWidth = line.length * labelFont * 0.56;
|
| 494 |
+
const fitAttrs = estimatedWidth > labelMaxWidth
|
| 495 |
+
? { textLength: labelMaxWidth, lengthAdjust: "spacingAndGlyphs" }
|
| 496 |
+
: {};
|
| 497 |
+
const tspan = text.append("tspan")
|
| 498 |
+
.attr("x", x)
|
| 499 |
+
.attr("dy", lineIndex === 0 ? 0 : labelFont * 1.12)
|
| 500 |
+
.text(line);
|
| 501 |
+
Object.keys(fitAttrs).forEach(attr => tspan.attr(attr, fitAttrs[attr]));
|
| 502 |
+
});
|
| 503 |
+
text.append("title").text(label);
|
| 504 |
+
});
|
| 505 |
+
|
| 506 |
+
function displayFieldName(field, unit) {
|
| 507 |
+
const spaced = String(field || "Incidents").replace(/([a-z])([A-Z])/g, "$1 $2");
|
| 508 |
+
const normalized = /incident/i.test(spaced) ? "Incidents" : spaced;
|
| 509 |
+
return unit ? `${normalized} (${unit})` : normalized;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
function getTemporalOrdering(rows, field) {
|
| 513 |
+
const values = rows.map(row => row[field]);
|
| 514 |
+
const fieldName = String(field || "").toLowerCase();
|
| 515 |
+
const hasTemporalName = /(year|date|month|quarter|season|timestamp|time|period|week|day)/.test(fieldName);
|
| 516 |
+
const parsed = values.map(value => temporalKey(value));
|
| 517 |
+
const parsedCount = parsed.filter(value => Number.isFinite(value)).length;
|
| 518 |
+
const isTemporal = hasTemporalName || (values.length > 0 && parsedCount === values.length);
|
| 519 |
+
return {
|
| 520 |
+
isTemporal,
|
| 521 |
+
rule: hasTemporalName ? "chronological-by-temporal-field-name" : "chronological-by-parseable-temporal-values",
|
| 522 |
+
key(value) {
|
| 523 |
+
const key = temporalKey(value);
|
| 524 |
+
return Number.isFinite(key) ? key : Number.MAX_SAFE_INTEGER;
|
| 525 |
+
}
|
| 526 |
+
};
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
function temporalKey(value) {
|
| 530 |
+
const raw = String(value == null ? "" : value).trim();
|
| 531 |
+
const lower = raw.toLowerCase();
|
| 532 |
+
const seasonOrder = {
|
| 533 |
+
spring: 1,
|
| 534 |
+
summer: 2,
|
| 535 |
+
autumn: 3,
|
| 536 |
+
fall: 3,
|
| 537 |
+
winter: 4
|
| 538 |
+
};
|
| 539 |
+
if (Object.prototype.hasOwnProperty.call(seasonOrder, lower)) {
|
| 540 |
+
return seasonOrder[lower];
|
| 541 |
+
}
|
| 542 |
+
const monthOrder = {
|
| 543 |
+
january: 1, jan: 1,
|
| 544 |
+
february: 2, feb: 2,
|
| 545 |
+
march: 3, mar: 3,
|
| 546 |
+
april: 4, apr: 4,
|
| 547 |
+
may: 5,
|
| 548 |
+
june: 6, jun: 6,
|
| 549 |
+
july: 7, jul: 7,
|
| 550 |
+
august: 8, aug: 8,
|
| 551 |
+
september: 9, sep: 9, sept: 9,
|
| 552 |
+
october: 10, oct: 10,
|
| 553 |
+
november: 11, nov: 11,
|
| 554 |
+
december: 12, dec: 12
|
| 555 |
+
};
|
| 556 |
+
if (Object.prototype.hasOwnProperty.call(monthOrder, lower)) {
|
| 557 |
+
return monthOrder[lower];
|
| 558 |
+
}
|
| 559 |
+
const quarterMatch = lower.match(/(?:q([1-4])|quarter\s*([1-4]))/);
|
| 560 |
+
if (quarterMatch) {
|
| 561 |
+
return +(quarterMatch[1] || quarterMatch[2]);
|
| 562 |
+
}
|
| 563 |
+
const yearMatch = lower.match(/\b(19|20)\d{2}\b/);
|
| 564 |
+
if (yearMatch && !Number.isNaN(+yearMatch[0])) {
|
| 565 |
+
return +yearMatch[0];
|
| 566 |
+
}
|
| 567 |
+
const parsedDate = Date.parse(raw);
|
| 568 |
+
if (!Number.isNaN(parsedDate) && /[-/,]|\b(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)/i.test(raw)) {
|
| 569 |
+
return parsedDate;
|
| 570 |
+
}
|
| 571 |
+
return Number.NaN;
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
function wrapLabel(label, maxChars, maxLines) {
|
| 575 |
+
const words = String(label)
|
| 576 |
+
.replace(/\//g, "/ ")
|
| 577 |
+
.replace(/\(/g, " (")
|
| 578 |
+
.replace(/\)/g, ") ")
|
| 579 |
+
.split(/\s+/)
|
| 580 |
+
.filter(Boolean);
|
| 581 |
+
const lines = [];
|
| 582 |
+
let current = "";
|
| 583 |
+
words.forEach(word => {
|
| 584 |
+
const candidate = current ? `${current} ${word}` : word;
|
| 585 |
+
if (candidate.length <= maxChars || !current) {
|
| 586 |
+
current = candidate;
|
| 587 |
+
} else {
|
| 588 |
+
lines.push(current);
|
| 589 |
+
current = word;
|
| 590 |
+
}
|
| 591 |
+
});
|
| 592 |
+
if (current) lines.push(current);
|
| 593 |
+
if (lines.length <= maxLines) return lines;
|
| 594 |
+
const kept = lines.slice(0, maxLines);
|
| 595 |
+
kept[maxLines - 1] = `${kept[maxLines - 1].slice(0, Math.max(3, maxChars - 1))}...`;
|
| 596 |
+
return kept;
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
return svg.node();
|
| 600 |
+
}
|
modules/chart_engine/template/d3-js/type15_lollipop_chart/lollipop_plain_chart_01.js
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Lollipop Chart",
|
| 5 |
+
"chart_name": "lollipop_plain_chart_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical", "temporal"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 24], [0, "inf"]],
|
| 10 |
+
"required_fields_icons": [],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": [],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": ["opacity", "stroke"],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "none",
|
| 20 |
+
"has_x_axis": "yes",
|
| 21 |
+
"has_y_axis": "yes"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data || {};
|
| 28 |
+
const chartData = (jsonData.data && jsonData.data.data) || [];
|
| 29 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 30 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 31 |
+
const chartUtilsFormatSample = chartUtils.format.autoText;
|
| 32 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 33 |
+
const chartUtilsStandard = {
|
| 34 |
+
schema: chartUtils.schema,
|
| 35 |
+
format: chartUtils.format,
|
| 36 |
+
text: chartUtils.text,
|
| 37 |
+
color: chartUtils.color,
|
| 38 |
+
legendLayout: chartUtils.legend.layout,
|
| 39 |
+
legendDraw: chartUtils.legend.draw,
|
| 40 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 41 |
+
};
|
| 42 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 43 |
+
x: { fallbackIndex: 0 },
|
| 44 |
+
y: { fallbackIndex: 1 },
|
| 45 |
+
y2: { fallbackIndex: 2 },
|
| 46 |
+
y3: { fallbackIndex: 3 },
|
| 47 |
+
size: { fallbackIndex: 2 },
|
| 48 |
+
group: { fallbackIndex: 2 },
|
| 49 |
+
group2: { fallbackIndex: 3 },
|
| 50 |
+
group3: { fallbackIndex: 4 }
|
| 51 |
+
});
|
| 52 |
+
const variables = jsonData.variables || {};
|
| 53 |
+
const typography = jsonData.typography || {
|
| 54 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "600" },
|
| 55 |
+
annotation: { font_family: "Arial", font_size: "11px", font_weight: "600" }
|
| 56 |
+
};
|
| 57 |
+
const sourceColors = jsonData.colors || {};
|
| 58 |
+
const colorOther = sourceColors.other || {};
|
| 59 |
+
|
| 60 |
+
d3.select(containerSelector).html("");
|
| 61 |
+
|
| 62 |
+
const width = Number(variables.width) || 600;
|
| 63 |
+
const baseHeight = Number(variables.height) || 600;
|
| 64 |
+
const height = Math.max(baseHeight, Math.max(chartData.length, 1) * 32 + 168);
|
| 65 |
+
const textColor = sourceColors.text_color || "#273044";
|
| 66 |
+
const primaryColor = colorOther.primary || "#f7941d";
|
| 67 |
+
const canvasColor = "none";
|
| 68 |
+
const panelColor = "none";
|
| 69 |
+
const inkColor = "#123724";
|
| 70 |
+
const mutedColor = "#69716d";
|
| 71 |
+
const deepGreen = "#083f1f";
|
| 72 |
+
const softGreen = "#edf4eb";
|
| 73 |
+
const gridColor = "#dfe2dc";
|
| 74 |
+
const trackColor = "#f3d8df";
|
| 75 |
+
const pinkStart = d3.interpolateRgb(primaryColor, "#de3f62")(0.72);
|
| 76 |
+
const pinkEnd = "#f4a7b5";
|
| 77 |
+
const labelTypography = typography.label || {};
|
| 78 |
+
const valueTypography = typography.annotation || labelTypography;
|
| 79 |
+
const labelFamily = labelTypography.font_family || "Arial";
|
| 80 |
+
const valueFamily = valueTypography.font_family || "Arial";
|
| 81 |
+
const labelWeight = labelTypography.font_weight || "600";
|
| 82 |
+
const valueWeight = valueTypography.font_weight || "600";
|
| 83 |
+
const baseLabelSize = parseFloat(labelTypography.font_size) || 12;
|
| 84 |
+
const baseValueSize = parseFloat(valueTypography.font_size) || 11;
|
| 85 |
+
|
| 86 |
+
function rgba(color, opacity) {
|
| 87 |
+
const c = d3.rgb(color);
|
| 88 |
+
return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function humanizeField(name) {
|
| 92 |
+
return String(name || "")
|
| 93 |
+
.replace(/_/g, " ")
|
| 94 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 95 |
+
.replace(/\s+/g, " ")
|
| 96 |
+
.trim();
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
|
| 100 |
+
const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
|
| 101 |
+
const xField = xColumn.name || "dimension";
|
| 102 |
+
const yField = yColumn.name || "value";
|
| 103 |
+
const xUnit = xColumn.unit && xColumn.unit !== "none" ? xColumn.unit : "";
|
| 104 |
+
const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
|
| 105 |
+
|
| 106 |
+
function parseTemporalSortValue(value) {
|
| 107 |
+
const text = String(value ?? "").trim();
|
| 108 |
+
const year = text.match(/^(-?\d{4})$/);
|
| 109 |
+
if (year) return Number(year[1]) * 10000;
|
| 110 |
+
const date = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
|
| 111 |
+
if (date) return Number(date[1]) * 10000 + Number(date[2]) * 100 + Number(date[3] || 1);
|
| 112 |
+
const seasonOrder = { winter: 1, spring: 2, summer: 3, fall: 4, autumn: 4 };
|
| 113 |
+
let season = text.match(/^(winter|spring|summer|fall|autumn)\s+(-?\d{4})$/i);
|
| 114 |
+
if (season) return Number(season[2]) * 10 + seasonOrder[season[1].toLowerCase()];
|
| 115 |
+
season = text.match(/^(-?\d{4})\s+(winter|spring|summer|fall|autumn)$/i);
|
| 116 |
+
if (season) return Number(season[1]) * 10 + seasonOrder[season[2].toLowerCase()];
|
| 117 |
+
return NaN;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function formatNumber(value) {
|
| 121 |
+
const numericValue = Number(value);
|
| 122 |
+
if (!Number.isFinite(numericValue)) return String(value ?? "");
|
| 123 |
+
const absValue = Math.abs(numericValue);
|
| 124 |
+
if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
|
| 125 |
+
if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
|
| 126 |
+
if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}K`;
|
| 127 |
+
return d3.format(",.4~g")(numericValue);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function formatLocalValue(value, includeUnit = true) {
|
| 131 |
+
const formatted = formatNumber(value);
|
| 132 |
+
if (!includeUnit || !yUnit) return formatted;
|
| 133 |
+
const prefixMatch = String(yUnit).match(/^([$€£¥])(.+)?$/);
|
| 134 |
+
if (prefixMatch) return `${prefixMatch[1]}${formatted}${prefixMatch[2] || ""}`;
|
| 135 |
+
if (yUnit === "%") return `${formatted}%`;
|
| 136 |
+
return `${formatted} ${yUnit}`;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function formatDimension(row) {
|
| 140 |
+
const value = row[xField];
|
| 141 |
+
return xUnit ? `${value} ${xUnit}` : String(value ?? "");
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
function formatFieldName(name, fallback) {
|
| 145 |
+
const text = String(name || fallback || "Value")
|
| 146 |
+
.replace(/[_-]+/g, " ")
|
| 147 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 148 |
+
.replace(/\s+/g, " ")
|
| 149 |
+
.trim();
|
| 150 |
+
return text || fallback || "Value";
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const xLabel = formatFieldName(xColumn.label || xColumn.name, "Category");
|
| 154 |
+
const yLabel = formatFieldName(yColumn.label || yColumn.name, "Value");
|
| 155 |
+
|
| 156 |
+
const rows = chartData.map((d, index) => {
|
| 157 |
+
const label = formatDimension(d);
|
| 158 |
+
return {
|
| 159 |
+
...d,
|
| 160 |
+
__index: index,
|
| 161 |
+
__label: label,
|
| 162 |
+
__key: `${label}__${index}`,
|
| 163 |
+
__value: Number(d[yField]),
|
| 164 |
+
__time: parseTemporalSortValue(d[xField])
|
| 165 |
+
};
|
| 166 |
+
}).filter(d => Number.isFinite(d.__value));
|
| 167 |
+
|
| 168 |
+
if (!rows.length) return;
|
| 169 |
+
|
| 170 |
+
const isTemporal = xColumn.data_type === "temporal" || rows.every(d => Number.isFinite(d.__time));
|
| 171 |
+
const sortedData = [...rows].sort((a, b) => {
|
| 172 |
+
if (isTemporal && Number.isFinite(a.__time) && Number.isFinite(b.__time)) {
|
| 173 |
+
return a.__time - b.__time;
|
| 174 |
+
}
|
| 175 |
+
const byValue = b.__value - a.__value;
|
| 176 |
+
if (byValue !== 0) return byValue;
|
| 177 |
+
return String(a.__label).localeCompare(String(b.__label));
|
| 178 |
+
});
|
| 179 |
+
const sortMode = isTemporal ? "temporal" : "value_desc";
|
| 180 |
+
|
| 181 |
+
const svg = d3.select(containerSelector)
|
| 182 |
+
.append("svg")
|
| 183 |
+
.attr("width", "100%")
|
| 184 |
+
.attr("height", height)
|
| 185 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 186 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 187 |
+
.attr("xmlns", "http://www.w3.org/2000/svg");
|
| 188 |
+
|
| 189 |
+
const defs = svg.append("defs");
|
| 190 |
+
const cardShadow = defs.append("filter")
|
| 191 |
+
.attr("id", "lollipop_plain_01_card_shadow")
|
| 192 |
+
.attr("x", "-8%")
|
| 193 |
+
.attr("y", "-10%")
|
| 194 |
+
.attr("width", "116%")
|
| 195 |
+
.attr("height", "124%");
|
| 196 |
+
cardShadow.append("feDropShadow")
|
| 197 |
+
.attr("dx", 0)
|
| 198 |
+
.attr("dy", 2)
|
| 199 |
+
.attr("stdDeviation", 2.2)
|
| 200 |
+
.attr("flood-color", "#5b695f")
|
| 201 |
+
.attr("flood-opacity", 0.12);
|
| 202 |
+
|
| 203 |
+
const measure = svg.append("g").attr("visibility", "hidden");
|
| 204 |
+
function measureLabelText(text, fontSize, family, weight) {
|
| 205 |
+
const node = measure.append("text")
|
| 206 |
+
.style("font-family", family)
|
| 207 |
+
.style("font-size", `${fontSize}px`)
|
| 208 |
+
.style("font-weight", weight)
|
| 209 |
+
.text(text)
|
| 210 |
+
.node();
|
| 211 |
+
const bboxWidth = node ? node.textContent.length * 7 : 0;
|
| 212 |
+
const approximateWidth = String(text).length * fontSize * 0.55;
|
| 213 |
+
const measuredWidth = Math.max(bboxWidth || 0, approximateWidth);
|
| 214 |
+
measure.selectAll("text").remove();
|
| 215 |
+
return measuredWidth;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function breakLongWord(word, maxWidth, fontSize, family, weight) {
|
| 219 |
+
const lines = [];
|
| 220 |
+
let current = "";
|
| 221 |
+
for (const char of String(word)) {
|
| 222 |
+
const next = current + char;
|
| 223 |
+
if (current && measureLabelText(next, fontSize, family, weight) > maxWidth) {
|
| 224 |
+
lines.push(`${current}-`);
|
| 225 |
+
current = char;
|
| 226 |
+
} else {
|
| 227 |
+
current = next;
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
if (current) lines.push(current);
|
| 231 |
+
return lines.length ? lines : [String(word)];
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
function wrapText(text, maxWidth, fontSize, family, weight) {
|
| 235 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 236 |
+
if (!words.length) return [String(text || "")];
|
| 237 |
+
const lines = [];
|
| 238 |
+
let current = "";
|
| 239 |
+
for (const word of words) {
|
| 240 |
+
const candidate = current ? `${current} ${word}` : word;
|
| 241 |
+
if (measureLabelText(candidate, fontSize, family, weight) <= maxWidth) {
|
| 242 |
+
current = candidate;
|
| 243 |
+
continue;
|
| 244 |
+
}
|
| 245 |
+
if (current) lines.push(current);
|
| 246 |
+
if (measureLabelText(word, fontSize, family, weight) <= maxWidth) {
|
| 247 |
+
current = word;
|
| 248 |
+
} else {
|
| 249 |
+
const broken = breakLongWord(word, maxWidth, fontSize, family, weight);
|
| 250 |
+
lines.push(...broken.slice(0, -1));
|
| 251 |
+
current = broken[broken.length - 1];
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
if (current) lines.push(current);
|
| 255 |
+
return lines.length ? lines : [String(text || "")];
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
const n = sortedData.length;
|
| 259 |
+
const labelFontSize = Math.max(8.5, Math.min(baseLabelSize, n > 18 ? 8.8 : n > 12 ? 9.8 : 12));
|
| 260 |
+
const valueFontSize = Math.max(8.5, Math.min(baseValueSize, n > 18 ? 8.8 : n > 12 ? 9.6 : 11.2));
|
| 261 |
+
const longestLabelWidth = Math.max(
|
| 262 |
+
0,
|
| 263 |
+
...sortedData.map(d => measureLabelText(d.__label, labelFontSize, labelFamily, labelWeight))
|
| 264 |
+
);
|
| 265 |
+
const valueLabels = sortedData.map(d => formatLocalValue(d.__value, true));
|
| 266 |
+
const maxValueWidth = Math.max(
|
| 267 |
+
44,
|
| 268 |
+
...valueLabels.map(label => measureLabelText(label, valueFontSize, valueFamily, valueWeight))
|
| 269 |
+
);
|
| 270 |
+
const cardX = Math.max(22, Math.min(34, width * 0.055));
|
| 271 |
+
const cardY = 6;
|
| 272 |
+
const cardWidth = width - cardX * 2;
|
| 273 |
+
const cardHeight = height - cardY * 2;
|
| 274 |
+
const headerHeight = 10;
|
| 275 |
+
const footerHeight = 26;
|
| 276 |
+
const plotY = cardY + headerHeight + 10;
|
| 277 |
+
const plotBottom = cardY + cardHeight - footerHeight - 20;
|
| 278 |
+
const innerHeight = Math.max(180, plotBottom - plotY);
|
| 279 |
+
const iconSlotSize = Math.max(26, Math.min(54, innerHeight / Math.max(n, 1) * 0.56));
|
| 280 |
+
const iconColumnWidth = iconSlotSize + 20;
|
| 281 |
+
const leftMargin = Math.max(146, Math.min(Math.min(166, cardWidth * 0.31), longestLabelWidth + iconColumnWidth + 20));
|
| 282 |
+
const rightMargin = Math.max(58, Math.min(86, maxValueWidth + 28));
|
| 283 |
+
const plotX = cardX + leftMargin;
|
| 284 |
+
const innerWidth = Math.max(220, cardWidth - leftMargin - rightMargin);
|
| 285 |
+
const labelMaxWidth = Math.max(84, leftMargin - iconColumnWidth - 18);
|
| 286 |
+
|
| 287 |
+
const maxValue = Math.max(1, d3.max(sortedData, d => Math.max(0, d.__value)) || 1);
|
| 288 |
+
const xScale = d3.scaleLinear()
|
| 289 |
+
.domain([0, maxValue])
|
| 290 |
+
.nice(5)
|
| 291 |
+
.range([0, innerWidth]);
|
| 292 |
+
const yScale = d3.scaleBand()
|
| 293 |
+
.domain(sortedData.map(d => d.__key))
|
| 294 |
+
.range([0, innerHeight])
|
| 295 |
+
.paddingInner(n > 16 ? 0.18 : 0.26)
|
| 296 |
+
.paddingOuter(0.16);
|
| 297 |
+
const radius = Math.max(4.8, Math.min(9.5, yScale.bandwidth() * 0.32));
|
| 298 |
+
const stemWidth = Math.max(5.4, Math.min(10.5, yScale.bandwidth() * 0.31));
|
| 299 |
+
|
| 300 |
+
function fittedSize(text, maxWidth, startSize, minSize, family, weight) {
|
| 301 |
+
let fontSize = startSize;
|
| 302 |
+
while (fontSize > minSize && measureLabelText(text, fontSize, family, weight) > maxWidth) {
|
| 303 |
+
fontSize -= 0.5;
|
| 304 |
+
}
|
| 305 |
+
return fontSize;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
function markColor(index) {
|
| 309 |
+
const t = n <= 1 ? 0.5 : index / (n - 1);
|
| 310 |
+
return d3.interpolateRgb(pinkStart, pinkEnd)(Math.min(0.9, t * 0.85));
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
svg.append("rect")
|
| 314 |
+
.attr("class", "chart-canvas")
|
| 315 |
+
.attr("x", 0)
|
| 316 |
+
.attr("y", 0)
|
| 317 |
+
.attr("width", width)
|
| 318 |
+
.attr("height", height)
|
| 319 |
+
.attr("fill", canvasColor)
|
| 320 |
+
.attr("opacity", 0);
|
| 321 |
+
|
| 322 |
+
svg.append("rect")
|
| 323 |
+
.attr("class", "chart-style-panel")
|
| 324 |
+
.attr("x", cardX)
|
| 325 |
+
.attr("y", cardY)
|
| 326 |
+
.attr("width", cardWidth)
|
| 327 |
+
.attr("height", cardHeight)
|
| 328 |
+
.attr("rx", 8)
|
| 329 |
+
.attr("ry", 8)
|
| 330 |
+
.attr("fill", panelColor)
|
| 331 |
+
.attr("stroke", "none")
|
| 332 |
+
.attr("stroke-width", 0)
|
| 333 |
+
.attr("filter", null)
|
| 334 |
+
.attr("opacity", 0);
|
| 335 |
+
|
| 336 |
+
const fieldText = yLabel.toUpperCase();
|
| 337 |
+
|
| 338 |
+
svg.append("rect")
|
| 339 |
+
.attr("class", "plot-soft-wash")
|
| 340 |
+
.attr("x", plotX - 16)
|
| 341 |
+
.attr("y", plotY - 10)
|
| 342 |
+
.attr("width", innerWidth + rightMargin - 2)
|
| 343 |
+
.attr("height", innerHeight + 34)
|
| 344 |
+
.attr("rx", 8)
|
| 345 |
+
.attr("ry", 8)
|
| 346 |
+
.attr("fill", "#fffefa")
|
| 347 |
+
.attr("opacity", 0);
|
| 348 |
+
|
| 349 |
+
svg.append("text")
|
| 350 |
+
.attr("class", "axis-title")
|
| 351 |
+
.attr("x", plotX + innerWidth + rightMargin - 18)
|
| 352 |
+
.attr("y", plotY - 18)
|
| 353 |
+
.attr("text-anchor", "end")
|
| 354 |
+
.style("font-family", valueFamily)
|
| 355 |
+
.style("font-size", `${Math.max(8.5, valueFontSize * 0.95)}px`)
|
| 356 |
+
.style("font-weight", "800")
|
| 357 |
+
.style("fill", "#d63355")
|
| 358 |
+
.text(yUnit && yUnit !== "$" && yUnit !== "%" ? `${fieldText} (${yUnit})` : fieldText);
|
| 359 |
+
|
| 360 |
+
const rowCentersAbs = sortedData.map(d => plotY + yScale(d.__key) + yScale.bandwidth() / 2);
|
| 361 |
+
let illustrationSlotWidth = Math.max(104, Math.min(cardWidth * 0.25, innerWidth * 0.48));
|
| 362 |
+
let illustrationSlotHeight = Math.max(86, Math.min(cardHeight * 0.24, innerHeight * 0.26));
|
| 363 |
+
const valueSafeRight = plotX + innerWidth + rightMargin - 10;
|
| 364 |
+
const illustrationSlotX = Math.max(
|
| 365 |
+
plotX + innerWidth * 0.70,
|
| 366 |
+
valueSafeRight - illustrationSlotWidth
|
| 367 |
+
);
|
| 368 |
+
let illustrationSlotY = plotY + innerHeight - illustrationSlotHeight - 88;
|
| 369 |
+
let illustrationSlotOpacity = 0.34;
|
| 370 |
+
let illustrationCollisionResult = "visible_lower_right_body_slot_clear_of_axis_and_endpoint_values";
|
| 371 |
+
if (n <= 4 && rowCentersAbs.length > 1) {
|
| 372 |
+
const candidateGaps = rowCentersAbs.slice(0, -1).map((center, index) => ({
|
| 373 |
+
top: center + 34,
|
| 374 |
+
bottom: rowCentersAbs[index + 1] - 34,
|
| 375 |
+
index
|
| 376 |
+
})).filter(gap => gap.bottom - gap.top >= Math.max(64, illustrationSlotHeight * 0.72));
|
| 377 |
+
if (candidateGaps.length) {
|
| 378 |
+
const gap = candidateGaps[candidateGaps.length - 1];
|
| 379 |
+
illustrationSlotHeight = Math.min(illustrationSlotHeight, gap.bottom - gap.top);
|
| 380 |
+
illustrationSlotY = gap.top + (gap.bottom - gap.top - illustrationSlotHeight) / 2;
|
| 381 |
+
illustrationSlotOpacity = 0.28;
|
| 382 |
+
illustrationCollisionResult = "visible_shrunk_between_sparse_row_centerlines_clear_of_labels";
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
const axisSafeTop = plotY + innerHeight - illustrationSlotHeight - 38;
|
| 386 |
+
if (illustrationSlotY > axisSafeTop) {
|
| 387 |
+
illustrationSlotY = axisSafeTop;
|
| 388 |
+
}
|
| 389 |
+
const rowGapHeight = yScale.step ? yScale.step() - yScale.bandwidth() : 0;
|
| 390 |
+
if (n > 8 && rowGapHeight < illustrationSlotHeight * 0.42) {
|
| 391 |
+
illustrationSlotOpacity = 0.11;
|
| 392 |
+
illustrationCollisionResult = "low_opacity_dense_rows_reserved_slot_kept_semantic_without_competing_with_marks";
|
| 393 |
+
}
|
| 394 |
+
const illustrationSlot = svg.append("g")
|
| 395 |
+
.attr("class", "reserved-asset-slot right-illustration-slot")
|
| 396 |
+
.attr("data-asset-slot", "right_illustration_slot")
|
| 397 |
+
.attr("data-reserved-slot", "right_illustration_slot")
|
| 398 |
+
.attr("data-slot-policy", "image2_asset_or_input_asset_or_neutral_placeholder")
|
| 399 |
+
.attr("data-slot-source-policy", "image2-generated-or-input-provided")
|
| 400 |
+
.attr("data-anchor", "lower-right-body-safe-area")
|
| 401 |
+
.attr("data-bbox", `${illustrationSlotX.toFixed(1)} ${illustrationSlotY.toFixed(1)} ${illustrationSlotWidth.toFixed(1)} ${illustrationSlotHeight.toFixed(1)}`)
|
| 402 |
+
.attr("data-mask", "rounded-abstract-placeholder")
|
| 403 |
+
.attr("data-no-embedded-raster", "true")
|
| 404 |
+
.attr("data-no-copied-reference-art", "true")
|
| 405 |
+
.attr("data-collision-rule", "lower-right body reserved slot stays outside label column and axis; use low opacity or sparse-row gap placement to avoid endpoint values stems axis ticks category labels title subtitle and right header callout")
|
| 406 |
+
.attr("data-collision-result", illustrationCollisionResult)
|
| 407 |
+
.attr("data-slot-owner", "chart_body")
|
| 408 |
+
.attr("transform", `translate(${illustrationSlotX}, ${illustrationSlotY})`)
|
| 409 |
+
.attr("opacity", illustrationSlotOpacity)
|
| 410 |
+
.style("pointer-events", "none");
|
| 411 |
+
illustrationSlot.append("rect")
|
| 412 |
+
.attr("class", "right-illustration-slot-mask")
|
| 413 |
+
.attr("x", 0)
|
| 414 |
+
.attr("y", 0)
|
| 415 |
+
.attr("width", illustrationSlotWidth)
|
| 416 |
+
.attr("height", illustrationSlotHeight)
|
| 417 |
+
.attr("rx", Math.max(18, illustrationSlotWidth * 0.16))
|
| 418 |
+
.attr("ry", Math.max(18, illustrationSlotWidth * 0.16))
|
| 419 |
+
.attr("fill", rgba("#f5eadf", 0.50))
|
| 420 |
+
.attr("stroke", rgba(deepGreen, 0.16))
|
| 421 |
+
.attr("stroke-width", 1.1)
|
| 422 |
+
.attr("stroke-dasharray", "5,7");
|
| 423 |
+
illustrationSlot.append("ellipse")
|
| 424 |
+
.attr("cx", illustrationSlotWidth * 0.66)
|
| 425 |
+
.attr("cy", illustrationSlotHeight * 0.60)
|
| 426 |
+
.attr("rx", illustrationSlotWidth * 0.32)
|
| 427 |
+
.attr("ry", illustrationSlotHeight * 0.38)
|
| 428 |
+
.attr("fill", rgba("#7eae70", 0.18));
|
| 429 |
+
illustrationSlot.append("ellipse")
|
| 430 |
+
.attr("cx", illustrationSlotWidth * 0.34)
|
| 431 |
+
.attr("cy", illustrationSlotHeight * 0.50)
|
| 432 |
+
.attr("rx", illustrationSlotWidth * 0.28)
|
| 433 |
+
.attr("ry", illustrationSlotHeight * 0.44)
|
| 434 |
+
.attr("fill", rgba(pinkStart, 0.14));
|
| 435 |
+
illustrationSlot.append("path")
|
| 436 |
+
.attr("d", `M ${illustrationSlotWidth * 0.18} ${illustrationSlotHeight * 0.72} C ${illustrationSlotWidth * 0.38} ${illustrationSlotHeight * 0.42}, ${illustrationSlotWidth * 0.54} ${illustrationSlotHeight * 0.70}, ${illustrationSlotWidth * 0.82} ${illustrationSlotHeight * 0.28}`)
|
| 437 |
+
.attr("fill", "none")
|
| 438 |
+
.attr("stroke", rgba("#d63355", 0.26))
|
| 439 |
+
.attr("stroke-width", Math.max(6, illustrationSlotWidth * 0.045))
|
| 440 |
+
.attr("stroke-linecap", "round")
|
| 441 |
+
.attr("stroke-linejoin", "round");
|
| 442 |
+
[[0.18, 0.18], [0.30, 0.18], [0.42, 0.18], [0.18, 0.30], [0.30, 0.30], [0.42, 0.30]].forEach(([cx, cy]) => {
|
| 443 |
+
illustrationSlot.append("circle")
|
| 444 |
+
.attr("cx", illustrationSlotWidth * cx)
|
| 445 |
+
.attr("cy", illustrationSlotHeight * cy)
|
| 446 |
+
.attr("r", Math.max(2.2, illustrationSlotWidth * 0.018))
|
| 447 |
+
.attr("fill", rgba(deepGreen, 0.22));
|
| 448 |
+
});
|
| 449 |
+
|
| 450 |
+
const g = svg.append("g")
|
| 451 |
+
.attr("class", "lollipop-plot")
|
| 452 |
+
.attr("data-count", sortedData.length)
|
| 453 |
+
.attr("data-sort-mode", sortMode)
|
| 454 |
+
.attr("data-max-value", maxValue)
|
| 455 |
+
.attr("transform", `translate(${plotX}, ${plotY})`);
|
| 456 |
+
|
| 457 |
+
const tickValues = xScale.ticks(Math.max(4, Math.min(6, Math.floor(innerWidth / 70)))).filter((value, index, array) => {
|
| 458 |
+
return index === 0 || Math.abs(value - array[index - 1]) > 1e-9;
|
| 459 |
+
});
|
| 460 |
+
|
| 461 |
+
tickValues.forEach(tick => {
|
| 462 |
+
const x = xScale(tick);
|
| 463 |
+
g.append("line")
|
| 464 |
+
.attr("class", "gridline")
|
| 465 |
+
.attr("x1", x)
|
| 466 |
+
.attr("x2", x)
|
| 467 |
+
.attr("y1", 0)
|
| 468 |
+
.attr("y2", innerHeight)
|
| 469 |
+
.attr("stroke", tick === 0 ? rgba(mutedColor, 0.38) : rgba(gridColor, 0.96))
|
| 470 |
+
.attr("stroke-width", tick === 0 ? 1.2 : 1)
|
| 471 |
+
.attr("stroke-dasharray", tick === 0 ? null : "4,5");
|
| 472 |
+
g.append("text")
|
| 473 |
+
.attr("class", "axis-tick")
|
| 474 |
+
.attr("x", x)
|
| 475 |
+
.attr("y", innerHeight + 24)
|
| 476 |
+
.attr("text-anchor", "middle")
|
| 477 |
+
.style("font-family", valueFamily)
|
| 478 |
+
.style("font-size", `${Math.max(8, valueFontSize * 0.9)}px`)
|
| 479 |
+
.style("fill", rgba(mutedColor, 0.78))
|
| 480 |
+
.text(formatLocalValue(tick, false));
|
| 481 |
+
});
|
| 482 |
+
|
| 483 |
+
g.append("text")
|
| 484 |
+
.attr("class", "axis-title axis-title-bottom")
|
| 485 |
+
.attr("x", innerWidth / 2)
|
| 486 |
+
.attr("y", innerHeight + 45)
|
| 487 |
+
.attr("text-anchor", "middle")
|
| 488 |
+
.style("font-family", valueFamily)
|
| 489 |
+
.style("font-size", `${Math.max(9, valueFontSize * 0.95)}px`)
|
| 490 |
+
.style("font-weight", valueWeight)
|
| 491 |
+
.style("fill", rgba(mutedColor, 0.82))
|
| 492 |
+
.text(yUnit && yUnit !== "$" && yUnit !== "%" ? `${humanizeField(yField)} (${yUnit})` : humanizeField(yField));
|
| 493 |
+
|
| 494 |
+
sortedData.forEach((d, i) => {
|
| 495 |
+
const y = yScale(d.__key) + yScale.bandwidth() / 2;
|
| 496 |
+
const x = xScale(Math.max(0, d.__value));
|
| 497 |
+
const rowColor = markColor(i);
|
| 498 |
+
const valueLabel = valueLabels[i];
|
| 499 |
+
const directValueSize = Math.max(valueFontSize + 1.2, valueFontSize * 1.12);
|
| 500 |
+
const valueWidth = measureLabelText(valueLabel, directValueSize, valueFamily, "800") + 13;
|
| 501 |
+
const labelToRight = x + radius + 10 + valueWidth <= innerWidth + rightMargin - 12;
|
| 502 |
+
const valueX = labelToRight ? x + radius + 11 : Math.max(8, x - radius - 11);
|
| 503 |
+
|
| 504 |
+
g.append("line")
|
| 505 |
+
.attr("class", "stem-track")
|
| 506 |
+
.attr("data-label", d.__label)
|
| 507 |
+
.attr("data-value", d.__value)
|
| 508 |
+
.attr("data-index", i)
|
| 509 |
+
.attr("data-rank", i + 1)
|
| 510 |
+
.attr("data-sort-mode", sortMode)
|
| 511 |
+
.attr("x1", 0)
|
| 512 |
+
.attr("x2", innerWidth)
|
| 513 |
+
.attr("y1", y)
|
| 514 |
+
.attr("y2", y)
|
| 515 |
+
.attr("stroke", trackColor)
|
| 516 |
+
.attr("stroke-width", Math.max(2, stemWidth * 0.72))
|
| 517 |
+
.attr("stroke-linecap", "round")
|
| 518 |
+
.attr("opacity", 0.14);
|
| 519 |
+
|
| 520 |
+
g.append("line")
|
| 521 |
+
.attr("class", "stem mark")
|
| 522 |
+
.attr("data-label", d.__label)
|
| 523 |
+
.attr("data-value", d.__value)
|
| 524 |
+
.attr("data-index", i)
|
| 525 |
+
.attr("data-rank", i + 1)
|
| 526 |
+
.attr("data-sort-mode", sortMode)
|
| 527 |
+
.attr("x1", 0)
|
| 528 |
+
.attr("x2", x)
|
| 529 |
+
.attr("y1", y)
|
| 530 |
+
.attr("y2", y)
|
| 531 |
+
.attr("stroke", rowColor)
|
| 532 |
+
.attr("stroke-width", stemWidth)
|
| 533 |
+
.attr("stroke-linecap", "round")
|
| 534 |
+
.attr("opacity", variables.has_opacity ? 0.88 : 0.98);
|
| 535 |
+
|
| 536 |
+
g.append("circle")
|
| 537 |
+
.attr("class", "dot mark")
|
| 538 |
+
.attr("data-label", d.__label)
|
| 539 |
+
.attr("data-value", d.__value)
|
| 540 |
+
.attr("data-index", i)
|
| 541 |
+
.attr("data-rank", i + 1)
|
| 542 |
+
.attr("data-sort-mode", sortMode)
|
| 543 |
+
.attr("cx", x)
|
| 544 |
+
.attr("cy", y)
|
| 545 |
+
.attr("r", Math.max(3.5, radius * 0.72))
|
| 546 |
+
.attr("fill", rowColor)
|
| 547 |
+
.attr("stroke", "#ffffff")
|
| 548 |
+
.attr("stroke-width", variables.has_stroke ? 2 : 1.2);
|
| 549 |
+
g.append("text")
|
| 550 |
+
.attr("class", "value")
|
| 551 |
+
.attr("data-label", d.__label)
|
| 552 |
+
.attr("data-value", d.__value)
|
| 553 |
+
.attr("data-index", i)
|
| 554 |
+
.attr("data-rank", i + 1)
|
| 555 |
+
.attr("data-sort-mode", sortMode)
|
| 556 |
+
.attr("x", valueX)
|
| 557 |
+
.attr("y", y)
|
| 558 |
+
.attr("text-anchor", labelToRight ? "start" : "end")
|
| 559 |
+
.attr("dominant-baseline", "middle")
|
| 560 |
+
.style("font-family", valueFamily)
|
| 561 |
+
.style("font-size", `${directValueSize}px`)
|
| 562 |
+
.style("font-weight", "800")
|
| 563 |
+
.style("fill", "#d63355")
|
| 564 |
+
.text(valueLabel);
|
| 565 |
+
|
| 566 |
+
const labelX = -labelMaxWidth - 8;
|
| 567 |
+
const iconX = labelX - iconSlotSize / 2 - 14;
|
| 568 |
+
const slot = g.append("g")
|
| 569 |
+
.attr("class", "reserved-asset-slot category-icon-slot")
|
| 570 |
+
.attr("data-asset-slot", "category_icon_slot")
|
| 571 |
+
.attr("data-reserved-slot", "category_icon_slot")
|
| 572 |
+
.attr("data-slot-policy", "input_asset_or_image2_asset_or_neutral_placeholder")
|
| 573 |
+
.attr("data-anchor", "row-centerline-left-of-category-label")
|
| 574 |
+
.attr("data-collision-rule", "rounded-square slot stays on row centerline; avoid category label stem title and axis")
|
| 575 |
+
.attr("data-mask", "rounded-square-initial-placeholder")
|
| 576 |
+
.attr("data-no-embedded-raster", "true")
|
| 577 |
+
.attr("data-no-copied-reference-art", "true")
|
| 578 |
+
.attr("data-label", d.__label)
|
| 579 |
+
.attr("data-value", d.__value)
|
| 580 |
+
.attr("data-bbox", `${(plotX + iconX - iconSlotSize / 2).toFixed(1)} ${(plotY + y - iconSlotSize / 2).toFixed(1)} ${iconSlotSize.toFixed(1)} ${iconSlotSize.toFixed(1)}`)
|
| 581 |
+
.attr("transform", `translate(${iconX}, ${y})`);
|
| 582 |
+
slot.append("rect")
|
| 583 |
+
.attr("x", -iconSlotSize / 2)
|
| 584 |
+
.attr("y", -iconSlotSize / 2)
|
| 585 |
+
.attr("width", iconSlotSize)
|
| 586 |
+
.attr("height", iconSlotSize)
|
| 587 |
+
.attr("rx", Math.max(7, iconSlotSize * 0.18))
|
| 588 |
+
.attr("ry", Math.max(7, iconSlotSize * 0.18))
|
| 589 |
+
.attr("fill", "#ffffff")
|
| 590 |
+
.attr("stroke", rgba(deepGreen, 0.16))
|
| 591 |
+
.attr("stroke-width", 1.1)
|
| 592 |
+
.attr("filter", "url(#lollipop_plain_01_card_shadow)");
|
| 593 |
+
slot.append("circle")
|
| 594 |
+
.attr("r", iconSlotSize * 0.27)
|
| 595 |
+
.attr("fill", rgba(rowColor, 0.13))
|
| 596 |
+
.attr("stroke", rgba(deepGreen, 0.20))
|
| 597 |
+
.attr("stroke-width", 0.8);
|
| 598 |
+
slot.append("text")
|
| 599 |
+
.attr("text-anchor", "middle")
|
| 600 |
+
.attr("dominant-baseline", "middle")
|
| 601 |
+
.style("font-family", labelFamily)
|
| 602 |
+
.style("font-size", `${Math.max(8.2, iconSlotSize * 0.28)}px`)
|
| 603 |
+
.style("font-weight", "800")
|
| 604 |
+
.style("fill", rgba(deepGreen, 0.72))
|
| 605 |
+
.text(String(d.__label || "").trim().charAt(0).toUpperCase());
|
| 606 |
+
|
| 607 |
+
const labelWrapWidth = Math.max(labelMaxWidth, Math.min(126, leftMargin - iconColumnWidth + 22));
|
| 608 |
+
const labelLines = wrapText(d.__label, labelWrapWidth, labelFontSize, labelFamily, labelWeight).slice(0, 2);
|
| 609 |
+
const label = g.append("text")
|
| 610 |
+
.attr("class", "label")
|
| 611 |
+
.attr("data-label", d.__label)
|
| 612 |
+
.attr("data-value", d.__value)
|
| 613 |
+
.attr("data-index", i)
|
| 614 |
+
.attr("data-rank", i + 1)
|
| 615 |
+
.attr("data-sort-mode", sortMode)
|
| 616 |
+
.attr("x", labelX)
|
| 617 |
+
.attr("y", y - ((labelLines.length - 1) * labelFontSize * 0.55))
|
| 618 |
+
.attr("text-anchor", "start")
|
| 619 |
+
.style("font-family", labelFamily)
|
| 620 |
+
.style("font-size", `${labelFontSize}px`)
|
| 621 |
+
.style("font-weight", labelWeight)
|
| 622 |
+
.style("fill", inkColor);
|
| 623 |
+
labelLines.forEach((line, lineIndex) => {
|
| 624 |
+
label.append("tspan")
|
| 625 |
+
.attr("x", labelX)
|
| 626 |
+
.attr("dy", lineIndex === 0 ? 0 : `${labelFontSize * 1.1}px`)
|
| 627 |
+
.text(line);
|
| 628 |
+
});
|
| 629 |
+
});
|
| 630 |
+
|
| 631 |
+
measure.remove();
|
| 632 |
+
return svg.node();
|
| 633 |
+
}
|
modules/chart_engine/template/d3-js/type15_lollipop_chart/lollipop_plain_chart_02.js
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "lollipop chart",
|
| 5 |
+
"chart_name": "lollipop_plain_chart_02",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical", "temporal"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 12], [0, "inf"]],
|
| 10 |
+
"required_fields_icons": [],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": [],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": ["opacity", "stroke"],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "none",
|
| 20 |
+
"has_x_axis": "yes",
|
| 21 |
+
"has_y_axis": "yes"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data || {};
|
| 28 |
+
const chartData = (jsonData.data && jsonData.data.data) || [];
|
| 29 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 30 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 31 |
+
const chartUtilsFormatSample = chartUtils.format.autoText;
|
| 32 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 33 |
+
const chartUtilsStandard = {
|
| 34 |
+
schema: chartUtils.schema,
|
| 35 |
+
format: chartUtils.format,
|
| 36 |
+
text: chartUtils.text,
|
| 37 |
+
color: chartUtils.color,
|
| 38 |
+
legendLayout: chartUtils.legend.layout,
|
| 39 |
+
legendDraw: chartUtils.legend.draw,
|
| 40 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 41 |
+
};
|
| 42 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 43 |
+
x: { fallbackIndex: 0 },
|
| 44 |
+
y: { fallbackIndex: 1 },
|
| 45 |
+
y2: { fallbackIndex: 2 },
|
| 46 |
+
y3: { fallbackIndex: 3 },
|
| 47 |
+
size: { fallbackIndex: 2 },
|
| 48 |
+
group: { fallbackIndex: 2 },
|
| 49 |
+
group2: { fallbackIndex: 3 },
|
| 50 |
+
group3: { fallbackIndex: 4 }
|
| 51 |
+
});
|
| 52 |
+
const variables = jsonData.variables || {};
|
| 53 |
+
const typography = jsonData.typography || {
|
| 54 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "600" },
|
| 55 |
+
annotation: { font_family: "Arial", font_size: "11px", font_weight: "600" }
|
| 56 |
+
};
|
| 57 |
+
const sourceColors = jsonData.colors || {};
|
| 58 |
+
const colorOther = sourceColors.other || {};
|
| 59 |
+
|
| 60 |
+
d3.select(containerSelector).html("");
|
| 61 |
+
|
| 62 |
+
const width = Number(variables.width) || 600;
|
| 63 |
+
const baseHeight = Number(variables.height) || 600;
|
| 64 |
+
const height = Math.max(baseHeight, Math.max(chartData.length, 1) * 34 + 152);
|
| 65 |
+
const textColor = sourceColors.text_color || "#0b1e3d";
|
| 66 |
+
const primaryColor = colorOther.primary || "#009b98";
|
| 67 |
+
const canvasColor = "#ffffff";
|
| 68 |
+
const panelColor = "#ffffff";
|
| 69 |
+
const inkColor = "#061c3b";
|
| 70 |
+
const mutedColor = "#66758d";
|
| 71 |
+
const gridColor = "#d9e3e8";
|
| 72 |
+
const trackColor = "#d7edf0";
|
| 73 |
+
const tealDark = d3.interpolateRgb(primaryColor, "#008884")(0.55);
|
| 74 |
+
const tealMid = "#009d9a";
|
| 75 |
+
const tealLight = "#42d0c4";
|
| 76 |
+
const labelTypography = typography.label || {};
|
| 77 |
+
const valueTypography = typography.annotation || labelTypography;
|
| 78 |
+
const labelFamily = labelTypography.font_family || "Arial";
|
| 79 |
+
const valueFamily = valueTypography.font_family || "Arial";
|
| 80 |
+
const labelWeight = labelTypography.font_weight || "600";
|
| 81 |
+
const valueWeight = valueTypography.font_weight || "600";
|
| 82 |
+
const baseLabelSize = parseFloat(labelTypography.font_size) || 12;
|
| 83 |
+
const baseValueSize = parseFloat(valueTypography.font_size) || 11;
|
| 84 |
+
|
| 85 |
+
function rgba(color, opacity) {
|
| 86 |
+
const c = d3.rgb(color);
|
| 87 |
+
return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
|
| 91 |
+
const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
|
| 92 |
+
const xField = xColumn.name || "dimension";
|
| 93 |
+
const yField = yColumn.name || "value";
|
| 94 |
+
const xUnit = xColumn.unit && xColumn.unit !== "none" ? xColumn.unit : "";
|
| 95 |
+
const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
|
| 96 |
+
|
| 97 |
+
function parseTemporalSortValue(value) {
|
| 98 |
+
const text = String(value ?? "").trim();
|
| 99 |
+
const year = text.match(/^(-?\d{4})$/);
|
| 100 |
+
if (year) return Number(year[1]) * 10000;
|
| 101 |
+
const date = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
|
| 102 |
+
if (date) return Number(date[1]) * 10000 + Number(date[2]) * 100 + Number(date[3] || 1);
|
| 103 |
+
const seasonOrder = { winter: 1, spring: 2, summer: 3, fall: 4, autumn: 4 };
|
| 104 |
+
let season = text.match(/^(winter|spring|summer|fall|autumn)\s+(-?\d{4})$/i);
|
| 105 |
+
if (season) return Number(season[2]) * 10 + seasonOrder[season[1].toLowerCase()];
|
| 106 |
+
season = text.match(/^(-?\d{4})\s+(winter|spring|summer|fall|autumn)$/i);
|
| 107 |
+
if (season) return Number(season[1]) * 10 + seasonOrder[season[2].toLowerCase()];
|
| 108 |
+
return NaN;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function formatNumber(value) {
|
| 112 |
+
const numericValue = Number(value);
|
| 113 |
+
if (!Number.isFinite(numericValue)) return String(value ?? "");
|
| 114 |
+
const absValue = Math.abs(numericValue);
|
| 115 |
+
if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
|
| 116 |
+
if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
|
| 117 |
+
if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}K`;
|
| 118 |
+
return d3.format(",.4~g")(numericValue);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function formatLocalValue(value, includeUnit = true) {
|
| 122 |
+
const formatted = formatNumber(value);
|
| 123 |
+
if (!includeUnit || !yUnit) return formatted;
|
| 124 |
+
if (yUnit === "$") return `$${formatted}`;
|
| 125 |
+
if (yUnit === "%") return `${formatted}%`;
|
| 126 |
+
return `${formatted} ${yUnit}`;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function formatDimension(row) {
|
| 130 |
+
const value = row[xField];
|
| 131 |
+
return xUnit ? `${value} ${xUnit}` : String(value ?? "");
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function formatFieldName(name, fallback) {
|
| 135 |
+
const text = String(name || fallback || "Value")
|
| 136 |
+
.replace(/[_-]+/g, " ")
|
| 137 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 138 |
+
.replace(/\s+/g, " ")
|
| 139 |
+
.trim();
|
| 140 |
+
return text || fallback || "Value";
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
const xLabel = formatFieldName(xColumn.label || xColumn.name, "Category");
|
| 144 |
+
const yLabel = formatFieldName(yColumn.label || yColumn.name, "Value");
|
| 145 |
+
|
| 146 |
+
const rows = chartData.map((d, index) => ({
|
| 147 |
+
...d,
|
| 148 |
+
__index: index,
|
| 149 |
+
__label: formatDimension(d),
|
| 150 |
+
__value: Number(d[yField])
|
| 151 |
+
})).filter(d => Number.isFinite(d.__value));
|
| 152 |
+
|
| 153 |
+
if (!rows.length) return;
|
| 154 |
+
|
| 155 |
+
const isTemporal = xColumn.data_type === "temporal" || (
|
| 156 |
+
rows.length > 0 && rows.every(d => Number.isFinite(parseTemporalSortValue(d[xField])))
|
| 157 |
+
);
|
| 158 |
+
|
| 159 |
+
const sortedData = [...rows].sort((a, b) => {
|
| 160 |
+
if (isTemporal) {
|
| 161 |
+
const aTime = parseTemporalSortValue(a[xField]);
|
| 162 |
+
const bTime = parseTemporalSortValue(b[xField]);
|
| 163 |
+
if (Number.isFinite(aTime) && Number.isFinite(bTime)) return aTime - bTime;
|
| 164 |
+
}
|
| 165 |
+
const byValue = b.__value - a.__value;
|
| 166 |
+
if (byValue !== 0) return byValue;
|
| 167 |
+
return String(a.__label).localeCompare(String(b.__label));
|
| 168 |
+
});
|
| 169 |
+
const sortMode = isTemporal ? "temporal" : "value_desc";
|
| 170 |
+
|
| 171 |
+
const svg = d3.select(containerSelector)
|
| 172 |
+
.append("svg")
|
| 173 |
+
.attr("width", "100%")
|
| 174 |
+
.attr("height", height)
|
| 175 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 176 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 177 |
+
.attr("xmlns", "http://www.w3.org/2000/svg");
|
| 178 |
+
|
| 179 |
+
const defs = svg.append("defs");
|
| 180 |
+
const cardShadow = defs.append("filter")
|
| 181 |
+
.attr("id", "lollipop_plain_02_card_shadow")
|
| 182 |
+
.attr("x", "-8%")
|
| 183 |
+
.attr("y", "-10%")
|
| 184 |
+
.attr("width", "116%")
|
| 185 |
+
.attr("height", "124%");
|
| 186 |
+
cardShadow.append("feDropShadow")
|
| 187 |
+
.attr("dx", 0)
|
| 188 |
+
.attr("dy", 2)
|
| 189 |
+
.attr("stdDeviation", 2.2)
|
| 190 |
+
.attr("flood-color", "#6f7481")
|
| 191 |
+
.attr("flood-opacity", 0.13);
|
| 192 |
+
|
| 193 |
+
const measure = svg.append("g").attr("visibility", "hidden");
|
| 194 |
+
function measureLabelText(text, fontSize, family, weight) {
|
| 195 |
+
const node = measure.append("text")
|
| 196 |
+
.style("font-family", family)
|
| 197 |
+
.style("font-size", `${fontSize}px`)
|
| 198 |
+
.style("font-weight", weight)
|
| 199 |
+
.text(text)
|
| 200 |
+
.node();
|
| 201 |
+
const bboxWidth = node ? node.textContent.length * 7 : 0;
|
| 202 |
+
const approximateWidth = String(text).length * fontSize * 0.55;
|
| 203 |
+
const measuredWidth = Math.max(bboxWidth || 0, approximateWidth);
|
| 204 |
+
measure.selectAll("text").remove();
|
| 205 |
+
return measuredWidth;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function breakLongWord(word, maxWidth, fontSize, family, weight) {
|
| 209 |
+
const lines = [];
|
| 210 |
+
let current = "";
|
| 211 |
+
for (const char of String(word)) {
|
| 212 |
+
const next = current + char;
|
| 213 |
+
if (current && measureLabelText(next, fontSize, family, weight) > maxWidth) {
|
| 214 |
+
lines.push(`${current}-`);
|
| 215 |
+
current = char;
|
| 216 |
+
} else {
|
| 217 |
+
current = next;
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
if (current) lines.push(current);
|
| 221 |
+
return lines.length ? lines : [String(word)];
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
function wrapText(text, maxWidth, fontSize, family, weight) {
|
| 225 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 226 |
+
if (!words.length) return [String(text || "")];
|
| 227 |
+
const lines = [];
|
| 228 |
+
let current = "";
|
| 229 |
+
for (const word of words) {
|
| 230 |
+
const candidate = current ? `${current} ${word}` : word;
|
| 231 |
+
if (measureLabelText(candidate, fontSize, family, weight) <= maxWidth) {
|
| 232 |
+
current = candidate;
|
| 233 |
+
continue;
|
| 234 |
+
}
|
| 235 |
+
if (current) lines.push(current);
|
| 236 |
+
if (measureLabelText(word, fontSize, family, weight) <= maxWidth) {
|
| 237 |
+
current = word;
|
| 238 |
+
} else {
|
| 239 |
+
current = word;
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
if (current) lines.push(current);
|
| 243 |
+
return lines.length ? lines : [String(text || "")];
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
const n = sortedData.length;
|
| 247 |
+
const labelFontSize = Math.max(8.5, Math.min(baseLabelSize, n > 9 ? 10.3 : 11.6));
|
| 248 |
+
const valueFontSize = Math.max(8.5, Math.min(baseValueSize, n > 9 ? 9.8 : 11.2));
|
| 249 |
+
const longestLabelWidth = Math.max(
|
| 250 |
+
0,
|
| 251 |
+
...sortedData.map(d => measureLabelText(d.__label, labelFontSize, labelFamily, labelWeight))
|
| 252 |
+
);
|
| 253 |
+
const valueLabels = sortedData.map(d => formatLocalValue(d.__value, yUnit === "$" || yUnit === "%"));
|
| 254 |
+
const maxValueWidth = Math.max(
|
| 255 |
+
44,
|
| 256 |
+
...valueLabels.map(label => measureLabelText(label, valueFontSize, valueFamily, valueWeight))
|
| 257 |
+
);
|
| 258 |
+
const cardX = Math.max(14, Math.min(22, width * 0.035));
|
| 259 |
+
const cardY = 16;
|
| 260 |
+
const cardWidth = width - cardX * 2;
|
| 261 |
+
const cardHeight = height - cardY * 2;
|
| 262 |
+
const headerHeight = 66;
|
| 263 |
+
const footerHeight = 34;
|
| 264 |
+
const plotY = cardY + headerHeight + 16;
|
| 265 |
+
const plotBottom = cardY + cardHeight - footerHeight - 12;
|
| 266 |
+
const innerHeight = Math.max(170, plotBottom - plotY);
|
| 267 |
+
const comparisonWidth = width >= 540 && n >= 7 ? Math.min(238, Math.max(206, cardWidth * 0.39)) : 0;
|
| 268 |
+
const leftMargin = Math.max(154, Math.min(Math.min(190, cardWidth * 0.34), longestLabelWidth * 0.74 + 88));
|
| 269 |
+
const rightMargin = Math.max(44, Math.min(76, maxValueWidth + 22));
|
| 270 |
+
const plotX = cardX + leftMargin;
|
| 271 |
+
const innerWidth = Math.max(166, cardWidth - leftMargin - rightMargin - comparisonWidth);
|
| 272 |
+
const labelMaxWidth = Math.max(74, leftMargin - 88);
|
| 273 |
+
|
| 274 |
+
const maxValue = Math.max(1, d3.max(sortedData, d => Math.max(0, d.__value)) || 1);
|
| 275 |
+
const xScale = d3.scaleLinear()
|
| 276 |
+
.domain([0, maxValue])
|
| 277 |
+
.nice(5)
|
| 278 |
+
.range([0, innerWidth]);
|
| 279 |
+
const yScale = d3.scaleBand()
|
| 280 |
+
.domain(sortedData.map(d => d.__label))
|
| 281 |
+
.range([0, innerHeight])
|
| 282 |
+
.paddingInner(0.28)
|
| 283 |
+
.paddingOuter(0.18);
|
| 284 |
+
const stemWidth = Math.max(8, Math.min(16, yScale.bandwidth() * 0.48));
|
| 285 |
+
const radius = Math.max(4.2, stemWidth / 2);
|
| 286 |
+
|
| 287 |
+
function fittedSize(text, maxWidth, startSize, minSize, family, weight) {
|
| 288 |
+
let fontSize = startSize;
|
| 289 |
+
while (fontSize > minSize && measureLabelText(text, fontSize, family, weight) > maxWidth) {
|
| 290 |
+
fontSize -= 0.5;
|
| 291 |
+
}
|
| 292 |
+
return fontSize;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
function markColor(index) {
|
| 296 |
+
const t = n <= 1 ? 0.5 : index / (n - 1);
|
| 297 |
+
return d3.interpolateRgb(tealDark, tealLight)(Math.min(0.85, t * 0.82));
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
function rightRoundedBarPath(x0, x1, cy, h) {
|
| 301 |
+
const width = Math.max(0.6, x1 - x0);
|
| 302 |
+
const r = Math.min(h * 0.22, Math.max(0, width * 0.5));
|
| 303 |
+
const top = cy - h / 2;
|
| 304 |
+
const bottom = cy + h / 2;
|
| 305 |
+
if (r <= 0.2) {
|
| 306 |
+
return `M${x0},${top} H${x1} V${bottom} H${x0} Z`;
|
| 307 |
+
}
|
| 308 |
+
return `M${x0},${top} H${x1 - r} Q${x1},${top} ${x1},${cy} Q${x1},${bottom} ${x1 - r},${bottom} H${x0} Z`;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
svg.append("rect")
|
| 312 |
+
.attr("class", "chart-canvas")
|
| 313 |
+
.attr("x", 0)
|
| 314 |
+
.attr("y", 0)
|
| 315 |
+
.attr("width", width)
|
| 316 |
+
.attr("height", height)
|
| 317 |
+
.attr("fill", canvasColor);
|
| 318 |
+
|
| 319 |
+
svg.append("rect")
|
| 320 |
+
.attr("class", "chart-style-panel")
|
| 321 |
+
.attr("x", cardX)
|
| 322 |
+
.attr("y", cardY)
|
| 323 |
+
.attr("width", cardWidth)
|
| 324 |
+
.attr("height", cardHeight)
|
| 325 |
+
.attr("rx", 8)
|
| 326 |
+
.attr("ry", 8)
|
| 327 |
+
.attr("fill", panelColor)
|
| 328 |
+
.attr("stroke", "#e4f0f3")
|
| 329 |
+
.attr("stroke-width", 0.8)
|
| 330 |
+
.attr("filter", "url(#lollipop_plain_02_card_shadow)");
|
| 331 |
+
|
| 332 |
+
const headerPillX = cardX + 22;
|
| 333 |
+
const headerPillY = cardY + 20;
|
| 334 |
+
const headerPillText = `${yLabel}${yUnit ? ` (${yUnit})` : ""}`.toUpperCase();
|
| 335 |
+
const headerPillWidth = Math.min(cardWidth - 44, Math.max(188, measureLabelText(headerPillText, 13, labelFamily, "800") + 42));
|
| 336 |
+
const headerFontSize = fittedSize(headerPillText, headerPillWidth - 48, Math.max(10, Math.min(baseLabelSize, 13)), 8.5, labelFamily, "800");
|
| 337 |
+
svg.append("rect")
|
| 338 |
+
.attr("class", "field-pill")
|
| 339 |
+
.attr("x", headerPillX)
|
| 340 |
+
.attr("y", headerPillY)
|
| 341 |
+
.attr("width", headerPillWidth)
|
| 342 |
+
.attr("height", 26)
|
| 343 |
+
.attr("rx", 7)
|
| 344 |
+
.attr("ry", 7)
|
| 345 |
+
.attr("fill", tealMid)
|
| 346 |
+
.attr("stroke", "none");
|
| 347 |
+
svg.append("circle")
|
| 348 |
+
.attr("class", "field-pill-dot")
|
| 349 |
+
.attr("data-asset-slot", "metric-pill-icon")
|
| 350 |
+
.attr("data-asset-policy", "neutral-placeholder")
|
| 351 |
+
.attr("cx", headerPillX + 16)
|
| 352 |
+
.attr("cy", headerPillY + 13)
|
| 353 |
+
.attr("r", 5.2)
|
| 354 |
+
.attr("fill", "#ffffff")
|
| 355 |
+
.attr("opacity", 0.94);
|
| 356 |
+
svg.append("text")
|
| 357 |
+
.attr("class", "field-pill-label")
|
| 358 |
+
.attr("x", headerPillX + 29)
|
| 359 |
+
.attr("y", headerPillY + 13)
|
| 360 |
+
.attr("dominant-baseline", "middle")
|
| 361 |
+
.style("font-family", labelFamily)
|
| 362 |
+
.style("font-size", `${headerFontSize}px`)
|
| 363 |
+
.style("font-weight", "800")
|
| 364 |
+
.style("fill", "#ffffff")
|
| 365 |
+
.text(headerPillText);
|
| 366 |
+
|
| 367 |
+
if (comparisonWidth > 0) {
|
| 368 |
+
const cx = cardX + cardWidth - comparisonWidth / 2 - 4;
|
| 369 |
+
const cy = plotY + innerHeight * 0.50;
|
| 370 |
+
const cr = Math.min(136, comparisonWidth * 0.62, innerHeight * 0.38);
|
| 371 |
+
const comparison = svg.append("g")
|
| 372 |
+
.attr("class", "comparison-slot")
|
| 373 |
+
.attr("data-asset-slot", "comparison-art")
|
| 374 |
+
.attr("data-asset-policy", "image2-owned")
|
| 375 |
+
.attr("data-anchor", "right-floating")
|
| 376 |
+
.attr("data-collision", "avoid-row-values-and-axis")
|
| 377 |
+
.attr("data-bbox", `${cx - cr} ${cy - cr} ${cr * 2} ${cr * 2}`);
|
| 378 |
+
|
| 379 |
+
comparison.append("circle")
|
| 380 |
+
.attr("class", "comparison-circle")
|
| 381 |
+
.attr("cx", cx)
|
| 382 |
+
.attr("cy", cy)
|
| 383 |
+
.attr("r", cr)
|
| 384 |
+
.attr("fill", "#e9f8f8")
|
| 385 |
+
.attr("stroke", "#d6eeee")
|
| 386 |
+
.attr("stroke-width", 3)
|
| 387 |
+
.attr("opacity", 0.82);
|
| 388 |
+
|
| 389 |
+
const comparisonTitle = wrapText(`${yLabel} comparison`, cr * 1.08, 15, labelFamily, "800").slice(0, 2);
|
| 390 |
+
const titleStartY = cy - cr * 0.42;
|
| 391 |
+
comparisonTitle.forEach((line, idx) => {
|
| 392 |
+
comparison.append("text")
|
| 393 |
+
.attr("class", "comparison-title")
|
| 394 |
+
.attr("x", cx - cr * 0.48)
|
| 395 |
+
.attr("y", titleStartY + idx * 17)
|
| 396 |
+
.attr("text-anchor", "start")
|
| 397 |
+
.style("font-family", labelFamily)
|
| 398 |
+
.style("font-size", `${Math.max(14, Math.min(17, cr * 0.13))}px`)
|
| 399 |
+
.style("font-weight", "800")
|
| 400 |
+
.style("fill", inkColor)
|
| 401 |
+
.text(line.toUpperCase());
|
| 402 |
+
});
|
| 403 |
+
|
| 404 |
+
const grades = [
|
| 405 |
+
["A", "#27a647"],
|
| 406 |
+
["B", "#77bf2f"],
|
| 407 |
+
["C", "#f3c21b"],
|
| 408 |
+
["D", "#f58a1f"],
|
| 409 |
+
["E", "#ef3d2f"]
|
| 410 |
+
];
|
| 411 |
+
grades.forEach((grade, idx) => {
|
| 412 |
+
const rowY = cy - cr * 0.06 + idx * Math.max(23, cr * 0.19);
|
| 413 |
+
const rowX = cx - cr * 0.52;
|
| 414 |
+
const rowW = cr * 0.78;
|
| 415 |
+
const arrowW = cr * 0.15;
|
| 416 |
+
const rowH = Math.max(18, cr * 0.15);
|
| 417 |
+
comparison.append("path")
|
| 418 |
+
.attr("class", "comparison-grade-row")
|
| 419 |
+
.attr("d", `M${rowX},${rowY - rowH / 2} H${rowX + rowW} L${rowX + rowW + arrowW},${rowY} L${rowX + rowW},${rowY + rowH / 2} H${rowX} Z`)
|
| 420 |
+
.attr("fill", grade[1])
|
| 421 |
+
.attr("opacity", 0.92);
|
| 422 |
+
comparison.append("text")
|
| 423 |
+
.attr("class", "comparison-grade-label")
|
| 424 |
+
.attr("x", rowX + 13)
|
| 425 |
+
.attr("y", rowY + Math.max(5, rowH * 0.28))
|
| 426 |
+
.style("font-family", labelFamily)
|
| 427 |
+
.style("font-size", `${Math.max(18, Math.min(26, cr * 0.19))}px`)
|
| 428 |
+
.style("font-weight", "800")
|
| 429 |
+
.style("fill", "#ffffff")
|
| 430 |
+
.text(grade[0]);
|
| 431 |
+
});
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
const g = svg.append("g")
|
| 435 |
+
.attr("class", "lollipop-plot")
|
| 436 |
+
.attr("data-count", sortedData.length)
|
| 437 |
+
.attr("data-sort-mode", sortMode)
|
| 438 |
+
.attr("transform", `translate(${plotX}, ${plotY})`);
|
| 439 |
+
|
| 440 |
+
const tickValues = xScale.ticks(5).filter((value, index, array) => {
|
| 441 |
+
return index === 0 || Math.abs(value - array[index - 1]) > 1e-9;
|
| 442 |
+
});
|
| 443 |
+
|
| 444 |
+
tickValues.forEach(tick => {
|
| 445 |
+
const x = xScale(tick);
|
| 446 |
+
g.append("line")
|
| 447 |
+
.attr("class", "gridline")
|
| 448 |
+
.attr("x1", x)
|
| 449 |
+
.attr("x2", x)
|
| 450 |
+
.attr("y1", 0)
|
| 451 |
+
.attr("y2", innerHeight)
|
| 452 |
+
.attr("stroke", tick === 0 ? rgba(mutedColor, 0.38) : rgba(gridColor, 0.96))
|
| 453 |
+
.attr("stroke-width", tick === 0 ? 1.3 : 1)
|
| 454 |
+
.attr("stroke-dasharray", tick === 0 ? null : "3,4");
|
| 455 |
+
g.append("text")
|
| 456 |
+
.attr("class", "axis-tick")
|
| 457 |
+
.attr("x", x)
|
| 458 |
+
.attr("y", innerHeight + 24)
|
| 459 |
+
.attr("text-anchor", "middle")
|
| 460 |
+
.style("font-family", valueFamily)
|
| 461 |
+
.style("font-size", `${Math.max(8.5, valueFontSize * 0.9)}px`)
|
| 462 |
+
.style("fill", rgba(mutedColor, 0.78))
|
| 463 |
+
.text(formatLocalValue(tick, false));
|
| 464 |
+
});
|
| 465 |
+
|
| 466 |
+
if (yUnit && yUnit !== "$" && yUnit !== "%") {
|
| 467 |
+
g.append("text")
|
| 468 |
+
.attr("class", "unit-label")
|
| 469 |
+
.attr("x", innerWidth)
|
| 470 |
+
.attr("y", innerHeight + 44)
|
| 471 |
+
.attr("text-anchor", "end")
|
| 472 |
+
.style("font-family", valueFamily)
|
| 473 |
+
.style("font-size", `${Math.max(9, valueFontSize * 0.9)}px`)
|
| 474 |
+
.style("fill", rgba(mutedColor, 0.76))
|
| 475 |
+
.text(yUnit);
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
sortedData.forEach((d, i) => {
|
| 479 |
+
const y = yScale(d.__label) + yScale.bandwidth() / 2;
|
| 480 |
+
const x = xScale(Math.max(0, d.__value));
|
| 481 |
+
const rowColor = markColor(i);
|
| 482 |
+
const valueLabel = valueLabels[i];
|
| 483 |
+
const valueWidth = measureLabelText(valueLabel, valueFontSize, valueFamily, valueWeight) + 14;
|
| 484 |
+
const valueX = Math.min(innerWidth + 10, x + radius + 9);
|
| 485 |
+
const iconX = -leftMargin + 26;
|
| 486 |
+
const labelX = -leftMargin + 56;
|
| 487 |
+
const iconSize = Math.max(18, Math.min(24, yScale.bandwidth() * 0.62));
|
| 488 |
+
|
| 489 |
+
const iconSlot = g.append("g")
|
| 490 |
+
.attr("class", "row-category-icon-slot")
|
| 491 |
+
.attr("data-asset-slot", "row-category-icon")
|
| 492 |
+
.attr("data-asset-policy", "neutral-placeholder")
|
| 493 |
+
.attr("data-anchor", "row-centerline")
|
| 494 |
+
.attr("data-collision", "avoid-label-and-stem")
|
| 495 |
+
.attr("data-label", d.__label)
|
| 496 |
+
.attr("data-value", d.__value)
|
| 497 |
+
.attr("data-index", i)
|
| 498 |
+
.attr("data-bbox", `${iconX - iconSize / 2} ${y - iconSize / 2} ${iconSize} ${iconSize}`);
|
| 499 |
+
iconSlot.append("rect")
|
| 500 |
+
.attr("class", "row-category-icon-box")
|
| 501 |
+
.attr("x", iconX - iconSize / 2)
|
| 502 |
+
.attr("y", y - iconSize / 2)
|
| 503 |
+
.attr("width", iconSize)
|
| 504 |
+
.attr("height", iconSize)
|
| 505 |
+
.attr("rx", 4)
|
| 506 |
+
.attr("ry", 4)
|
| 507 |
+
.attr("fill", tealMid)
|
| 508 |
+
.attr("opacity", 0.96);
|
| 509 |
+
iconSlot.append("rect")
|
| 510 |
+
.attr("class", "row-category-icon-placeholder")
|
| 511 |
+
.attr("x", iconX - iconSize * 0.23)
|
| 512 |
+
.attr("y", y + iconSize * 0.15)
|
| 513 |
+
.attr("width", iconSize * 0.46)
|
| 514 |
+
.attr("height", Math.max(2, iconSize * 0.10))
|
| 515 |
+
.attr("rx", 1)
|
| 516 |
+
.attr("fill", "#ffffff")
|
| 517 |
+
.attr("opacity", 0.68);
|
| 518 |
+
iconSlot.append("circle")
|
| 519 |
+
.attr("class", "row-category-icon-anchor-dot")
|
| 520 |
+
.attr("cx", iconX)
|
| 521 |
+
.attr("cy", y - iconSize * 0.12)
|
| 522 |
+
.attr("r", Math.max(2.2, iconSize * 0.13))
|
| 523 |
+
.attr("fill", "#ffffff")
|
| 524 |
+
.attr("opacity", 0.74);
|
| 525 |
+
|
| 526 |
+
g.append("line")
|
| 527 |
+
.attr("class", "stem-track")
|
| 528 |
+
.attr("data-label", d.__label)
|
| 529 |
+
.attr("data-value", d.__value)
|
| 530 |
+
.attr("data-index", i)
|
| 531 |
+
.attr("data-sort-mode", sortMode)
|
| 532 |
+
.attr("x1", 0)
|
| 533 |
+
.attr("x2", innerWidth)
|
| 534 |
+
.attr("y1", y)
|
| 535 |
+
.attr("y2", y)
|
| 536 |
+
.attr("stroke", trackColor)
|
| 537 |
+
.attr("stroke-width", 1.4)
|
| 538 |
+
.attr("opacity", 0.18)
|
| 539 |
+
.attr("stroke-linecap", "butt");
|
| 540 |
+
|
| 541 |
+
g.append("path")
|
| 542 |
+
.attr("class", "stem mark")
|
| 543 |
+
.attr("data-label", d.__label)
|
| 544 |
+
.attr("data-value", d.__value)
|
| 545 |
+
.attr("data-index", i)
|
| 546 |
+
.attr("data-sort-mode", sortMode)
|
| 547 |
+
.attr("d", rightRoundedBarPath(0, x, y, stemWidth))
|
| 548 |
+
.attr("fill", rowColor)
|
| 549 |
+
.attr("stroke", "none")
|
| 550 |
+
.attr("opacity", variables.has_opacity ? 0.88 : 0.98);
|
| 551 |
+
|
| 552 |
+
g.append("rect")
|
| 553 |
+
.attr("class", "value-pill")
|
| 554 |
+
.attr("data-label", d.__label)
|
| 555 |
+
.attr("data-value", d.__value)
|
| 556 |
+
.attr("data-index", i)
|
| 557 |
+
.attr("x", valueX)
|
| 558 |
+
.attr("y", y - valueFontSize * 0.86)
|
| 559 |
+
.attr("width", valueWidth)
|
| 560 |
+
.attr("height", valueFontSize * 1.72)
|
| 561 |
+
.attr("rx", 4)
|
| 562 |
+
.attr("ry", 4)
|
| 563 |
+
.attr("fill", "none")
|
| 564 |
+
.attr("stroke", "none")
|
| 565 |
+
.attr("opacity", 0);
|
| 566 |
+
g.append("text")
|
| 567 |
+
.attr("class", "value")
|
| 568 |
+
.attr("data-label", d.__label)
|
| 569 |
+
.attr("data-value", d.__value)
|
| 570 |
+
.attr("data-index", i)
|
| 571 |
+
.attr("data-sort-mode", sortMode)
|
| 572 |
+
.attr("x", valueX)
|
| 573 |
+
.attr("y", y)
|
| 574 |
+
.attr("text-anchor", "start")
|
| 575 |
+
.attr("dominant-baseline", "middle")
|
| 576 |
+
.style("font-family", valueFamily)
|
| 577 |
+
.style("font-size", `${Math.max(valueFontSize, 10.2)}px`)
|
| 578 |
+
.style("font-weight", "800")
|
| 579 |
+
.style("fill", tealMid)
|
| 580 |
+
.text(valueLabel);
|
| 581 |
+
|
| 582 |
+
const labelLines = wrapText(d.__label, labelMaxWidth, labelFontSize, labelFamily, labelWeight).slice(0, 2);
|
| 583 |
+
const label = g.append("text")
|
| 584 |
+
.attr("class", "label")
|
| 585 |
+
.attr("data-label", d.__label)
|
| 586 |
+
.attr("data-value", d.__value)
|
| 587 |
+
.attr("data-index", i)
|
| 588 |
+
.attr("data-sort-mode", sortMode)
|
| 589 |
+
.attr("x", labelX)
|
| 590 |
+
.attr("y", y - ((labelLines.length - 1) * labelFontSize * 0.55))
|
| 591 |
+
.attr("text-anchor", "start")
|
| 592 |
+
.style("font-family", labelFamily)
|
| 593 |
+
.style("font-size", `${labelFontSize}px`)
|
| 594 |
+
.style("font-weight", "700")
|
| 595 |
+
.style("fill", inkColor);
|
| 596 |
+
labelLines.forEach((line, lineIndex) => {
|
| 597 |
+
label.append("tspan")
|
| 598 |
+
.attr("x", labelX)
|
| 599 |
+
.attr("dy", lineIndex === 0 ? 0 : `${labelFontSize * 1.1}px`)
|
| 600 |
+
.text(line);
|
| 601 |
+
});
|
| 602 |
+
});
|
| 603 |
+
|
| 604 |
+
measure.remove();
|
| 605 |
+
return svg.node();
|
| 606 |
+
}
|