Ray1ee01 commited on
Commit
f9b6fdc
·
verified ·
1 Parent(s): 7be5861

Upload folder using huggingface_hub

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