Ray1ee01 commited on
Commit
4d7e0fd
·
verified ·
1 Parent(s): 5eacdf8

Upload folder using huggingface_hub

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