Ray1ee01 commited on
Commit
4a4fe20
·
verified ·
1 Parent(s): aa138c1

Upload folder using huggingface_hub

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