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

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type21_horizontal_dot_bar_chart/horizontal_dot_bar_plain_chart_01.js ADDED
@@ -0,0 +1,797 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "horizontal dot bar chart",
5
+ "chart_name": "horizontal_dot_bar_plain_chart_01",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y"],
8
+ "required_fields_type": [["categorical", "temporal"], ["numerical"]],
9
+ "required_fields_range": [[2, 30], [0, "inf"]],
10
+ "required_fields_icons": [],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": [],
13
+ "required_other_colors": ["primary"],
14
+ "supported_effects": ["shadow", "gradient", "stroke", "spacing"],
15
+ "min_height": 400,
16
+ "min_width": 400,
17
+ "background": "no",
18
+ "icon_mark": "none",
19
+ "icon_label": "none",
20
+ "has_x_axis": "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: "normal" },
55
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
56
+ };
57
+ const sourceColors = jsonData.colors || {};
58
+ const colorOther = sourceColors.other || {};
59
+ const colorField = sourceColors.field || {};
60
+
61
+ d3.select(containerSelector).html("");
62
+
63
+ const width = Number(variables.width) || 800;
64
+ const baseHeight = Number(variables.height) || 600;
65
+ const rowCount = Math.max(chartData.length, 1);
66
+ const height = Math.max(baseHeight, rowCount * 43 + 222);
67
+
68
+ const labelTypography = typography.label || {};
69
+ const valueTypography = typography.annotation || labelTypography;
70
+ const labelFamily = labelTypography.font_family || "Arial";
71
+ const labelWeight = labelTypography.font_weight || "normal";
72
+ const valueFamily = valueTypography.font_family || "Arial";
73
+ const valueWeight = valueTypography.font_weight || "normal";
74
+ const baseLabelSize = parseFloat(labelTypography.font_size) || 12;
75
+ const baseValueSize = parseFloat(valueTypography.font_size) || 12;
76
+ const textColor = sourceColors.text_color || "#27323f";
77
+ const styleGreen = colorOther.primary || colorField.y || "#2f8f3a";
78
+ const primaryColor = d3.interpolateRgb("#2f8f3a", styleGreen)(0.22);
79
+ const palePrimary = "#b9e873";
80
+ const darkPrimary = "#155f2a";
81
+ const tableGreen = "#2f8f3a";
82
+
83
+ function rgba(color, opacity) {
84
+ const c = d3.rgb(color);
85
+ return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
86
+ }
87
+
88
+ const axisColor = sourceColors.axis_color || rgba(textColor, 0.48);
89
+ const gridColor = sourceColors.grid_color || "rgba(47, 143, 58, 0.14)";
90
+
91
+ const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
92
+ const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
93
+ const dimensionField = xColumn.name || chartUtils.schema.column(dataColumns, 0).raw?.name || "dimension";
94
+ const valueField = yColumn.name || chartUtils.schema.column(dataColumns, 1).raw?.name || "value";
95
+ const dimensionUnit = xColumn.unit && xColumn.unit !== "none" ? xColumn.unit : "";
96
+ const valueUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
97
+
98
+ function parseTemporalSortValue(value) {
99
+ const text = String(value ?? "").trim();
100
+ let match = text.match(/^(-?\d{1,6})$/);
101
+ if (match) return Number(match[1]);
102
+ match = text.match(/^(-?\d{1,6})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
103
+ if (match) {
104
+ return Number(match[1]) * 10000 + Number(match[2]) * 100 + Number(match[3] || 1);
105
+ }
106
+ const dateValue = Date.parse(text);
107
+ return Number.isNaN(dateValue) ? NaN : dateValue;
108
+ }
109
+
110
+ function formatNumber(value) {
111
+ const numericValue = Number(value);
112
+ if (!Number.isFinite(numericValue)) return String(value ?? "");
113
+ const absValue = Math.abs(numericValue);
114
+ if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
115
+ if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
116
+ if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}K`;
117
+ return d3.format(",.4~g")(numericValue);
118
+ }
119
+
120
+ function formatWithUnit(value, unit) {
121
+ const formatted = formatNumber(value);
122
+ if (!unit) return formatted;
123
+ if (unit === "$") return `$${formatted}`;
124
+ if (unit === "%") return `${formatted}%`;
125
+ return `${formatted} ${unit}`;
126
+ }
127
+
128
+ function dimensionLabel(d) {
129
+ const value = d[dimensionField];
130
+ return dimensionUnit ? `${value} ${dimensionUnit}` : String(value ?? "");
131
+ }
132
+
133
+ function prettifyHeader(value, fallback) {
134
+ const text = String(value || fallback || "").trim();
135
+ if (!text) return "";
136
+ return text
137
+ .replace(/[_-]+/g, " ")
138
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
139
+ .replace(/\s+/g, " ");
140
+ }
141
+
142
+ const enrichedData = chartData.map((d, index) => ({
143
+ ...d,
144
+ __index: index,
145
+ __dimension: dimensionLabel(d),
146
+ __value: Number(d[valueField])
147
+ })).filter(d => Number.isFinite(d.__value));
148
+
149
+ const temporalValues = enrichedData.map(d => parseTemporalSortValue(d[dimensionField]));
150
+ const isTemporal = xColumn.data_type === "temporal" || (
151
+ temporalValues.length > 0 && temporalValues.every(Number.isFinite)
152
+ );
153
+
154
+ const sortedData = [...enrichedData].sort((a, b) => {
155
+ if (isTemporal) {
156
+ const aTime = parseTemporalSortValue(a[dimensionField]);
157
+ const bTime = parseTemporalSortValue(b[dimensionField]);
158
+ if (Number.isFinite(aTime) && Number.isFinite(bTime)) return aTime - bTime;
159
+ }
160
+ const byValue = b.__value - a.__value;
161
+ if (byValue !== 0) return byValue;
162
+ return String(a.__dimension).localeCompare(String(b.__dimension));
163
+ });
164
+
165
+ const svg = d3.select(containerSelector)
166
+ .append("svg")
167
+ .attr("width", "100%")
168
+ .attr("height", height)
169
+ .attr("viewBox", `0 0 ${width} ${height}`)
170
+ .attr("style", "max-width: 100%; height: auto;")
171
+ .attr("xmlns", "http://www.w3.org/2000/svg");
172
+
173
+ const defs = svg.append("defs");
174
+ const gradientId = "horizontal-dot-bar-gradient";
175
+ const gradient = defs.append("linearGradient")
176
+ .attr("id", gradientId)
177
+ .attr("x1", "0%")
178
+ .attr("x2", "100%")
179
+ .attr("y1", "0%")
180
+ .attr("y2", "0%");
181
+ gradient.append("stop")
182
+ .attr("offset", "0%")
183
+ .attr("stop-color", palePrimary);
184
+ gradient.append("stop")
185
+ .attr("offset", "100%")
186
+ .attr("stop-color", primaryColor);
187
+
188
+ if (variables.has_shadow) {
189
+ const shadow = defs.append("filter")
190
+ .attr("id", "dot-shadow")
191
+ .attr("x", "-40%")
192
+ .attr("y", "-40%")
193
+ .attr("width", "180%")
194
+ .attr("height", "180%");
195
+ shadow.append("feDropShadow")
196
+ .attr("dx", 0)
197
+ .attr("dy", 1)
198
+ .attr("stdDeviation", 1.4)
199
+ .attr("flood-color", "#000000")
200
+ .attr("flood-opacity", 0.18);
201
+ }
202
+
203
+ const measureSvg = svg.append("g").attr("visibility", "hidden");
204
+ function measureLabelText(text, fontSize, family, weight) {
205
+ const node = measureSvg.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 width = node ? node.textContent.length * 7 : String(text).length * fontSize * 0.55;
212
+ measureSvg.selectAll("text").remove();
213
+ return width;
214
+ }
215
+
216
+ function breakLongWord(word, maxWidth, fontSize, family, weight) {
217
+ const lines = [];
218
+ let current = "";
219
+ for (const char of String(word)) {
220
+ const next = current + char;
221
+ if (current && measureLabelText(next, fontSize, family, weight) > maxWidth) {
222
+ lines.push(`${current}-`);
223
+ current = char;
224
+ } else {
225
+ current = next;
226
+ }
227
+ }
228
+ if (current) lines.push(current);
229
+ return lines.length ? lines : [String(word)];
230
+ }
231
+
232
+ function wrapText(text, maxWidth, fontSize, family, weight) {
233
+ const words = String(text || "").split(/\s+/).filter(Boolean);
234
+ if (!words.length) return [String(text || "")];
235
+ const lines = [];
236
+ let current = "";
237
+ for (const word of words) {
238
+ const candidate = current ? `${current} ${word}` : word;
239
+ if (measureLabelText(candidate, fontSize, family, weight) <= maxWidth) {
240
+ current = candidate;
241
+ continue;
242
+ }
243
+ if (current) lines.push(current);
244
+ if (measureLabelText(word, fontSize, family, weight) <= maxWidth) {
245
+ current = word;
246
+ } else {
247
+ const broken = breakLongWord(word, maxWidth, fontSize, family, weight);
248
+ lines.push(...broken.slice(0, -1));
249
+ current = broken[broken.length - 1];
250
+ }
251
+ }
252
+ if (current) lines.push(current);
253
+ return lines.length ? lines : [String(text || "")];
254
+ }
255
+
256
+ function appendMiniIcon(parent, type, x, y, size, color, className) {
257
+ const g = parent.append("g")
258
+ .attr("class", className || "mini-icon")
259
+ .attr("transform", `translate(${x}, ${y})`);
260
+ const strokeWidth = Math.max(1.4, size * 0.11);
261
+
262
+ if (type === "calendar") {
263
+ g.append("rect")
264
+ .attr("x", 0)
265
+ .attr("y", size * 0.14)
266
+ .attr("width", size)
267
+ .attr("height", size * 0.78)
268
+ .attr("rx", size * 0.12)
269
+ .attr("fill", "none")
270
+ .attr("stroke", color)
271
+ .attr("stroke-width", strokeWidth);
272
+ g.append("line")
273
+ .attr("x1", 0)
274
+ .attr("x2", size)
275
+ .attr("y1", size * 0.38)
276
+ .attr("y2", size * 0.38)
277
+ .attr("stroke", color)
278
+ .attr("stroke-width", strokeWidth);
279
+ [0.28, 0.72].forEach(px => {
280
+ g.append("line")
281
+ .attr("x1", size * px)
282
+ .attr("x2", size * px)
283
+ .attr("y1", 0)
284
+ .attr("y2", size * 0.26)
285
+ .attr("stroke", color)
286
+ .attr("stroke-width", strokeWidth)
287
+ .attr("stroke-linecap", "round");
288
+ });
289
+ return g;
290
+ }
291
+
292
+ if (type === "trend") {
293
+ [0.15, 0.38, 0.61].forEach((px, index) => {
294
+ const barHeight = size * (0.28 + index * 0.18);
295
+ g.append("rect")
296
+ .attr("x", size * px)
297
+ .attr("y", size * (0.88) - barHeight)
298
+ .attr("width", size * 0.12)
299
+ .attr("height", barHeight)
300
+ .attr("fill", color);
301
+ });
302
+ g.append("path")
303
+ .attr("d", `M${size * 0.12},${size * 0.58} L${size * 0.38},${size * 0.38} L${size * 0.58},${size * 0.46} L${size * 0.86},${size * 0.18}`)
304
+ .attr("fill", "none")
305
+ .attr("stroke", color)
306
+ .attr("stroke-width", strokeWidth)
307
+ .attr("stroke-linecap", "round")
308
+ .attr("stroke-linejoin", "round");
309
+ g.append("path")
310
+ .attr("d", `M${size * 0.74},${size * 0.18} L${size * 0.86},${size * 0.18} L${size * 0.86},${size * 0.31}`)
311
+ .attr("fill", "none")
312
+ .attr("stroke", color)
313
+ .attr("stroke-width", strokeWidth)
314
+ .attr("stroke-linecap", "round")
315
+ .attr("stroke-linejoin", "round");
316
+ return g;
317
+ }
318
+
319
+ if (type === "pulse") {
320
+ g.append("path")
321
+ .attr("d", `M0,${size * 0.55} L${size * 0.18},${size * 0.55} L${size * 0.28},${size * 0.28} L${size * 0.42},${size * 0.78} L${size * 0.55},${size * 0.2} L${size * 0.68},${size * 0.55} L${size},${size * 0.55}`)
322
+ .attr("fill", "none")
323
+ .attr("stroke", color)
324
+ .attr("stroke-width", strokeWidth)
325
+ .attr("stroke-linecap", "round")
326
+ .attr("stroke-linejoin", "round");
327
+ return g;
328
+ }
329
+
330
+ g.append("circle")
331
+ .attr("cx", size / 2)
332
+ .attr("cy", size / 2)
333
+ .attr("r", size * 0.36)
334
+ .attr("fill", "none")
335
+ .attr("stroke", color)
336
+ .attr("stroke-width", strokeWidth);
337
+ return g;
338
+ }
339
+
340
+ function splitValueLabel(label, unit) {
341
+ const text = String(label || "");
342
+ if (unit && text.endsWith(` ${unit}`)) {
343
+ return [text.slice(0, -unit.length - 1), unit];
344
+ }
345
+ return [text, ""];
346
+ }
347
+
348
+ svg.append("rect")
349
+ .attr("class", "chart-style-background")
350
+ .attr("width", width)
351
+ .attr("height", height)
352
+ .attr("fill", "#f6fbf1");
353
+
354
+ const cardX = Math.max(18, width * 0.035);
355
+ const cardY = 18;
356
+ const cardWidth = width - cardX * 2;
357
+ const cardHeight = height - cardY - 24;
358
+ const legendHeight = 82;
359
+ const legendGap = 20;
360
+ const headerHeight = 52;
361
+ const footerHeight = 44;
362
+ const footerGap = 20;
363
+ const tableY = cardY + legendHeight + legendGap;
364
+ const tableHeight = Math.max(
365
+ rowCount * 34 + headerHeight,
366
+ cardHeight - legendHeight - legendGap - footerHeight - footerGap
367
+ );
368
+ const innerHeight = Math.max(120, tableHeight - headerHeight);
369
+ const footerY = tableY + tableHeight + footerGap;
370
+ const labelMaxWidth = Math.min(230, cardWidth * 0.3);
371
+ const valueLabels = sortedData.map(d => formatWithUnit(d.__value, valueUnit));
372
+ const maxValueLabelWidth = Math.max(
373
+ 46,
374
+ ...valueLabels.map(label => measureLabelText(label, baseValueSize, valueFamily, valueWeight))
375
+ );
376
+ const rightColumnWidth = Math.max(122, maxValueLabelWidth + 62);
377
+ const labelFontSize = Math.max(8, Math.min(baseLabelSize, innerHeight / rowCount * 0.34));
378
+ const wrappedLabels = sortedData.map(d => wrapText(d.__dimension, labelMaxWidth, labelFontSize, labelFamily, labelWeight));
379
+ const maxLabelLineWidth = Math.max(
380
+ 54,
381
+ ...wrappedLabels.flat().map(line => measureLabelText(line, labelFontSize, labelFamily, labelWeight))
382
+ );
383
+ const leftColumnWidth = Math.max(132, Math.min(cardWidth * 0.36, maxLabelLineWidth + 66));
384
+ measureSvg.remove();
385
+
386
+ const innerWidth = Math.max(220, cardWidth - leftColumnWidth - rightColumnWidth);
387
+ const chartX = cardX + leftColumnWidth;
388
+ const chartY = tableY + headerHeight;
389
+ const yScale = d3.scaleBand()
390
+ .domain(sortedData.map(d => `${d.__dimension}__${d.__index}`))
391
+ .range([0, innerHeight])
392
+ .padding(0);
393
+
394
+ const values = sortedData.map(d => d.__value);
395
+ let minValue = Math.min(0, d3.min(values) ?? 0);
396
+ let maxValue = Math.max(0, d3.max(values) ?? 0);
397
+ if (minValue === maxValue) {
398
+ maxValue = maxValue === 0 ? 1 : maxValue * 1.1;
399
+ minValue = 0;
400
+ }
401
+ const xScale = d3.scaleLinear()
402
+ .domain([minValue, maxValue])
403
+ .nice(5)
404
+ .range([0, innerWidth]);
405
+ const zeroX = xScale(0);
406
+
407
+ const panel = svg.append("g")
408
+ .attr("class", "chart-style-panel");
409
+
410
+ panel.append("rect")
411
+ .attr("class", "legend-card")
412
+ .attr("x", cardX)
413
+ .attr("y", cardY)
414
+ .attr("width", cardWidth)
415
+ .attr("height", legendHeight)
416
+ .attr("rx", 8)
417
+ .attr("fill", "#ffffff")
418
+ .attr("stroke", "rgba(47, 143, 58, 0.18)")
419
+ .attr("stroke-width", 1);
420
+
421
+ const legendTitle = `${prettifyHeader(yColumn.name, "Value")} Level`;
422
+ const legendTextColor = "#1f2937";
423
+ const legendY = cardY;
424
+ const legendSeparatorX = cardX + cardWidth * 0.66;
425
+ const legendSegments = 18;
426
+ const legendSegmentStart = cardX + Math.min(118, cardWidth * 0.18);
427
+ const legendSegmentWidth = Math.max(7, Math.min(10, cardWidth * 0.016));
428
+ const legendSegmentGap = Math.max(5, Math.min(8, cardWidth * 0.012));
429
+ const legendSegmentY = legendY + 48;
430
+
431
+ panel.append("text")
432
+ .attr("class", "legend-title")
433
+ .attr("x", cardX + 22)
434
+ .attr("y", legendY + 28)
435
+ .attr("text-anchor", "start")
436
+ .style("font-family", labelFamily)
437
+ .style("font-size", `${Math.max(12, baseLabelSize * 1.05)}px`)
438
+ .style("font-weight", 700)
439
+ .style("fill", legendTextColor)
440
+ .text(legendTitle);
441
+
442
+ panel.append("text")
443
+ .attr("class", "legend-low")
444
+ .attr("x", cardX + 22)
445
+ .attr("y", legendY + 60)
446
+ .attr("text-anchor", "start")
447
+ .style("font-family", labelFamily)
448
+ .style("font-size", `${Math.max(12, baseLabelSize * 1.05)}px`)
449
+ .style("font-weight", 700)
450
+ .style("fill", tableGreen)
451
+ .text("Low");
452
+
453
+ d3.range(legendSegments).forEach(index => {
454
+ panel.append("rect")
455
+ .attr("class", "legend-segment")
456
+ .attr("x", legendSegmentStart + index * (legendSegmentWidth + legendSegmentGap))
457
+ .attr("y", legendSegmentY)
458
+ .attr("width", legendSegmentWidth)
459
+ .attr("height", 24)
460
+ .attr("rx", legendSegmentWidth / 2)
461
+ .attr("fill", d3.interpolateRgb("#dff4bf", tableGreen)(index / Math.max(1, legendSegments - 1)));
462
+ });
463
+
464
+ panel.append("text")
465
+ .attr("class", "legend-high")
466
+ .attr("x", Math.min(legendSeparatorX - 44, legendSegmentStart + legendSegments * (legendSegmentWidth + legendSegmentGap) + 10))
467
+ .attr("y", legendY + 60)
468
+ .attr("text-anchor", "start")
469
+ .style("font-family", labelFamily)
470
+ .style("font-size", `${Math.max(12, baseLabelSize * 1.05)}px`)
471
+ .style("font-weight", 700)
472
+ .style("fill", darkPrimary)
473
+ .text("High");
474
+
475
+ panel.append("line")
476
+ .attr("class", "legend-separator")
477
+ .attr("x1", legendSeparatorX)
478
+ .attr("x2", legendSeparatorX)
479
+ .attr("y1", legendY + 22)
480
+ .attr("y2", legendY + legendHeight - 22)
481
+ .attr("stroke", "rgba(47, 143, 58, 0.18)")
482
+ .attr("stroke-width", 1.5);
483
+
484
+ panel.append("circle")
485
+ .attr("class", "legend-dot")
486
+ .attr("cx", legendSeparatorX + 34)
487
+ .attr("cy", legendY + 42)
488
+ .attr("r", 7)
489
+ .attr("fill", primaryColor)
490
+ .attr("stroke", "#ffffff")
491
+ .attr("stroke-width", 2);
492
+
493
+ panel.append("text")
494
+ .attr("class", "legend-note")
495
+ .attr("x", legendSeparatorX + 58)
496
+ .attr("y", legendY + 35)
497
+ .attr("text-anchor", "start")
498
+ .style("font-family", valueFamily)
499
+ .style("font-size", `${Math.max(10, baseValueSize * 0.9)}px`)
500
+ .style("font-weight", valueWeight)
501
+ .style("fill", "rgba(31, 41, 55, 0.82)")
502
+ .text("Each bar represents");
503
+ panel.append("text")
504
+ .attr("class", "legend-note")
505
+ .attr("x", legendSeparatorX + 58)
506
+ .attr("y", legendY + 58)
507
+ .attr("text-anchor", "start")
508
+ .style("font-family", valueFamily)
509
+ .style("font-size", `${Math.max(10, baseValueSize * 0.9)}px`)
510
+ .style("font-weight", valueWeight)
511
+ .style("fill", "rgba(31, 41, 55, 0.82)")
512
+ .text("the relative level.");
513
+
514
+ panel.append("rect")
515
+ .attr("class", "plot-background")
516
+ .attr("x", cardX)
517
+ .attr("y", tableY)
518
+ .attr("width", cardWidth)
519
+ .attr("height", tableHeight)
520
+ .attr("rx", 8)
521
+ .attr("fill", "#ffffff")
522
+ .attr("stroke", "rgba(47, 143, 58, 0.18)")
523
+ .attr("stroke-width", 1);
524
+
525
+ panel.append("rect")
526
+ .attr("class", "table-header")
527
+ .attr("x", cardX)
528
+ .attr("y", tableY)
529
+ .attr("width", cardWidth)
530
+ .attr("height", headerHeight)
531
+ .attr("rx", 8)
532
+ .attr("fill", tableGreen);
533
+
534
+ panel.append("rect")
535
+ .attr("x", cardX)
536
+ .attr("y", tableY + headerHeight - 8)
537
+ .attr("width", cardWidth)
538
+ .attr("height", 8)
539
+ .attr("fill", tableGreen);
540
+
541
+ [
542
+ cardX + leftColumnWidth,
543
+ cardX + leftColumnWidth + innerWidth
544
+ ].forEach(x => {
545
+ panel.append("line")
546
+ .attr("class", "column-separator")
547
+ .attr("x1", x)
548
+ .attr("x2", x)
549
+ .attr("y1", tableY)
550
+ .attr("y2", tableY + tableHeight)
551
+ .attr("stroke", "rgba(47, 143, 58, 0.18)")
552
+ .attr("stroke-width", 1);
553
+ });
554
+
555
+ const headerFontSize = Math.max(11, Math.min(15, baseLabelSize * 1.02));
556
+ const headerIconSize = Math.max(16, Math.min(22, headerHeight * 0.42));
557
+ const levelHeaderText = "Relative Level";
558
+ const headerLabels = [
559
+ {
560
+ text: prettifyHeader(xColumn.name, "Category"),
561
+ x: cardX + 58,
562
+ anchor: "start",
563
+ icon: isTemporal ? "calendar" : "circle",
564
+ iconX: cardX + 24
565
+ },
566
+ {
567
+ text: levelHeaderText,
568
+ x: chartX + innerWidth / 2 + 14,
569
+ anchor: "middle",
570
+ icon: "pulse",
571
+ iconX: chartX + innerWidth / 2 - 92
572
+ },
573
+ {
574
+ text: "Value",
575
+ x: cardX + cardWidth - 22,
576
+ anchor: "end",
577
+ icon: "trend",
578
+ iconX: cardX + cardWidth - rightColumnWidth + 22
579
+ }
580
+ ];
581
+ headerLabels.forEach(label => {
582
+ appendMiniIcon(
583
+ panel,
584
+ label.icon,
585
+ label.iconX,
586
+ tableY + headerHeight / 2 - headerIconSize / 2,
587
+ headerIconSize,
588
+ "#ffffff",
589
+ "table-header-icon"
590
+ );
591
+ });
592
+ panel.selectAll(".table-header-label")
593
+ .data(headerLabels)
594
+ .enter()
595
+ .append("text")
596
+ .attr("class", "table-header-label")
597
+ .attr("x", d => d.x)
598
+ .attr("y", tableY + headerHeight / 2)
599
+ .attr("dy", "0.35em")
600
+ .attr("text-anchor", d => d.anchor)
601
+ .style("font-family", labelFamily)
602
+ .style("font-size", `${headerFontSize}px`)
603
+ .style("font-weight", 700)
604
+ .style("fill", "#ffffff")
605
+ .text(d => d.text);
606
+
607
+ const plot = svg.append("g")
608
+ .attr("transform", `translate(${chartX}, ${chartY})`);
609
+
610
+ plot.selectAll(".row-separator")
611
+ .data(sortedData)
612
+ .enter()
613
+ .append("line")
614
+ .attr("class", "row-separator")
615
+ .attr("x1", -leftColumnWidth)
616
+ .attr("x2", innerWidth + rightColumnWidth)
617
+ .attr("y1", d => (yScale(`${d.__dimension}__${d.__index}`) || 0) + yScale.bandwidth())
618
+ .attr("y2", d => (yScale(`${d.__dimension}__${d.__index}`) || 0) + yScale.bandwidth())
619
+ .attr("stroke", "rgba(47, 143, 58, 0.13)")
620
+ .attr("stroke-width", 1);
621
+
622
+ const rowGroups = plot.selectAll(".row")
623
+ .data(sortedData)
624
+ .enter()
625
+ .append("g")
626
+ .attr("class", "row")
627
+ .attr("transform", d => `translate(0, ${yScale(`${d.__dimension}__${d.__index}`)})`);
628
+
629
+ rowGroups.append("rect")
630
+ .attr("class", "row-band")
631
+ .attr("x", -leftColumnWidth)
632
+ .attr("y", 0)
633
+ .attr("width", cardWidth)
634
+ .attr("height", yScale.bandwidth())
635
+ .attr("fill", (d, i) => i % 2 === 0 ? "rgba(47, 143, 58, 0.035)" : "transparent");
636
+
637
+ const trackHeight = Math.max(5, Math.min(9, yScale.bandwidth() * 0.18));
638
+ const dotRadius = Math.max(4.5, Math.min(7.5, yScale.bandwidth() * 0.2));
639
+ const valueFontSize = Math.max(8, Math.min(baseValueSize, yScale.bandwidth() * 0.35));
640
+ const rowCenter = yScale.bandwidth() / 2;
641
+ const trackStart = xScale(Math.max(0, minValue));
642
+ const trackEnd = xScale(maxValue);
643
+
644
+ rowGroups.append("line")
645
+ .attr("class", "dot-bar-track")
646
+ .attr("x1", trackStart)
647
+ .attr("x2", trackEnd)
648
+ .attr("y1", rowCenter)
649
+ .attr("y2", rowCenter)
650
+ .attr("stroke", "rgba(47, 143, 58, 0.11)")
651
+ .attr("stroke-width", trackHeight)
652
+ .attr("stroke-linecap", "round");
653
+
654
+ rowGroups.append("line")
655
+ .attr("class", "dot-bar bar")
656
+ .attr("x1", zeroX)
657
+ .attr("x2", d => xScale(d.__value))
658
+ .attr("y1", rowCenter)
659
+ .attr("y2", rowCenter)
660
+ .attr("stroke", "transparent")
661
+ .attr("stroke-width", 1)
662
+ .attr("stroke-linecap", "round")
663
+ .attr("opacity", 0);
664
+
665
+ const segmentGap = Math.max(7.5, trackHeight * 1.05);
666
+ const segmentWidth = Math.max(2.6, trackHeight * 0.38);
667
+ const segmentHeight = Math.max(8, trackHeight * 1.22);
668
+ rowGroups.each(function(d) {
669
+ const valueX = xScale(d.__value);
670
+ const startX = Math.min(zeroX, valueX);
671
+ const endX = Math.max(zeroX, valueX);
672
+ const span = Math.max(1, endX - startX);
673
+ const segmentXs = d3.range(startX + segmentGap * 0.55, Math.max(startX + segmentGap, endX - dotRadius * 0.65), segmentGap);
674
+ d3.select(this).selectAll(".dot-segment")
675
+ .data(segmentXs)
676
+ .enter()
677
+ .append("rect")
678
+ .attr("class", "dot-segment")
679
+ .attr("x", x => x - segmentWidth / 2)
680
+ .attr("y", rowCenter - segmentHeight / 2)
681
+ .attr("width", segmentWidth)
682
+ .attr("height", segmentHeight)
683
+ .attr("rx", segmentWidth / 2)
684
+ .attr("fill", x => d3.interpolateRgb(palePrimary, primaryColor)(Math.min(1, Math.max(0, (x - startX) / span))))
685
+ .attr("opacity", 0.96);
686
+ });
687
+
688
+ rowGroups.append("circle")
689
+ .attr("class", "dot mark")
690
+ .attr("cx", d => xScale(d.__value))
691
+ .attr("cy", rowCenter)
692
+ .attr("r", dotRadius)
693
+ .attr("fill", primaryColor)
694
+ .attr("stroke", variables.has_stroke ? darkPrimary : "#ffffff")
695
+ .attr("stroke-width", variables.has_stroke ? 1.4 : 1.8)
696
+ .style("filter", variables.has_shadow ? "url(#dot-shadow)" : "none");
697
+
698
+ rowGroups.each(function(d, rowIndex) {
699
+ const lines = wrappedLabels[rowIndex] || [d.__dimension];
700
+ const lineHeight = labelFontSize * 1.08;
701
+ const startY = rowCenter - ((lines.length - 1) * lineHeight) / 2;
702
+ appendMiniIcon(
703
+ d3.select(this),
704
+ isTemporal ? "calendar" : "circle",
705
+ -leftColumnWidth + 22,
706
+ rowCenter - 8,
707
+ 16,
708
+ tableGreen,
709
+ "row-icon"
710
+ );
711
+ const label = d3.select(this).append("text")
712
+ .attr("class", "label")
713
+ .attr("x", -leftColumnWidth + 50)
714
+ .attr("y", startY)
715
+ .attr("text-anchor", "start")
716
+ .style("font-family", labelFamily)
717
+ .style("font-size", `${labelFontSize}px`)
718
+ .style("font-weight", labelWeight)
719
+ .style("fill", textColor);
720
+ lines.forEach((line, lineIndex) => {
721
+ label.append("tspan")
722
+ .attr("x", -leftColumnWidth + 50)
723
+ .attr("dy", lineIndex === 0 ? "0.35em" : `${lineHeight}px`)
724
+ .text(line);
725
+ });
726
+ });
727
+
728
+ rowGroups.each(function(d) {
729
+ const [valueNumber, valueSuffix] = splitValueLabel(formatWithUnit(d.__value, valueUnit), valueUnit);
730
+ const value = d3.select(this).append("text")
731
+ .attr("class", "value")
732
+ .attr("x", innerWidth + rightColumnWidth - 22)
733
+ .attr("y", rowCenter)
734
+ .attr("dy", "0.35em")
735
+ .attr("text-anchor", "end")
736
+ .style("font-family", valueFamily)
737
+ .style("font-size", `${valueFontSize}px`)
738
+ .style("fill", textColor);
739
+ value.append("tspan")
740
+ .attr("class", "value-number")
741
+ .style("font-weight", 700)
742
+ .text(valueNumber);
743
+ if (valueSuffix) {
744
+ value.append("tspan")
745
+ .attr("class", "value-unit")
746
+ .style("font-weight", valueWeight)
747
+ .style("fill", "rgba(31, 41, 55, 0.72)")
748
+ .text(` ${valueSuffix}`);
749
+ }
750
+ });
751
+
752
+ panel.append("rect")
753
+ .attr("class", "source-footer")
754
+ .attr("x", cardX)
755
+ .attr("y", footerY)
756
+ .attr("width", cardWidth)
757
+ .attr("height", footerHeight)
758
+ .attr("rx", 8)
759
+ .attr("fill", "rgba(255, 255, 255, 0.74)")
760
+ .attr("stroke", "rgba(47, 143, 58, 0.14)")
761
+ .attr("stroke-width", 1);
762
+
763
+ panel.append("circle")
764
+ .attr("class", "source-footer-icon")
765
+ .attr("cx", cardX + 30)
766
+ .attr("cy", footerY + footerHeight / 2)
767
+ .attr("r", 10)
768
+ .attr("fill", primaryColor)
769
+ .attr("stroke", "#ffffff")
770
+ .attr("stroke-width", 2);
771
+
772
+ panel.append("text")
773
+ .attr("class", "source-footer-label")
774
+ .attr("x", cardX + 30)
775
+ .attr("y", footerY + footerHeight / 2)
776
+ .attr("dy", "0.35em")
777
+ .attr("text-anchor", "middle")
778
+ .style("font-family", valueFamily)
779
+ .style("font-size", `${Math.max(9, baseValueSize * 0.86)}px`)
780
+ .style("font-weight", 700)
781
+ .style("fill", "#ffffff")
782
+ .text("i");
783
+
784
+ panel.append("text")
785
+ .attr("class", "source-footer-text")
786
+ .attr("x", cardX + 56)
787
+ .attr("y", footerY + footerHeight / 2)
788
+ .attr("dy", "0.35em")
789
+ .attr("text-anchor", "start")
790
+ .style("font-family", valueFamily)
791
+ .style("font-size", `${Math.max(9, baseValueSize * 0.86)}px`)
792
+ .style("font-weight", valueWeight)
793
+ .style("fill", "rgba(31, 41, 55, 0.72)")
794
+ .text("Data values are generated from the provided source sample.");
795
+
796
+ return svg.node();
797
+ }
modules/chart_engine/template/d3-js/type21_horizontal_dot_bar_chart/horizontal_dot_bar_plain_chart_02.js ADDED
@@ -0,0 +1,587 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Horizontal Dot Bar Chart",
5
+ "chart_name": "horizontal_dot_bar_plain_chart_02",
6
+ "is_composite": false,
7
+ "required_fields": ["x", "y"],
8
+ "required_fields_type": [["categorical"], ["numerical"]],
9
+ "required_fields_range": [[5, 30], [0, 20]],
10
+ "required_fields_icons": ["x"],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": [],
13
+ "required_other_colors": [],
14
+ "supported_effects": ["shadow", "gradient", "stroke", "spacing"],
15
+ "min_height": 400,
16
+ "min_width": 400,
17
+ "background": "no",
18
+ "icon_mark": "none",
19
+ "icon_label": "none",
20
+ "has_x_axis": "no",
21
+ "has_y_axis": "no"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ // 百分比竖条图实现 - 使用D3.js
27
+ function makeChart(containerSelector, data) {
28
+ const colorResolver = chartUtils.color.resolver(data);
29
+ // ---------- 1. 数据准备阶段 ----------
30
+
31
+ // 提取数据和配置
32
+ const jsonData = data; // 完整的JSON数据对象
33
+ const chartData = jsonData.data.data // 实际数据点数组
34
+ const variables = jsonData.variables || {}; // 图表配置
35
+ const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
36
+ title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
37
+ label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
38
+ description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
39
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
40
+ };
41
+ const colors = jsonData.colors || {
42
+ text_color: "#333333",
43
+ other: { primary: "#FFBB33" } // 默认橙黄色
44
+ };
45
+ const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
46
+
47
+ // 设置视觉效果变量的默认值
48
+ variables.has_rounded_corners = variables.has_rounded_corners !== undefined ? variables.has_rounded_corners : true;
49
+ variables.has_shadow = variables.has_shadow !== undefined ? variables.has_shadow : false;
50
+ variables.has_gradient = true; // 确保渐变被启用
51
+ variables.has_stroke = variables.has_stroke !== undefined ? variables.has_stroke : false;
52
+ variables.has_spacing = variables.has_spacing !== undefined ? variables.has_spacing : true;
53
+
54
+ // 数值格式化函数
55
+
56
+ // 计算类别label布局(统一字号+智能换行+测宽+margin.left计算)
57
+ const buildDimLabelLayout = (params) => {
58
+ const {
59
+ containerSelector,
60
+ labels,
61
+ fontConfig,
62
+ maxWidth,
63
+ maxHeight,
64
+ lineSpacing,
65
+ targetFontSize
66
+ } = params;
67
+
68
+ const tempMeasureSvg = d3.select(containerSelector)
69
+ .append("svg")
70
+ .attr("width", 0)
71
+ .attr("height", 0)
72
+ .style("visibility", "hidden");
73
+
74
+ const measureWidth = (text, fontSize) => chartUtils.text.measure(tempMeasureSvg, text, {
75
+ fontFamily: fontConfig.font_family,
76
+ fontSize,
77
+ fontWeight: fontConfig.font_weight
78
+ }).width;
79
+
80
+ const breakLongWord = (word, maxWidth, fontSize) => {
81
+ const lines = [];
82
+ let currentLine = "";
83
+ for (let i = 0; i < word.length; i++) {
84
+ const char = word[i];
85
+ const testWithChar = currentLine + char;
86
+ const testWithHyphen = currentLine + "-";
87
+ if (measureWidth(testWithChar, fontSize) > maxWidth && currentLine !== "") {
88
+ lines.push(testWithHyphen);
89
+ currentLine = char;
90
+ } else {
91
+ currentLine = testWithChar;
92
+ }
93
+ }
94
+ if (currentLine !== "") lines.push(currentLine);
95
+ return lines.length > 0 ? lines : [word];
96
+ };
97
+
98
+ const breakWordIntoCurrentLine = (currentLine, word, maxWidth, fontSize) => {
99
+ const completedLines = [];
100
+ let chars = "";
101
+ for (let i = 0; i < word.length; i++) {
102
+ const char = word[i];
103
+ const testLine = currentLine + chars + char;
104
+ const testLineWithHyphen = currentLine + chars + "-";
105
+ if (measureWidth(testLine, fontSize) > maxWidth) {
106
+ if (chars === "") {
107
+ completedLines.push(currentLine);
108
+ const brokenLines = breakLongWord(word, maxWidth, fontSize);
109
+ return {
110
+ completedLines: [...completedLines, ...brokenLines.slice(0, -1)],
111
+ remaining: brokenLines[brokenLines.length - 1]
112
+ };
113
+ }
114
+ completedLines.push(testLineWithHyphen);
115
+ const remaining = word.substring(i);
116
+ const remainingWidth = measureWidth(remaining, fontSize);
117
+ if (remainingWidth > maxWidth) {
118
+ const brokenLines = breakLongWord(remaining, maxWidth, fontSize);
119
+ return {
120
+ completedLines: [...completedLines, ...brokenLines.slice(0, -1)],
121
+ remaining: brokenLines[brokenLines.length - 1]
122
+ };
123
+ }
124
+ return { completedLines, remaining };
125
+ }
126
+ chars += char;
127
+ }
128
+ return { completedLines, remaining: currentLine + chars };
129
+ };
130
+
131
+ const wrapSmart = (text, maxWidth, fontSize) => {
132
+ const words = String(text || "").split(/\s+/).filter(w => w !== "");
133
+ if (words.length === 0) return [String(text || "")];
134
+
135
+ const lines = [];
136
+ let currentLine = "";
137
+
138
+ for (let i = 0; i < words.length; i++) {
139
+ const word = words[i];
140
+ const separator = currentLine === "" ? "" : " ";
141
+ const testLine = currentLine + separator + word;
142
+ if (measureWidth(testLine, fontSize) <= maxWidth) {
143
+ currentLine = testLine;
144
+ continue;
145
+ }
146
+
147
+ if (currentLine === "") {
148
+ const broken = breakLongWord(word, maxWidth, fontSize);
149
+ lines.push(...broken.slice(0, -1));
150
+ currentLine = broken[broken.length - 1];
151
+ continue;
152
+ }
153
+
154
+ const utilization = measureWidth(currentLine, fontSize) / maxWidth;
155
+ if (utilization < 0.5) {
156
+ const result = breakWordIntoCurrentLine(currentLine, word, maxWidth, fontSize);
157
+ lines.push(...result.completedLines);
158
+ currentLine = result.remaining;
159
+ continue;
160
+ }
161
+
162
+ lines.push(currentLine);
163
+ if (measureWidth(word, fontSize) > maxWidth) {
164
+ const broken = breakLongWord(word, maxWidth, fontSize);
165
+ lines.push(...broken.slice(0, -1));
166
+ currentLine = broken[broken.length - 1];
167
+ } else {
168
+ currentLine = word;
169
+ }
170
+ }
171
+
172
+ if (currentLine !== "") lines.push(currentLine);
173
+
174
+ const out = [...lines];
175
+ for (let i = 1; i < out.length; i++) {
176
+ const prevTrim = (out[i - 1] || "").trimEnd();
177
+ if (!prevTrim.endsWith("-")) continue;
178
+
179
+ const curTrim = String(out[i] || "").trim();
180
+ const match = curTrim.match(/^([A-Za-z])(\s+.*)?$/);
181
+ if (!match) continue;
182
+
183
+ const firstLetter = match[1];
184
+ const rest = (match[2] || "").trimStart();
185
+ const candidatePrev = prevTrim.slice(0, -1) + firstLetter;
186
+ if (measureWidth(candidatePrev, fontSize) > maxWidth) continue;
187
+
188
+ out[i - 1] = candidatePrev;
189
+ if (rest === "") {
190
+ out.splice(i, 1);
191
+ i -= 1;
192
+ } else {
193
+ out[i] = rest;
194
+ }
195
+ }
196
+
197
+ return out.length > 0 ? out : [String(text || "")];
198
+ };
199
+
200
+ const fitsAll = (fontSize) => {
201
+ const lineHeight = fontSize * lineSpacing;
202
+ for (let i = 0; i < labels.length; i++) {
203
+ const labelText = String(labels[i] || "");
204
+ const lines = wrapSmart(labelText, maxWidth, fontSize);
205
+ const totalHeight = (lines.length <= 1)
206
+ ? fontSize
207
+ : ((lines.length - 1) * lineHeight + fontSize);
208
+ if (totalHeight > maxHeight) return false;
209
+ }
210
+ return true;
211
+ };
212
+
213
+ let low = 1;
214
+ let high = targetFontSize;
215
+ for (let i = 0; i < 18; i++) {
216
+ const mid = (low + high) / 2;
217
+ if (fitsAll(mid)) low = mid;
218
+ else high = mid;
219
+ }
220
+
221
+ const bestFontSize = low;
222
+ const lineHeight = bestFontSize * lineSpacing;
223
+ const dimLabelConfig = {};
224
+ let maxLabelWidth = 0;
225
+
226
+ for (let i = 0; i < labels.length; i++) {
227
+ const label = labels[i];
228
+ const labelText = String(label || "");
229
+ const lines = wrapSmart(labelText, maxWidth, bestFontSize);
230
+ dimLabelConfig[label] = {
231
+ fontSize: bestFontSize,
232
+ lineHeight: lineHeight,
233
+ lines: lines,
234
+ needsWrapping: lines.length > 1
235
+ };
236
+ for (let j = 0; j < lines.length; j++) {
237
+ maxLabelWidth = Math.max(maxLabelWidth, measureWidth(lines[j], bestFontSize));
238
+ }
239
+ }
240
+
241
+ tempMeasureSvg.remove();
242
+ return { dimLabelConfig, maxLabelWidth, fontSize: bestFontSize, lineHeight };
243
+ };
244
+
245
+ // 清空容器
246
+ d3.select(containerSelector).html("");
247
+
248
+ // ---------- 2. 尺寸和布局设置 ----------
249
+
250
+ // 设置图表总尺寸
251
+ const width = variables.width || 800;
252
+ const height = variables.height || 600;
253
+
254
+ // 设置边距
255
+ const margin = {
256
+ top: 90, // 顶部留出标题空间
257
+ right: 30, // 右侧足够显示数值
258
+ bottom: 40, // 底部边距
259
+ left: 100 // 左侧足够空间用于维度标签
260
+ };
261
+
262
+ // ---------- 3. 提取字段名和单位 ----------
263
+
264
+ // 根据数据列顺序提取字段名
265
+ const dimensionField = chartUtils.schema.channel(jsonData, "x").key || "dimension";
266
+ const valueField = chartUtils.schema.channel(jsonData, "y").key || "value";
267
+
268
+ // 获取字段单位(如果存在)
269
+ let dimensionUnit = "";
270
+ let valueUnit = ""; // 默认为百分比
271
+
272
+ if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
273
+ dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit || "";
274
+ }
275
+
276
+ if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
277
+ valueUnit = chartUtils.schema.channel(jsonData, "y").unit || "";
278
+ }
279
+
280
+ // ---------- 4. 数据处理 ----------
281
+
282
+ // 获取唯一维度值并按数值降序排列数据
283
+ const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
284
+ const maxValue_ = d3.max(chartData, d => Math.abs(+d[valueField]));
285
+
286
+ // 计算每个棒子表示的单位数量
287
+ // 目标:最大值对应的棒子数量在20-50之间
288
+ const targetMaxBars = 35; // 选择目标范围(20-50)的中间值
289
+ const unitPerBar = Math.ceil(maxValue_ / targetMaxBars);
290
+
291
+ // 为每个数据点计算应显示的棒子数量
292
+ chartData.forEach(d => {
293
+ // 原始数值保存在 valueField
294
+ // 转换后的棒子数量保存在 valueField_
295
+ d[`${valueField}_`] = Math.max(0.1, +d[valueField] / unitPerBar);
296
+ });
297
+
298
+ // 按数值降序排序数据
299
+ const sortedData = [...chartData].sort((a, b) => +b[valueField] - +a[valueField]);
300
+ const sortedDimensions = sortedData.map(d => d[dimensionField]);
301
+
302
+ // ---------- 5. 布局计算和字体调整 ----------
303
+
304
+ const dimLabelMaxWidth = width * 0.25;
305
+ const dimLabelLineSpacing = 1.01;
306
+ const targetLabelFontSize = parseFloat(typography.label.font_size) || 12;
307
+
308
+ // 计算内部绘图区域尺寸(初始值)
309
+ let innerWidth = width - margin.left - margin.right;
310
+ let innerHeight = height - margin.top - margin.bottom;
311
+
312
+ // 计算行间距
313
+ const rowPadding = variables.has_spacing ? 0.3 : 0.2;
314
+
315
+ // Y轴比例尺(用于维度)
316
+ const yScale = d3.scaleBand()
317
+ .domain(sortedDimensions)
318
+ .range([0, innerHeight])
319
+ .padding(rowPadding);
320
+
321
+ const rowHeight = yScale.bandwidth();
322
+
323
+ // 调用智能换行布局计算
324
+ const dimLabelResult = buildDimLabelLayout({
325
+ containerSelector: containerSelector,
326
+ labels: sortedDimensions,
327
+ fontConfig: typography.label,
328
+ maxWidth: dimLabelMaxWidth,
329
+ maxHeight: rowHeight,
330
+ lineSpacing: dimLabelLineSpacing,
331
+ targetFontSize: targetLabelFontSize
332
+ });
333
+
334
+ const { dimLabelConfig, maxLabelWidth } = dimLabelResult;
335
+
336
+ // 根据标签宽度调整左边距
337
+ margin.left = maxLabelWidth + 15;
338
+
339
+ // 重新计算内部绘图区域尺寸
340
+ innerWidth = width - margin.left - margin.right;
341
+ innerHeight = height - margin.top - margin.bottom;
342
+
343
+ // ---------- 6. 创建SVG容器 ----------
344
+
345
+ const svg = d3.select(containerSelector)
346
+ .append("svg")
347
+ .attr("width", "100%")
348
+ .attr("height", height)
349
+ .attr("viewBox", `0 0 ${width} ${height}`)
350
+ .attr("style", "max-width: 100%; height: auto;")
351
+ .attr("xmlns", "http://www.w3.org/2000/svg")
352
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
353
+
354
+
355
+ // 添加defs用于视觉效果
356
+ const defs = svg.append("defs");
357
+
358
+ // 添加阴影滤镜(如果启用)
359
+ if (variables.has_shadow) {
360
+ const filter = defs.append("filter")
361
+ .attr("id", "shadow")
362
+ .attr("filterUnits", "userSpaceOnUse")
363
+ .attr("width", "200%")
364
+ .attr("height", "200%");
365
+
366
+ filter.append("feGaussianBlur")
367
+ .attr("in", "SourceAlpha")
368
+ .attr("stdDeviation", 2);
369
+
370
+ filter.append("feOffset")
371
+ .attr("dx", 2)
372
+ .attr("dy", 2)
373
+ .attr("result", "offsetblur");
374
+
375
+ const feMerge = filter.append("feMerge");
376
+ feMerge.append("feMergeNode");
377
+ feMerge.append("feMergeNode").attr("in", "SourceGraphic");
378
+ }
379
+
380
+ // 获取主要颜色
381
+ const primaryColor = colorResolver.other("primary", { fallback: "#FFBB33" }).value;
382
+
383
+ // 创建每10个棒子一组的渐变定义
384
+ const maxGroups = 10; // 假设最多10组(即最大100%)
385
+ for (let group = 0; group < maxGroups; group++) {
386
+ // 为每组创建渐变
387
+ const gradient = defs.append("linearGradient")
388
+ .attr("id", `bar-gradient-${group}`)
389
+ .attr("x1", "0%")
390
+ .attr("y1", "0%")
391
+ .attr("x2", "100%")
392
+ .attr("y2", "0%");
393
+
394
+ // 计算此组的亮度偏移
395
+ // 随着组号增加,整体颜色变深
396
+ const groupDarkenFactor = 0.2 * group;
397
+
398
+ gradient.append("stop")
399
+ .attr("offset", "0%")
400
+ .attr("stop-color", colorResolver.variant(primaryColor, { mode: "brighter", amount: 1.0 - groupDarkenFactor }));
401
+
402
+ gradient.append("stop")
403
+ .attr("offset", "100%")
404
+ .attr("stop-color", colorResolver.variant(primaryColor, { mode: "darker", amount: 0.0 + groupDarkenFactor }));
405
+ }
406
+
407
+ // ---------- 7. 创建比例尺 ----------
408
+
409
+ // 计算最大值,确保所有棒子能够显示
410
+ const maxValue = d3.max(chartData, d => +d[`${valueField}_`]);
411
+
412
+ // 计算适合当前宽度的棒子尺寸
413
+ const availableWidth = innerWidth;
414
+
415
+ // 计算合适的棒子尺寸,确保所有数据都能显示
416
+ // 默认值
417
+ const defaultBarWidth = 30;
418
+ const defaultBarSpacing = 5;
419
+ const largerGroupSpacing = 15; // 每10个棒子后的额外间距
420
+
421
+ // 棒子组大小(每组10个)
422
+ const groupSize = 5;
423
+
424
+ // 计算一组10个棒子需要的宽度(包括普通间距)
425
+ const groupBaseWidth = groupSize * defaultBarWidth + (groupSize - 1) * defaultBarSpacing;
426
+
427
+ // 计算需要多少组
428
+ const requiredGroups = Math.ceil(maxValue / groupSize);
429
+
430
+ // 计算总需要宽度(包括组间额外间距)
431
+ const requiredWidth = requiredGroups * groupBaseWidth + (requiredGroups - 1) * largerGroupSpacing;
432
+
433
+ // 如果需要的宽度大于可用宽度,则缩放
434
+ let barWidth, barSpacing, groupSpacing;
435
+
436
+ if (requiredWidth > availableWidth) {
437
+ const scaleFactor = availableWidth / requiredWidth;
438
+ barWidth = Math.max(3, Math.floor(defaultBarWidth * scaleFactor));
439
+ barSpacing = Math.max(1, Math.floor(defaultBarSpacing * scaleFactor));
440
+ groupSpacing = Math.max(3, Math.floor(largerGroupSpacing * scaleFactor));
441
+ } else {
442
+ barWidth = defaultBarWidth;
443
+ barSpacing = defaultBarSpacing;
444
+ groupSpacing = largerGroupSpacing;
445
+ }
446
+
447
+ // ---------- 8. 创建主图表组 ----------
448
+
449
+ const g = svg.append("g")
450
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
451
+
452
+
453
+
454
+ // ---------- 10. 绘制百分比竖条图和标签 ----------
455
+
456
+ // 为每个维度绘制竖条组和标签
457
+ sortedDimensions.forEach(dimension => {
458
+ const dataPoint = chartData.find(d => d[dimensionField] === dimension);
459
+
460
+ if (dataPoint) {
461
+ const rowHeight = yScale.bandwidth();
462
+ const barHeight = rowHeight; // 竖条高度为行高
463
+ const barY = yScale(dimension) + (rowHeight - barHeight) / 2; // 竖条垂直居中
464
+ const barCount = Math.floor(dataPoint[`${valueField}_`]); // 向下取整到整数
465
+ const partialBar = dataPoint[`${valueField}_`] - barCount; // 计算小数部分
466
+
467
+ // 绘制竖条组
468
+ for (let i = 0; i < barCount; i++) {
469
+ // 计算当前棒子所在的组(每5个一组)
470
+ const groupIndex = Math.floor(i / groupSize);
471
+ // 计算在当前组内的索引(0-4)
472
+ const inGroupIndex = i % groupSize;
473
+
474
+ // 计算x位置(考虑组间的额外间距)
475
+ const barX = (groupIndex * (groupSize * (barWidth + barSpacing) + groupSpacing)) +
476
+ (inGroupIndex * (barWidth + barSpacing));
477
+
478
+ // 获取当前组的渐变ID
479
+ const gradientId = `bar-gradient-${groupIndex}`;
480
+
481
+ // 绘制单个竖条
482
+ g.append("image")
483
+ .attr("x", barX)
484
+ .attr("y", barY)
485
+ .attr("width", barWidth)
486
+ .attr("height", barHeight)
487
+ .attr("xlink:href", jsonData.images.field[dataPoint[dimensionField]]);
488
+ }
489
+
490
+ // 如果有小数部分,绘制部分宽度的最后一个竖条
491
+ if (partialBar > 0) {
492
+ const groupIndex = Math.floor(barCount / groupSize);
493
+ const inGroupIndex = barCount % groupSize;
494
+
495
+ const barX = (groupIndex * (groupSize * (barWidth + barSpacing) + groupSpacing)) +
496
+ (inGroupIndex * (barWidth + barSpacing));
497
+
498
+ // 使用SVG裁剪方式实现部分图像显示
499
+ const imageWidth = barWidth * partialBar;
500
+
501
+ // 方法一:使用裁剪路径实现部分图像显示
502
+ // 创建唯一的裁剪路径ID
503
+ const clipPathId = `clip-path-${dimension.replace(/\s+/g, '-')}-${barCount}`;
504
+
505
+ // 在defs中定义裁剪路径 - 注意:clipPath采用用户坐标系统
506
+ defs.append("clipPath")
507
+ .attr("id", clipPathId)
508
+ .attr("clipPathUnits", "userSpaceOnUse") // 使用用户坐标系而非对象坐标系
509
+ .append("rect")
510
+ .attr("x", barX) // 使用与图像相同的x坐标
511
+ .attr("y", barY) // 使用与图像相同的y坐标
512
+ .attr("width", imageWidth)
513
+ .attr("height", barHeight);
514
+
515
+ // 使用裁剪路径显示部分图像
516
+ g.append("image")
517
+ .attr("x", barX)
518
+ .attr("y", barY)
519
+ .attr("width", barWidth) // 保持原始宽度
520
+ .attr("height", barHeight)
521
+ .attr("clip-path", `url(#${clipPathId})`)
522
+ .attr("xlink:href", jsonData.images.field[dataPoint[dimensionField]]);
523
+ }
524
+
525
+ // 添加维度标签(支持多行)
526
+ const labelConfig = dimLabelConfig[dimension];
527
+ const centerY = yScale(dimension) + rowHeight / 2;
528
+ const labelGroup = g.append("text")
529
+ .attr("x", -10)
530
+ .attr("text-anchor", "end")
531
+ .style("font-family", typography.label.font_family)
532
+ .style("font-size", `${labelConfig.fontSize}px`)
533
+ .style("font-weight", typography.label.font_weight)
534
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value);
535
+
536
+ if (labelConfig.needsWrapping) {
537
+ const lineHeight = labelConfig.lineHeight;
538
+ const startY = centerY - ((labelConfig.lines.length - 1) * lineHeight) / 2;
539
+ labelGroup.attr("y", startY);
540
+
541
+ labelConfig.lines.forEach((line, i) => {
542
+ labelGroup.append("tspan")
543
+ .attr("x", -10)
544
+ .attr("dy", i === 0 ? "0.35em" : `${lineHeight}`)
545
+ .text(line);
546
+ });
547
+ } else {
548
+ labelGroup
549
+ .attr("y", centerY)
550
+ .attr("dy", "0.35em")
551
+ .text(labelConfig.lines[0]);
552
+ }
553
+
554
+ // 计算动态字体大小(条形高度的60%)
555
+ const dynamicFontSize = `${barHeight * 0.8}px`;
556
+
557
+ // 计算最后一个棒子的位置,用于放置数值标签
558
+ const lastGroupIndex = Math.floor(barCount / groupSize);
559
+ const lastInGroupIndex = barCount % groupSize;
560
+ let lastBarX = (lastGroupIndex * (groupSize * (barWidth + barSpacing) + groupSpacing)) +
561
+ (lastInGroupIndex * (barWidth + barSpacing));
562
+
563
+ // 如果有小数部分,调整标签位置
564
+ if (partialBar > 0) {
565
+ lastBarX += barWidth * partialBar;
566
+ } else {
567
+ lastBarX += barWidth;
568
+ }
569
+
570
+ g.append("text")
571
+ .attr("x", lastBarX + 5)
572
+ .attr("y", yScale(dimension) + rowHeight / 2)
573
+ .attr("dy", "0.35em")
574
+ .attr("text-anchor", "start")
575
+ .style("font-family", typography.annotation.font_family)
576
+ .style("font-size", dynamicFontSize)
577
+ .style("font-weight", typography.annotation.font_weight)
578
+ .style("fill", colorResolver.text({ fallback: "#333333" }).value)
579
+ .text(valueUnit ?
580
+ `${chartUtils.format.autoText(+dataPoint[valueField])}${valueUnit}` :
581
+ `${chartUtils.format.autoText(+dataPoint[valueField])}`);
582
+ }
583
+ });
584
+
585
+ // 返回SVG节点
586
+ return svg.node();
587
+ }