Ray1ee01 commited on
Commit
4de7714
·
verified ·
1 Parent(s): 4bd7d86

Upload folder using huggingface_hub

Browse files
modules/chart_engine/template/d3-js/type71_small_multiples_of_gauge/small_multiples_gauge_plain_chart_01.js ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Small Multiples of Gauge Chart",
5
+ "chart_name": "small_multiples_gauge_plain_chart_01",
6
+ "required_fields": ["x", "y", "group"],
7
+ "required_fields_type": [["categorical"], ["numerical"], ["categorical"]],
8
+ "required_fields_range": [[2, 9], [0, "inf"], [2, 3]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": ["group"],
12
+ "required_other_colors": [],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 600,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "no",
20
+ "has_y_axis": "no"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, data) {
26
+ // 数值格式化函数
27
+
28
+ // 提取数据
29
+ const jsonData = data;
30
+ const chartData = jsonData.data.data;
31
+ const variables = jsonData.variables;
32
+ const typography = jsonData.typography;
33
+ const colors = jsonData.colors || {};
34
+ const colorResolver = chartUtils.color.resolver(jsonData);
35
+ const dataColumns = chartUtils.schema.columns(jsonData);
36
+ const images = jsonData.images || {};
37
+
38
+ // 清空容器
39
+ d3.select(containerSelector).html("");
40
+
41
+ // 获取字段名
42
+ const xField = chartUtils.schema.columnField(dataColumns, 0);
43
+ const yField = chartUtils.schema.columnField(dataColumns, 1);
44
+ const groupField = chartUtils.schema.columnField(dataColumns, 2);
45
+
46
+ // 设置尺寸和边距
47
+ const width = variables.width;
48
+ const height = variables.height;
49
+ const margin = { top: 60, right: 20, bottom: 20, left: 20 };
50
+ const chartWidth = width - margin.left - margin.right;
51
+ const chartHeight = height - margin.top - margin.bottom;
52
+
53
+ // 获取唯一的x值和group值
54
+ const uniqueXValues = [...new Set(chartData.map(d => d[xField]))];
55
+ const uniqueGroups = [...new Set(chartData.map(d => d[groupField]))];
56
+
57
+ // 计算每个group的全局总值,用于确定统一的排序顺序
58
+ const groupTotals = {};
59
+ uniqueGroups.forEach(group => {
60
+ groupTotals[group] = chartData
61
+ .filter(d => d[groupField] === group)
62
+ .reduce((sum, d) => sum + (+d[yField]), 0);
63
+ });
64
+
65
+ // 按全局总值从大到小排序group,确保所有子图顺序一致
66
+ const sortedGroups = uniqueGroups.sort((a, b) => groupTotals[b] - groupTotals[a]);
67
+ const colorScale = colorResolver.scale(sortedGroups, { palette: "category10" });
68
+
69
+ // 按x分组数据
70
+ const groupedData = {};
71
+ uniqueXValues.forEach(xValue => {
72
+ groupedData[xValue] = [];
73
+ sortedGroups.forEach(group => {
74
+ const item = chartData.find(d => d[xField] === xValue && d[groupField] === group);
75
+ if (item) {
76
+ groupedData[xValue].push({
77
+ group: group,
78
+ value: +item[yField],
79
+ color: colorScale(group)
80
+ });
81
+ }
82
+ });
83
+ // 移除单独排序,使用统一的sortedGroups顺序
84
+ });
85
+
86
+ // 计算全局最大值,确保所有子图刻度一致
87
+ const globalMaxValue = Math.max(...chartData.map(d => +d[yField]));
88
+ const angleScale = d3.scaleLinear().domain([0, globalMaxValue]).range([0, Math.PI]);
89
+
90
+ // 创建SVG
91
+ const svg = d3.select(containerSelector)
92
+ .append("svg")
93
+ .attr("width", "100%")
94
+ .attr("height", height)
95
+ .attr("viewBox", `0 0 ${width} ${height}`)
96
+ .attr("style", "max-width: 100%; height: auto;")
97
+ .attr("xmlns", "http://www.w3.org/2000/svg")
98
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
99
+
100
+ chartUtils.legend.centered(svg, sortedGroups, {
101
+ colorScale,
102
+ colorResolver,
103
+ colors,
104
+ }, width / 2, 15, {
105
+ markerShape: "circle",
106
+ markerSize: 10,
107
+ labelGap: 5,
108
+ itemGap: 15,
109
+ fontSize: 12,
110
+ textColor: colorResolver.text({ fallback: "#333" }).value,
111
+ });
112
+
113
+ // 优化网格布局逻辑
114
+ const numCharts = uniqueXValues.length;
115
+ let rows, cols;
116
+
117
+ if (numCharts === 2) {
118
+ rows = 1; cols = 2;
119
+ } else if (numCharts <= 4) {
120
+ rows = 2; cols = 2;
121
+ } else if (numCharts <= 6) {
122
+ rows = 3; cols = 2;
123
+ } else {
124
+ rows = 3; cols = 3;
125
+ }
126
+
127
+ const subChartWidth = chartWidth / cols;
128
+ const subChartHeight = chartHeight / rows;
129
+ const chartArea = svg.append("g").attr("transform", `translate(${margin.left}, ${margin.top})`);
130
+
131
+ // 为每个x值创建子图
132
+ uniqueXValues.forEach((xValue, index) => {
133
+ const row = Math.floor(index / cols);
134
+ const col = index % cols;
135
+ const subChartX = col * subChartWidth;
136
+ const subChartY = row * subChartHeight;
137
+
138
+ const subChart = chartArea.append("g")
139
+ .attr("class", "other")
140
+ .attr("transform", `translate(${subChartX}, ${subChartY})`);
141
+
142
+ // 计算仪表盘参数 - 优化空间利用
143
+ const centerX = subChartWidth / 2;
144
+ const centerY = subChartHeight * 0.75; // 圆心下移,给标签留更少空间
145
+ // 增大半圆占用比例,减少边距浪费
146
+ const maxRadius = Math.min(subChartWidth * 0.95, (subChartHeight - 30) * 0.8) / 2; // 宽度用95%,高度考虑标签后用80%
147
+ const categoryData = groupedData[xValue];
148
+ const arcThickness = Math.max(8, maxRadius / (categoryData.length + 1));
149
+ const gapBetweenLayers = Math.max(2, arcThickness * 0.15);
150
+
151
+ // 添加x标签,紧贴在半圆上方
152
+ const labelY = centerY - maxRadius - 8; // 减小标签与半圆的间距
153
+ subChart.append("text")
154
+ .attr("x", subChartWidth / 2)
155
+ .attr("y", labelY)
156
+ .attr("text-anchor", "middle")
157
+ .attr("font-size", "14px")
158
+ .attr("font-weight", "bold")
159
+ .attr("fill", colorResolver.text({ fallback: "#333" }).value)
160
+ .attr("class", "label")
161
+ .text(xValue);
162
+
163
+ const startAngle = -Math.PI / 2;
164
+ const endAngle = Math.PI / 2;
165
+
166
+ const gaugeGroup = subChart.append("g")
167
+ .attr("class", "other")
168
+ .attr("transform", `translate(${centerX}, ${centerY})`);
169
+
170
+ // 为每个group创建同心圆弧
171
+ categoryData.forEach((d, i) => {
172
+ const outerRadius = maxRadius - i * (arcThickness + gapBetweenLayers);
173
+ const innerRadius = outerRadius - arcThickness;
174
+
175
+ // 背景弧
176
+ const backgroundArc = d3.arc()
177
+ .innerRadius(innerRadius)
178
+ .outerRadius(outerRadius)
179
+ .startAngle(startAngle)
180
+ .endAngle(endAngle);
181
+
182
+ gaugeGroup.append("path")
183
+ .attr("class", "mark")
184
+ .attr("d", backgroundArc)
185
+ .attr("fill", "#666666")
186
+ .attr("opacity", 0.3);
187
+
188
+ // 数值弧
189
+ const valueArc = d3.arc()
190
+ .innerRadius(innerRadius)
191
+ .outerRadius(outerRadius)
192
+ .startAngle(startAngle);
193
+
194
+ const targetAngle = startAngle + angleScale(d.value);
195
+
196
+ gaugeGroup.append("path")
197
+ .attr("class", "mark")
198
+ .attr("d", valueArc({
199
+ startAngle: startAngle,
200
+ endAngle: targetAngle
201
+ }))
202
+ .attr("fill", d.color)
203
+ .style("filter", variables.has_shadow ? "drop-shadow(0 2px 4px rgba(0,0,0,0.15))" : "none");
204
+
205
+ // 数值标签
206
+ const labelRadius = (innerRadius + outerRadius) / 2;
207
+ const labelAngle = Math.PI;
208
+ const labelX = labelRadius * Math.cos(labelAngle);
209
+ const labelY = labelRadius * Math.sin(labelAngle) + arcThickness * 0.5;
210
+
211
+ const maxFontSize = Math.min(arcThickness * 0.6, 12);
212
+ const minFontSize = Math.max(5, arcThickness * 0.3);
213
+ let fontSize = maxFontSize;
214
+
215
+ const valueLabel = chartUtils.format.scaledText(d.value);
216
+ const textWidth = chartUtils.text.measure(gaugeGroup, valueLabel, {
217
+ fontSize,
218
+ fontWeight: "bold"
219
+ }).width;
220
+ const availableWidth = arcThickness * 1.5;
221
+
222
+ if (textWidth > availableWidth) {
223
+ fontSize = Math.max(minFontSize, fontSize * availableWidth / textWidth);
224
+ }
225
+
226
+ gaugeGroup.append("text")
227
+ .attr("class", "value")
228
+ .attr("x", labelX)
229
+ .attr("y", labelY)
230
+ .attr("text-anchor", "middle")
231
+ .attr("dominant-baseline", "central")
232
+ .attr("font-size", fontSize + "px")
233
+ .attr("font-weight", "bold")
234
+ .attr("fill", d.color)
235
+ .text(chartUtils.format.scaledText(d.value));
236
+ });
237
+ });
238
+
239
+ return svg.node();
240
+ }
modules/chart_engine/template/d3-js/type71_small_multiples_of_gauge/small_multiples_gauge_plain_chart_02.js ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Small Multiples of Gauge Chart",
5
+ "chart_name": "small_multiples_gauge_plain_chart_02",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["categorical"], ["numerical"]],
8
+ "required_fields_range": [[2, 9], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 600,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "no",
20
+ "has_y_axis": "no"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, dataJSON) {
26
+ const jsonData = dataJSON || {};
27
+ const dataColumns = chartUtils.schema.columns(jsonData);
28
+ const colorResolver = chartUtils.color.resolver(jsonData);
29
+ const chartUtilsFormatSample = chartUtils.format.autoText;
30
+ const chartUtilsTextSample = chartUtils.text.estimate;
31
+ const chartUtilsStandard = {
32
+ schema: chartUtils.schema,
33
+ format: chartUtils.format,
34
+ text: chartUtils.text,
35
+ color: chartUtils.color,
36
+ legendLayout: chartUtils.legend.layout,
37
+ legendDraw: chartUtils.legend.draw,
38
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
39
+ };
40
+ const standardChannels = chartUtils.schema.channels(jsonData, {
41
+ x: { fallbackIndex: 0 },
42
+ y: { fallbackIndex: 1 },
43
+ y2: { fallbackIndex: 2 },
44
+ y3: { fallbackIndex: 3 },
45
+ size: { fallbackIndex: 2 },
46
+ group: { fallbackIndex: 2 },
47
+ group2: { fallbackIndex: 3 },
48
+ group3: { fallbackIndex: 4 }
49
+ });
50
+ const dataBlock = jsonData.data || {};
51
+ const sourceRows = dataBlock.data || [];
52
+ const columns = dataBlock.columns || [];
53
+ const variables = jsonData.variables || {};
54
+ const typography = jsonData.typography || {};
55
+ const sourceColors = jsonData.colors || {};
56
+
57
+ d3.select(containerSelector).html("");
58
+
59
+ const categoryColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
60
+ const valueColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
61
+ const categoryField = categoryColumn.name;
62
+ const valueField = valueColumn.name;
63
+ const valueUnit = valueColumn.unit === "none" ? "" : (valueColumn.unit || "");
64
+
65
+ const width = Math.max(460, Number(variables.width) || 600);
66
+ const baseHeight = Math.max(400, Number(variables.height) || 520);
67
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
68
+ const annotationFamily = typography.annotation?.font_family || fontFamily;
69
+ const textColor = sourceColors.text_color || "#1f2937";
70
+ const mutedText = "#64748b";
71
+ const primaryColor = sourceColors.other?.primary || sourceColors.available_colors?.[0] || "#2563eb";
72
+ const trackColor = "rgba(100, 116, 139, 0.20)";
73
+ const labelSize = Math.max(8.5, Math.min(11, parseFloat(typography.label?.font_size) || 10));
74
+ const valueSize = Math.max(11, Math.min(16, parseFloat(typography.annotation?.font_size) || 13));
75
+ const tickSize = Math.max(7.5, Math.min(9, valueSize - 3));
76
+ const labelWeight = typography.label?.font_weight || "700";
77
+ const valueWeight = typography.annotation?.font_weight || "750";
78
+
79
+ const svg = d3.select(containerSelector)
80
+ .append("svg")
81
+ .attr("width", "100%")
82
+ .attr("style", "max-width: 100%; height: auto;")
83
+ .attr("xmlns", "http://www.w3.org/2000/svg")
84
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
85
+ .attr("class", "small-multiples-gauge-chart-02");
86
+
87
+ function message(text) {
88
+ svg.attr("height", baseHeight).attr("viewBox", `0 0 ${width} ${baseHeight}`);
89
+ svg.append("text")
90
+ .attr("x", width / 2)
91
+ .attr("y", baseHeight / 2)
92
+ .attr("text-anchor", "middle")
93
+ .attr("fill", textColor)
94
+ .style("font-family", fontFamily)
95
+ .style("font-size", "16px")
96
+ .text(text);
97
+ return svg.node();
98
+ }
99
+
100
+ if (!categoryField || !valueField) return message("Missing gauge fields");
101
+
102
+ function readableLabel(value) {
103
+ return String(value ?? "")
104
+ .replace(/_/g, " ")
105
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
106
+ .replace(/\s+/g, " ")
107
+ .trim();
108
+ }
109
+
110
+ function parseTemporalRank(value) {
111
+ const text = String(value ?? "").trim();
112
+ const lower = text.toLowerCase();
113
+ const months = {
114
+ jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4,
115
+ may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8,
116
+ sep: 9, sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12
117
+ };
118
+ if (months[lower] != null) return months[lower];
119
+ let match = text.match(/^(-?\d{4})$/);
120
+ if (match) return Number(match[1]) * 10000;
121
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
122
+ if (match) return Number(match[1]) * 10000 + Number(match[2]) * 100 + Number(match[3] || 1);
123
+ match = text.match(/^Q([1-4])\s+(-?\d{4})$/i);
124
+ if (match) return Number(match[2]) * 10 + Number(match[1]);
125
+ match = text.match(/^(-?\d{4})\s+Q([1-4])$/i);
126
+ if (match) return Number(match[1]) * 10 + Number(match[2]);
127
+ return NaN;
128
+ }
129
+
130
+ function parseOrdinalRank(value) {
131
+ const text = String(value ?? "").trim().toLowerCase();
132
+ const named = { low: 1, lower: 1, medium: 2, moderate: 2, high: 3, higher: 3 };
133
+ if (named[text] != null) return named[text];
134
+ let match = text.match(/^(-?\d+(?:\.\d+)?)/);
135
+ if (match) return Number(match[1]);
136
+ return NaN;
137
+ }
138
+
139
+ function compactNumber(value) {
140
+ const numeric = Number(value);
141
+ const abs = Math.abs(numeric);
142
+ if (abs >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
143
+ if (abs >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
144
+ if (abs >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
145
+ return d3.format(",.4~g")(numeric);
146
+ }
147
+
148
+ function formatLocalValue(value, unit = "") {
149
+ const numeric = Number(value);
150
+ const formatted = compactNumber(numeric);
151
+ if (!unit) return formatted;
152
+ if (unit === "%") return `${formatted}%`;
153
+ if (unit.startsWith("$")) return `$${compactNumber(Math.abs(numeric))}${unit.slice(1)}`;
154
+ if (unit.length <= 3 && /^[^A-Za-z0-9]+$/.test(unit)) return `${unit}${formatted}`;
155
+ return `${formatted} ${unit}`;
156
+ }
157
+
158
+ function truncateText(text, maxChars) {
159
+ if (text.length <= maxChars) return text;
160
+ return `${text.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
161
+ }
162
+
163
+ const aggregated = new Map();
164
+ sourceRows.forEach((row, index) => {
165
+ const category = readableLabel(row[categoryField]);
166
+ const value = Number(row[valueField]);
167
+ if (!category || !Number.isFinite(value) || value < 0) return;
168
+ if (!aggregated.has(category)) {
169
+ aggregated.set(category, {
170
+ category,
171
+ rawCategory: row[categoryField],
172
+ firstIndex: index,
173
+ value: 0
174
+ });
175
+ }
176
+ aggregated.get(category).value += value;
177
+ });
178
+
179
+ const rows = Array.from(aggregated.values()).map(row => ({
180
+ ...row,
181
+ temporalRank: parseTemporalRank(row.rawCategory),
182
+ ordinalRank: parseOrdinalRank(row.rawCategory)
183
+ }));
184
+ if (!rows.length) return message("No valid data for gauges");
185
+
186
+ const allTemporal = categoryColumn.data_type === "temporal" || rows.every(d => Number.isFinite(d.temporalRank));
187
+ const allOrdinal = !allTemporal && rows.every(d => Number.isFinite(d.ordinalRank));
188
+ const sortMode = allTemporal ? "chronological" : (allOrdinal ? "ordered category" : "largest value");
189
+ rows.sort((a, b) => {
190
+ if (allTemporal) return d3.ascending(a.temporalRank, b.temporalRank) || d3.ascending(a.firstIndex, b.firstIndex);
191
+ if (allOrdinal) return d3.ascending(a.ordinalRank, b.ordinalRank) || d3.ascending(a.firstIndex, b.firstIndex);
192
+ return d3.descending(a.value, b.value) || d3.ascending(a.category, b.category);
193
+ });
194
+
195
+ const count = rows.length;
196
+ const cols = count <= 2 ? count : (count <= 4 ? 2 : 3);
197
+ const panelRows = Math.ceil(count / cols);
198
+ const outer = { top: 36, right: 26, bottom: 52, left: 26 };
199
+ const gapX = 22;
200
+ const gapY = 28;
201
+ const minPanelHeight = count <= 3 ? 170 : 140;
202
+ const height = Math.max(baseHeight, outer.top + outer.bottom + panelRows * minPanelHeight + (panelRows - 1) * gapY);
203
+ svg.attr("height", height).attr("viewBox", `0 0 ${width} ${height}`);
204
+
205
+ const panelWidth = (width - outer.left - outer.right - gapX * (cols - 1)) / cols;
206
+ const panelHeight = (height - outer.top - outer.bottom - gapY * (panelRows - 1)) / panelRows;
207
+ const maxValue = d3.max(rows, d => d.value) || 0;
208
+ const gaugeMax = d3.scaleLinear().domain([0, maxValue || 1]).nice(4).domain()[1] || 1;
209
+ const angleScale = d3.scaleLinear().domain([0, gaugeMax]).range([-Math.PI * 0.72, Math.PI * 0.72]).clamp(true);
210
+ const startAngle = -Math.PI * 0.72;
211
+ const endAngle = Math.PI * 0.72;
212
+
213
+ rows.forEach((row, index) => {
214
+ const gridRow = Math.floor(index / cols);
215
+ const gridCol = index % cols;
216
+ const x = outer.left + gridCol * (panelWidth + gapX);
217
+ const y = outer.top + gridRow * (panelHeight + gapY);
218
+ const centerX = panelWidth / 2;
219
+ const centerY = panelHeight * 0.58;
220
+ const radius = Math.max(38, Math.min(panelWidth * 0.34, panelHeight * 0.34));
221
+ const thickness = Math.max(10, radius * 0.22);
222
+ const innerRadius = radius - thickness;
223
+
224
+ const panel = svg.append("g")
225
+ .attr("class", "gauge-panel")
226
+ .attr("data-category", row.category)
227
+ .attr("transform", `translate(${x}, ${y})`);
228
+
229
+ panel.append("rect")
230
+ .attr("class", "panel-background")
231
+ .attr("x", 0)
232
+ .attr("y", 0)
233
+ .attr("width", panelWidth)
234
+ .attr("height", panelHeight)
235
+ .attr("rx", 5)
236
+ .attr("fill", "rgba(255, 255, 255, 0.22)")
237
+ .attr("stroke", "rgba(100, 116, 139, 0.16)");
238
+
239
+ panel.append("text")
240
+ .attr("class", "category-label")
241
+ .attr("data-category", row.category)
242
+ .attr("x", centerX)
243
+ .attr("y", 18)
244
+ .attr("text-anchor", "middle")
245
+ .attr("fill", textColor)
246
+ .style("font-family", fontFamily)
247
+ .style("font-size", `${labelSize}px`)
248
+ .style("font-weight", labelWeight)
249
+ .text(truncateText(row.category, Math.max(10, Math.floor(panelWidth / 8))));
250
+
251
+ const gauge = panel.append("g")
252
+ .attr("class", "gauge")
253
+ .attr("transform", `translate(${centerX}, ${centerY})`);
254
+
255
+ const trackArc = d3.arc()
256
+ .innerRadius(innerRadius)
257
+ .outerRadius(radius)
258
+ .startAngle(startAngle)
259
+ .endAngle(endAngle);
260
+ const valueArc = d3.arc()
261
+ .innerRadius(innerRadius)
262
+ .outerRadius(radius)
263
+ .startAngle(startAngle)
264
+ .endAngle(angleScale(row.value));
265
+
266
+ gauge.append("path")
267
+ .attr("class", "gauge-track")
268
+ .attr("d", trackArc)
269
+ .attr("fill", trackColor);
270
+
271
+ gauge.append("path")
272
+ .attr("class", "gauge-value-arc data-point mark")
273
+ .attr("data-category", row.category)
274
+ .attr("data-value", row.value)
275
+ .attr("data-scale-max", gaugeMax)
276
+ .attr("d", valueArc)
277
+ .attr("fill", primaryColor);
278
+
279
+ const tickAngles = [startAngle, endAngle];
280
+ tickAngles.forEach((angle, tickIndex) => {
281
+ const tx = Math.sin(angle) * (radius + 4);
282
+ const ty = -Math.cos(angle) * (radius + 4);
283
+ panel.append("text")
284
+ .attr("class", "gauge-tick")
285
+ .attr("x", centerX + tx)
286
+ .attr("y", centerY + ty + 4)
287
+ .attr("text-anchor", tickIndex === 0 ? "end" : "start")
288
+ .attr("fill", mutedText)
289
+ .style("font-family", annotationFamily)
290
+ .style("font-size", `${tickSize}px`)
291
+ .style("font-weight", valueWeight)
292
+ .text(tickIndex === 0 ? "0" : formatLocalValue(gaugeMax, valueUnit));
293
+ });
294
+
295
+ panel.append("text")
296
+ .attr("class", "value-label")
297
+ .attr("data-category", row.category)
298
+ .attr("x", centerX)
299
+ .attr("y", centerY + radius * 0.42)
300
+ .attr("text-anchor", "middle")
301
+ .attr("fill", primaryColor)
302
+ .style("font-family", annotationFamily)
303
+ .style("font-size", `${valueSize}px`)
304
+ .style("font-weight", valueWeight)
305
+ .text(formatLocalValue(row.value, valueUnit));
306
+ });
307
+
308
+ svg.append("text")
309
+ .attr("class", "scale-note")
310
+ .attr("x", width / 2)
311
+ .attr("y", height - 28)
312
+ .attr("text-anchor", "middle")
313
+ .attr("fill", mutedText)
314
+ .style("font-family", annotationFamily)
315
+ .style("font-size", `${Math.max(8, tickSize)}px`)
316
+ .text(`${readableLabel(valueColumn.label || valueField)} gauge scale: 0-${formatLocalValue(gaugeMax, valueUnit)}; arcs use actual values only`);
317
+
318
+ svg.append("text")
319
+ .attr("class", "category-note")
320
+ .attr("x", width / 2)
321
+ .attr("y", height - 12)
322
+ .attr("text-anchor", "middle")
323
+ .attr("fill", mutedText)
324
+ .style("font-family", annotationFamily)
325
+ .style("font-size", `${Math.max(7.5, tickSize - 1)}px`)
326
+ .text(`${readableLabel(categoryColumn.label || categoryField)} order: ${sortMode}; decorative images removed`);
327
+
328
+ return svg.node();
329
+ }
modules/chart_engine/template/d3-js/type71_small_multiples_of_gauge/small_multiples_gauge_plain_chart_03.js ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Small Multiples of Gauge Chart",
5
+ "chart_name": "small_multiples_gauge_plain_chart_03",
6
+ "required_fields": ["x", "y"],
7
+ "required_fields_type": [["categorical"], ["numerical"]],
8
+ "required_fields_range": [[2, 9], [0, "inf"]],
9
+ "required_fields_icons": [],
10
+ "required_other_icons": [],
11
+ "required_fields_colors": [],
12
+ "required_other_colors": ["primary"],
13
+ "supported_effects": [],
14
+ "min_height": 400,
15
+ "min_width": 600,
16
+ "background": "light",
17
+ "icon_mark": "none",
18
+ "icon_label": "none",
19
+ "has_x_axis": "no",
20
+ "has_y_axis": "no"
21
+ }
22
+ REQUIREMENTS_END
23
+ */
24
+
25
+ function makeChart(containerSelector, dataJSON) {
26
+ const jsonData = dataJSON || {};
27
+ const dataColumns = chartUtils.schema.columns(jsonData);
28
+ const colorResolver = chartUtils.color.resolver(jsonData);
29
+ const chartUtilsFormatSample = chartUtils.format.autoText;
30
+ const chartUtilsTextSample = chartUtils.text.estimate;
31
+ const chartUtilsStandard = {
32
+ schema: chartUtils.schema,
33
+ format: chartUtils.format,
34
+ text: chartUtils.text,
35
+ color: chartUtils.color,
36
+ legendLayout: chartUtils.legend.layout,
37
+ legendDraw: chartUtils.legend.draw,
38
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
39
+ };
40
+ const standardChannels = chartUtils.schema.channels(jsonData, {
41
+ x: { fallbackIndex: 0 },
42
+ y: { fallbackIndex: 1 },
43
+ y2: { fallbackIndex: 2 },
44
+ y3: { fallbackIndex: 3 },
45
+ size: { fallbackIndex: 2 },
46
+ group: { fallbackIndex: 2 },
47
+ group2: { fallbackIndex: 3 },
48
+ group3: { fallbackIndex: 4 }
49
+ });
50
+ const dataBlock = jsonData.data || {};
51
+ const sourceRows = dataBlock.data || [];
52
+ const columns = dataBlock.columns || [];
53
+ const variables = jsonData.variables || {};
54
+ const typography = jsonData.typography || {};
55
+ const sourceColors = jsonData.colors || {};
56
+
57
+ d3.select(containerSelector).html("");
58
+
59
+ const categoryColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
60
+ const valueColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
61
+ const categoryField = categoryColumn.name;
62
+ const valueField = valueColumn.name;
63
+ const valueUnit = valueColumn.unit === "none" ? "" : (valueColumn.unit || "");
64
+
65
+ const width = Math.max(460, Number(variables.width) || 600);
66
+ const baseHeight = Math.max(400, Number(variables.height) || 520);
67
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
68
+ const annotationFamily = typography.annotation?.font_family || fontFamily;
69
+ const textColor = sourceColors.text_color || "#1f2937";
70
+ const mutedText = "#64748b";
71
+ const primaryColor = sourceColors.other?.primary || sourceColors.available_colors?.[0] || "#2563eb";
72
+ const trackColor = "rgba(100, 116, 139, 0.20)";
73
+ const labelSize = Math.max(8.5, Math.min(11, parseFloat(typography.label?.font_size) || 10));
74
+ const valueSize = Math.max(11, Math.min(16, parseFloat(typography.annotation?.font_size) || 13));
75
+ const tickSize = Math.max(7.5, Math.min(9, valueSize - 3));
76
+ const labelWeight = typography.label?.font_weight || "700";
77
+ const valueWeight = typography.annotation?.font_weight || "750";
78
+
79
+ const svg = d3.select(containerSelector)
80
+ .append("svg")
81
+ .attr("width", "100%")
82
+ .attr("style", "max-width: 100%; height: auto;")
83
+ .attr("xmlns", "http://www.w3.org/2000/svg")
84
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
85
+ .attr("class", "small-multiples-gauge-chart-03");
86
+
87
+ function message(text) {
88
+ svg.attr("height", baseHeight).attr("viewBox", `0 0 ${width} ${baseHeight}`);
89
+ svg.append("text")
90
+ .attr("x", width / 2)
91
+ .attr("y", baseHeight / 2)
92
+ .attr("text-anchor", "middle")
93
+ .attr("fill", textColor)
94
+ .style("font-family", fontFamily)
95
+ .style("font-size", "16px")
96
+ .text(text);
97
+ return svg.node();
98
+ }
99
+
100
+ if (!categoryField || !valueField) return message("Missing gauge fields");
101
+
102
+ function readableLabel(value) {
103
+ return String(value ?? "")
104
+ .replace(/_/g, " ")
105
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
106
+ .replace(/\s+/g, " ")
107
+ .trim();
108
+ }
109
+
110
+ function parseTemporalRank(value) {
111
+ const text = String(value ?? "").trim();
112
+ const lower = text.toLowerCase();
113
+ const months = {
114
+ jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3, apr: 4, april: 4,
115
+ may: 5, jun: 6, june: 6, jul: 7, july: 7, aug: 8, august: 8,
116
+ sep: 9, sept: 9, september: 9, oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12
117
+ };
118
+ if (months[lower] != null) return months[lower];
119
+ let match = text.match(/^(-?\d{4})$/);
120
+ if (match) return Number(match[1]) * 10000;
121
+ match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
122
+ if (match) return Number(match[1]) * 10000 + Number(match[2]) * 100 + Number(match[3] || 1);
123
+ match = text.match(/^Q([1-4])\s+(-?\d{4})$/i);
124
+ if (match) return Number(match[2]) * 10 + Number(match[1]);
125
+ match = text.match(/^(-?\d{4})\s+Q([1-4])$/i);
126
+ if (match) return Number(match[1]) * 10 + Number(match[2]);
127
+ return NaN;
128
+ }
129
+
130
+ function parseOrdinalRank(value) {
131
+ const text = String(value ?? "").trim().toLowerCase();
132
+ const named = {
133
+ "very easy": 1, easy: 2, "slightly easy": 3, moderate: 4,
134
+ "somewhat difficult": 5, difficult: 6, "very difficult": 7, "extremely difficult": 8,
135
+ low: 1, lower: 1, medium: 2, high: 3, higher: 3
136
+ };
137
+ if (named[text] != null) return named[text];
138
+ let match = text.match(/(?:less than|under)\s*([\d,]+)/);
139
+ if (match) return Number(match[1].replace(/,/g, "")) - 0.5;
140
+ match = text.match(/^([\d,]+(?:\.\d+)?)\s*(?:-|to)/);
141
+ if (match) return Number(match[1].replace(/,/g, ""));
142
+ match = text.match(/(?:more than|over)\s*([\d,]+)/);
143
+ if (match) return Number(match[1].replace(/,/g, "")) + 0.5;
144
+ match = text.match(/^(-?\d+(?:\.\d+)?)/);
145
+ if (match) return Number(match[1]);
146
+ return NaN;
147
+ }
148
+
149
+ function compactNumber(value) {
150
+ const numeric = Number(value);
151
+ const abs = Math.abs(numeric);
152
+ if (abs >= 1000000000) return `${d3.format(".3~g")(numeric / 1000000000)}B`;
153
+ if (abs >= 1000000) return `${d3.format(".3~g")(numeric / 1000000)}M`;
154
+ if (abs >= 1000) return `${d3.format(".3~g")(numeric / 1000)}K`;
155
+ return d3.format(",.4~g")(numeric);
156
+ }
157
+
158
+ function formatLocalValue(value, unit = "") {
159
+ const numeric = Number(value);
160
+ const formatted = compactNumber(numeric);
161
+ if (!unit) return formatted;
162
+ if (unit === "%") return `${formatted}%`;
163
+ if (unit.startsWith("$")) return `$${compactNumber(Math.abs(numeric))}${unit.slice(1)}`;
164
+ if (unit.length <= 3 && /^[^A-Za-z0-9]+$/.test(unit)) return `${unit}${formatted}`;
165
+ return `${formatted} ${unit}`;
166
+ }
167
+
168
+ function truncateText(text, maxChars) {
169
+ if (text.length <= maxChars) return text;
170
+ return `${text.slice(0, Math.max(1, maxChars - 3)).trim()}...`;
171
+ }
172
+
173
+ const rows = [];
174
+ const seen = new Set();
175
+ sourceRows.forEach((row, index) => {
176
+ const category = readableLabel(row[categoryField]);
177
+ const value = Number(row[valueField]);
178
+ if (!category || !Number.isFinite(value) || value < 0 || seen.has(category)) return;
179
+ seen.add(category);
180
+ rows.push({
181
+ category,
182
+ rawCategory: row[categoryField],
183
+ firstIndex: index,
184
+ value,
185
+ temporalRank: parseTemporalRank(row[categoryField]),
186
+ ordinalRank: parseOrdinalRank(row[categoryField])
187
+ });
188
+ });
189
+ if (!rows.length) return message("No valid data for gauges");
190
+
191
+ const allTemporal = categoryColumn.data_type === "temporal" || rows.every(d => Number.isFinite(d.temporalRank));
192
+ const allOrdinal = !allTemporal && rows.every(d => Number.isFinite(d.ordinalRank));
193
+ const sortMode = allTemporal ? "chronological" : (allOrdinal ? "ordered category" : "largest value");
194
+ rows.sort((a, b) => {
195
+ if (allTemporal) return d3.ascending(a.temporalRank, b.temporalRank) || d3.ascending(a.firstIndex, b.firstIndex);
196
+ if (allOrdinal) return d3.ascending(a.ordinalRank, b.ordinalRank) || d3.ascending(a.firstIndex, b.firstIndex);
197
+ return d3.descending(a.value, b.value) || d3.ascending(a.category, b.category);
198
+ });
199
+
200
+ const count = rows.length;
201
+ const cols = count <= 2 ? count : (count <= 4 ? 2 : 3);
202
+ const panelRows = Math.ceil(count / cols);
203
+ const outer = { top: 36, right: 26, bottom: 52, left: 26 };
204
+ const gapX = 22;
205
+ const gapY = 28;
206
+ const minPanelHeight = count <= 3 ? 170 : 140;
207
+ const height = Math.max(baseHeight, outer.top + outer.bottom + panelRows * minPanelHeight + (panelRows - 1) * gapY);
208
+ svg.attr("height", height).attr("viewBox", `0 0 ${width} ${height}`);
209
+
210
+ const panelWidth = (width - outer.left - outer.right - gapX * (cols - 1)) / cols;
211
+ const panelHeight = (height - outer.top - outer.bottom - gapY * (panelRows - 1)) / panelRows;
212
+ const maxValue = d3.max(rows, d => d.value) || 0;
213
+ const gaugeMax = d3.scaleLinear().domain([0, maxValue || 1]).nice(4).domain()[1] || 1;
214
+ const startAngle = -Math.PI * 0.72;
215
+ const endAngle = Math.PI * 0.72;
216
+ const angleScale = d3.scaleLinear().domain([0, gaugeMax]).range([startAngle, endAngle]).clamp(true);
217
+
218
+ rows.forEach((row, index) => {
219
+ const gridRow = Math.floor(index / cols);
220
+ const gridCol = index % cols;
221
+ const x = outer.left + gridCol * (panelWidth + gapX);
222
+ const y = outer.top + gridRow * (panelHeight + gapY);
223
+ const centerX = panelWidth / 2;
224
+ const centerY = panelHeight * 0.58;
225
+ const radius = Math.max(38, Math.min(panelWidth * 0.34, panelHeight * 0.34));
226
+ const thickness = Math.max(10, radius * 0.22);
227
+ const innerRadius = radius - thickness;
228
+
229
+ const panel = svg.append("g")
230
+ .attr("class", "gauge-panel")
231
+ .attr("data-category", row.category)
232
+ .attr("transform", `translate(${x}, ${y})`);
233
+
234
+ panel.append("rect")
235
+ .attr("class", "panel-background")
236
+ .attr("x", 0)
237
+ .attr("y", 0)
238
+ .attr("width", panelWidth)
239
+ .attr("height", panelHeight)
240
+ .attr("rx", 5)
241
+ .attr("fill", "rgba(255, 255, 255, 0.22)")
242
+ .attr("stroke", "rgba(100, 116, 139, 0.16)");
243
+
244
+ panel.append("text")
245
+ .attr("class", "category-label")
246
+ .attr("data-category", row.category)
247
+ .attr("x", centerX)
248
+ .attr("y", 18)
249
+ .attr("text-anchor", "middle")
250
+ .attr("fill", textColor)
251
+ .style("font-family", fontFamily)
252
+ .style("font-size", `${labelSize}px`)
253
+ .style("font-weight", labelWeight)
254
+ .text(truncateText(row.category, Math.max(10, Math.floor(panelWidth / 8))));
255
+
256
+ const gauge = panel.append("g")
257
+ .attr("class", "gauge")
258
+ .attr("transform", `translate(${centerX}, ${centerY})`);
259
+
260
+ const trackArc = d3.arc()
261
+ .innerRadius(innerRadius)
262
+ .outerRadius(radius)
263
+ .startAngle(startAngle)
264
+ .endAngle(endAngle);
265
+ const valueArc = d3.arc()
266
+ .innerRadius(innerRadius)
267
+ .outerRadius(radius)
268
+ .startAngle(startAngle)
269
+ .endAngle(angleScale(row.value));
270
+
271
+ gauge.append("path")
272
+ .attr("class", "gauge-track")
273
+ .attr("d", trackArc)
274
+ .attr("fill", trackColor);
275
+
276
+ gauge.append("path")
277
+ .attr("class", "gauge-value-arc data-point mark")
278
+ .attr("data-category", row.category)
279
+ .attr("data-value", row.value)
280
+ .attr("data-scale-max", gaugeMax)
281
+ .attr("d", valueArc)
282
+ .attr("fill", primaryColor);
283
+
284
+ [startAngle, endAngle].forEach((angle, tickIndex) => {
285
+ const tx = Math.sin(angle) * (radius + 4);
286
+ const ty = -Math.cos(angle) * (radius + 4);
287
+ panel.append("text")
288
+ .attr("class", "gauge-tick")
289
+ .attr("x", centerX + tx)
290
+ .attr("y", centerY + ty + 4)
291
+ .attr("text-anchor", tickIndex === 0 ? "end" : "start")
292
+ .attr("fill", mutedText)
293
+ .style("font-family", annotationFamily)
294
+ .style("font-size", `${tickSize}px`)
295
+ .style("font-weight", valueWeight)
296
+ .text(tickIndex === 0 ? "0" : formatLocalValue(gaugeMax, valueUnit));
297
+ });
298
+
299
+ panel.append("text")
300
+ .attr("class", "value-label")
301
+ .attr("data-category", row.category)
302
+ .attr("x", centerX)
303
+ .attr("y", centerY + radius * 0.42)
304
+ .attr("text-anchor", "middle")
305
+ .attr("fill", primaryColor)
306
+ .style("font-family", annotationFamily)
307
+ .style("font-size", `${valueSize}px`)
308
+ .style("font-weight", valueWeight)
309
+ .text(formatLocalValue(row.value, valueUnit));
310
+ });
311
+
312
+ svg.append("text")
313
+ .attr("class", "scale-note")
314
+ .attr("x", width / 2)
315
+ .attr("y", height - 28)
316
+ .attr("text-anchor", "middle")
317
+ .attr("fill", mutedText)
318
+ .style("font-family", annotationFamily)
319
+ .style("font-size", `${Math.max(8, tickSize)}px`)
320
+ .text(`${readableLabel(valueColumn.label || valueField)} gauge scale: 0-${formatLocalValue(gaugeMax, valueUnit)}; arcs use actual values only`);
321
+
322
+ svg.append("text")
323
+ .attr("class", "category-note")
324
+ .attr("x", width / 2)
325
+ .attr("y", height - 12)
326
+ .attr("text-anchor", "middle")
327
+ .attr("fill", mutedText)
328
+ .style("font-family", annotationFamily)
329
+ .style("font-size", `${Math.max(7.5, tickSize - 1)}px`)
330
+ .text(`${readableLabel(categoryColumn.label || categoryField)} order: ${sortMode}; decorative images removed`);
331
+
332
+ return svg.node();
333
+ }
modules/chart_engine/template/d3-js/type71_small_multiples_of_gauge/small_multiples_gauge_plain_chart_04.js ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ REQUIREMENTS_BEGIN
3
+ {
4
+ "chart_type": "Small Multiples of Gauge Charts",
5
+ "chart_name": "small_multiples_gauge_plain_chart_04",
6
+ "is_composite": false,
7
+ "required_fields": ["x","y"],
8
+ "required_fields_type": [["categorical"], ["numerical"]],
9
+ "required_fields_range": [[2, 10], [0, "inf"]],
10
+ "required_fields_icons": [],
11
+ "required_other_icons": [],
12
+ "required_fields_colors": [],
13
+ "required_other_colors": ["primary"],
14
+ "supported_effects": ["shadow", "stroke"],
15
+ "min_height": 300,
16
+ "min_width": 300,
17
+ "background": "no",
18
+ "icon_mark": "none",
19
+ "icon_label": "none",
20
+ "has_x_axis": "no",
21
+ "has_y_axis": "no"
22
+ }
23
+ REQUIREMENTS_END
24
+ */
25
+
26
+ function makeChart(containerSelector, data) {
27
+ const jsonData = data || {};
28
+ const dataColumns = chartUtils.schema.columns(jsonData);
29
+ const colorResolver = chartUtils.color.resolver(jsonData);
30
+ const chartUtilsFormatSample = chartUtils.format.autoText;
31
+ const chartUtilsTextSample = chartUtils.text.estimate;
32
+ const chartUtilsStandard = {
33
+ schema: chartUtils.schema,
34
+ format: chartUtils.format,
35
+ text: chartUtils.text,
36
+ color: chartUtils.color,
37
+ legendLayout: chartUtils.legend.layout,
38
+ legendDraw: chartUtils.legend.draw,
39
+ random: chartUtils.random.generator(jsonData, "dev-wz-style")
40
+ };
41
+ const standardChannels = chartUtils.schema.channels(jsonData, {
42
+ x: { fallbackIndex: 0 },
43
+ y: { fallbackIndex: 1 },
44
+ y2: { fallbackIndex: 2 },
45
+ y3: { fallbackIndex: 3 },
46
+ size: { fallbackIndex: 2 },
47
+ group: { fallbackIndex: 2 },
48
+ group2: { fallbackIndex: 3 },
49
+ group3: { fallbackIndex: 4 }
50
+ });
51
+ const dataBlock = jsonData.data || {};
52
+ const chartData = dataBlock.data || [];
53
+ const variables = jsonData.variables || {};
54
+ const typography = jsonData.typography || {
55
+ label: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
56
+ annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
57
+ };
58
+ const sourceColors = jsonData.colors || {};
59
+
60
+ d3.select(containerSelector).html("");
61
+
62
+ const dimensionColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
63
+ const valueColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
64
+ const dimensionField = dimensionColumn.name;
65
+ const valueField = valueColumn.name;
66
+ const valueUnit = valueColumn.unit && valueColumn.unit !== "none" ? valueColumn.unit : "";
67
+
68
+ const width = Math.max(430, Number(variables.width) || 600);
69
+ const requestedHeight = Number(variables.height) || 390;
70
+ const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
71
+ const annotationFamily = typography.annotation?.font_family || fontFamily;
72
+ const labelWeight = typography.label?.font_weight || "700";
73
+ const valueWeight = typography.annotation?.font_weight || "800";
74
+ const textColor = sourceColors.text_color || "#182638";
75
+ const mutedText = "#617184";
76
+ const greenDark = "#1f7b3a";
77
+ const green = "#63ad4b";
78
+ const greenLight = "#b8db76";
79
+ const blueDark = "#07549f";
80
+ const blue = "#1b78c8";
81
+ const blueLight = "#a9d5f7";
82
+ const panelFill = "#ffffff";
83
+ const softPanel = "#f6f9fb";
84
+
85
+ function message(text) {
86
+ const svg = d3.select(containerSelector)
87
+ .append("svg")
88
+ .attr("width", "100%")
89
+ .attr("height", requestedHeight)
90
+ .attr("viewBox", `0 0 ${width} ${requestedHeight}`)
91
+ .attr("style", "max-width: 100%; height: auto;")
92
+ .attr("xmlns", "http://www.w3.org/2000/svg")
93
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
94
+ svg.append("text")
95
+ .attr("x", width / 2)
96
+ .attr("y", requestedHeight / 2)
97
+ .attr("text-anchor", "middle")
98
+ .attr("fill", textColor)
99
+ .style("font-family", fontFamily)
100
+ .style("font-size", "15px")
101
+ .text(text);
102
+ return svg.node();
103
+ }
104
+
105
+ if (!dimensionField || !valueField) return message("Missing gauge fields");
106
+
107
+ function readableLabel(value) {
108
+ return String(value ?? "")
109
+ .replace(/_/g, " ")
110
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
111
+ .replace(/\s+/g, " ")
112
+ .trim();
113
+ }
114
+
115
+ function parseTemporalSortValue(value) {
116
+ const text = String(value ?? "").trim();
117
+ let match = text.match(/^(-?\d{1,6})$/);
118
+ if (match) return Number(match[1]);
119
+ match = text.match(/^(-?\d{1,6})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
120
+ if (match) return Number(match[1]) * 10000 + Number(match[2]) * 100 + Number(match[3] || 1);
121
+ return NaN;
122
+ }
123
+
124
+ function compactNumber(value) {
125
+ const numericValue = Number(value);
126
+ if (!Number.isFinite(numericValue)) return String(value ?? "");
127
+ const absValue = Math.abs(numericValue);
128
+ if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
129
+ if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
130
+ if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}K`;
131
+ return d3.format(",.4~g")(numericValue);
132
+ }
133
+
134
+ function formatLocalValue(value) {
135
+ const formatted = compactNumber(value);
136
+ if (!valueUnit) return formatted;
137
+ if (valueUnit === "%") return `${formatted}%`;
138
+ if (valueUnit.length <= 3 && /^[^A-Za-z0-9]+$/.test(valueUnit)) return `${valueUnit}${formatted}`;
139
+ return `${formatted} ${valueUnit}`;
140
+ }
141
+
142
+ function fontSizeFor(text, maxWidth, baseSize, minSize = 7.2) {
143
+ const estimated = String(text ?? "").length * baseSize * 0.55;
144
+ if (estimated <= maxWidth) return baseSize;
145
+ return Math.max(minSize, Math.min(baseSize, maxWidth / Math.max(1, String(text ?? "").length * 0.55)));
146
+ }
147
+
148
+ function fitText(selection, text, maxWidth, baseSize, minSize = 7.2) {
149
+ const size = fontSizeFor(text, maxWidth, baseSize, minSize);
150
+ selection.style("font-size", `${size}px`).text(text);
151
+ const estimated = String(text ?? "").length * size * 0.55;
152
+ if (estimated > maxWidth) {
153
+ selection.attr("textLength", maxWidth).attr("lengthAdjust", "spacingAndGlyphs");
154
+ }
155
+ return selection;
156
+ }
157
+
158
+ const cleanedData = chartData
159
+ .map((row, index) => ({
160
+ row,
161
+ sourceIndex: index,
162
+ category: readableLabel(row[dimensionField]),
163
+ rawCategory: row[dimensionField],
164
+ value: Number(row[valueField]),
165
+ temporal: parseTemporalSortValue(row[dimensionField])
166
+ }))
167
+ .filter(d => d.category && Number.isFinite(d.value) && d.value >= 0);
168
+
169
+ if (!cleanedData.length) return message("No valid gauge data");
170
+
171
+ const hasTemporalLikeX = (
172
+ dimensionColumn.data_type === "temporal" ||
173
+ cleanedData.every(d => Number.isFinite(d.temporal))
174
+ );
175
+ cleanedData.sort((a, b) => {
176
+ if (hasTemporalLikeX && Number.isFinite(a.temporal) && Number.isFinite(b.temporal)) {
177
+ return d3.ascending(a.temporal, b.temporal) || d3.ascending(a.sourceIndex, b.sourceIndex);
178
+ }
179
+ return d3.descending(a.value, b.value) || d3.ascending(a.sourceIndex, b.sourceIndex);
180
+ });
181
+
182
+ const maxValue = d3.max(cleanedData, d => d.value) || 1;
183
+ const valueTextForScale = [
184
+ valueField,
185
+ valueColumn.description,
186
+ valueUnit
187
+ ].join(" ").toLowerCase();
188
+ const looksBoundedToHundred = (
189
+ cleanedData.every(d => d.value >= 0 && d.value <= 100) &&
190
+ /%|percent|percentage|share|rate|ratio|index|score/.test(valueTextForScale)
191
+ );
192
+ const gaugeMax = looksBoundedToHundred ? 100 : maxValue;
193
+
194
+ const count = cleanedData.length;
195
+ const cols = count <= 3 ? count : Math.min(4, count);
196
+ const rows = Math.ceil(count / cols);
197
+ const panelX = 8;
198
+ const panelY = 8;
199
+ const panelWidth = width - panelX * 2;
200
+ const topBandHeight = 88;
201
+ const footerHeight = 62;
202
+ const gridGapX = Math.max(10, width * 0.018);
203
+ const gridGapY = Math.max(16, width * 0.026);
204
+ const gridX = panelX + 20;
205
+ const gridWidth = panelWidth - 40;
206
+ const cardWidth = (gridWidth - gridGapX * (cols - 1)) / cols;
207
+ const rowHeight = Math.max(132, Math.min(178, cardWidth * 1.08));
208
+ const gridY = panelY + topBandHeight + 12;
209
+ const gridHeight = rows * rowHeight + (rows - 1) * gridGapY;
210
+ const footerY = gridY + gridHeight + 18;
211
+ const height = Math.max(360, footerY + footerHeight + 12);
212
+ const panelHeight = height - panelY * 2;
213
+
214
+ const svg = d3.select(containerSelector)
215
+ .append("svg")
216
+ .attr("width", "100%")
217
+ .attr("height", height)
218
+ .attr("viewBox", `0 0 ${width} ${height}`)
219
+ .attr("style", "max-width: 100%; height: auto;")
220
+ .attr("xmlns", "http://www.w3.org/2000/svg")
221
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
222
+ .attr("class", "small-multiples-gauge-chart-04");
223
+
224
+ const defs = svg.append("defs");
225
+ const safeId = chartUtils.random.value(jsonData, "small_multiples_gauge_plain_chart_04").toString(36).slice(2, 10);
226
+ const greenGradientId = `gauge-04-green-${safeId}`;
227
+ const blueGradientId = `gauge-04-blue-${safeId}`;
228
+ [greenGradientId, blueGradientId].forEach((id, idx) => {
229
+ const colorsForGradient = idx === 0
230
+ ? [{ offset: "0%", color: greenDark }, { offset: "58%", color: green }, { offset: "100%", color: greenLight }]
231
+ : [{ offset: "0%", color: blueDark }, { offset: "60%", color: blue }, { offset: "100%", color: blueLight }];
232
+ defs.append("linearGradient")
233
+ .attr("id", id)
234
+ .attr("x1", "0%")
235
+ .attr("x2", "100%")
236
+ .attr("y1", "0%")
237
+ .attr("y2", "0%")
238
+ .selectAll("stop")
239
+ .data(colorsForGradient)
240
+ .enter()
241
+ .append("stop")
242
+ .attr("offset", d => d.offset)
243
+ .attr("stop-color", d => d.color);
244
+ });
245
+
246
+ svg.append("rect")
247
+ .attr("class", "chart-style-panel")
248
+ .attr("x", panelX)
249
+ .attr("y", panelY)
250
+ .attr("width", panelWidth)
251
+ .attr("height", panelHeight)
252
+ .attr("rx", 10)
253
+ .attr("fill", panelFill)
254
+ .attr("stroke", "rgba(38, 77, 116, 0.14)");
255
+
256
+ svg.append("rect")
257
+ .attr("class", "reference-icon-slot chart-icon-slot")
258
+ .attr("data-slot", "icon")
259
+ .attr("x", panelX + 20)
260
+ .attr("y", panelY + 17)
261
+ .attr("width", 46)
262
+ .attr("height", 46)
263
+ .attr("rx", 11)
264
+ .attr("fill", "#eef7ef")
265
+ .attr("stroke", "rgba(31, 123, 58, 0.24)");
266
+ svg.append("path")
267
+ .attr("class", "reference-icon-slot-glyph")
268
+ .attr("d", `M${panelX + 33},${panelY + 48} C${panelX + 39},${panelY + 27} ${panelX + 58},${panelY + 25} ${panelX + 55},${panelY + 48}`)
269
+ .attr("fill", "none")
270
+ .attr("stroke", green)
271
+ .attr("stroke-width", 5)
272
+ .attr("stroke-linecap", "round");
273
+
274
+ const metricTitle = readableLabel(valueColumn.label || valueField || valueColumn.description);
275
+ svg.append("text")
276
+ .attr("class", "scale-note")
277
+ .attr("x", panelX + 80)
278
+ .attr("y", panelY + 34)
279
+ .attr("fill", "#17324d")
280
+ .style("font-family", annotationFamily)
281
+ .style("font-size", "11px")
282
+ .style("font-weight", "800")
283
+ .text(metricTitle);
284
+ svg.append("text")
285
+ .attr("class", "category-note")
286
+ .attr("x", panelX + 80)
287
+ .attr("y", panelY + 54)
288
+ .attr("fill", mutedText)
289
+ .style("font-family", annotationFamily)
290
+ .style("font-size", "9px")
291
+ .style("font-weight", "650")
292
+ .text(`${readableLabel(dimensionColumn.label || dimensionField)} ranked by ${hasTemporalLikeX ? "time" : "value"}`);
293
+
294
+ const decorX = panelX + panelWidth - 111;
295
+ svg.append("rect")
296
+ .attr("class", "reference-decorative-image-slot decorative-image-slot")
297
+ .attr("data-slot", "decorative-image")
298
+ .attr("x", decorX)
299
+ .attr("y", panelY + 16)
300
+ .attr("width", 88)
301
+ .attr("height", 48)
302
+ .attr("rx", 12)
303
+ .attr("fill", softPanel)
304
+ .attr("stroke", "rgba(7, 84, 159, 0.18)");
305
+ svg.append("circle")
306
+ .attr("class", "reference-decorative-image-dot")
307
+ .attr("cx", decorX + 25)
308
+ .attr("cy", panelY + 40)
309
+ .attr("r", 13)
310
+ .attr("fill", greenLight);
311
+ svg.append("path")
312
+ .attr("class", "reference-decorative-image-wave")
313
+ .attr("d", `M${decorX + 43},${panelY + 47} C${decorX + 53},${panelY + 20} ${decorX + 71},${panelY + 61} ${decorX + 80},${panelY + 29}`)
314
+ .attr("fill", "none")
315
+ .attr("stroke", blue)
316
+ .attr("stroke-width", 5)
317
+ .attr("stroke-linecap", "round");
318
+
319
+ const startAngle = -Math.PI * 0.78;
320
+ const endAngle = Math.PI * 0.78;
321
+
322
+ cleanedData.forEach((d, index) => {
323
+ const row = Math.floor(index / cols);
324
+ const col = index % cols;
325
+ const rowCount = Math.min(cols, count - row * cols);
326
+ const rowOffset = (cols - rowCount) * (cardWidth + gridGapX) / 2;
327
+ const x = gridX + rowOffset + col * (cardWidth + gridGapX);
328
+ const y = gridY + row * (rowHeight + gridGapY);
329
+ const isBlue = row % 2 === 1;
330
+ const accent = isBlue ? blue : green;
331
+ const accentDark = isBlue ? blueDark : greenDark;
332
+ const accentPale = isBlue ? blueLight : greenLight;
333
+ const gradientId = isBlue ? blueGradientId : greenGradientId;
334
+ const metricHeight = Math.max(38, Math.min(48, rowHeight * 0.30));
335
+ const gaugeCenterX = x + cardWidth / 2;
336
+ const gaugeCenterY = y + metricHeight + Math.max(48, rowHeight * 0.36);
337
+ const radius = Math.max(23, Math.min(cardWidth * 0.32, rowHeight * 0.28));
338
+ const arcThickness = Math.max(7, radius * 0.22);
339
+ const percent = Math.max(0, Math.min(d.value / gaugeMax, 1));
340
+ const valueAngle = startAngle + (endAngle - startAngle) * percent;
341
+ const trackArc = d3.arc()
342
+ .innerRadius(radius - arcThickness)
343
+ .outerRadius(radius)
344
+ .startAngle(startAngle)
345
+ .endAngle(endAngle);
346
+ const valueArc = d3.arc()
347
+ .innerRadius(radius - arcThickness)
348
+ .outerRadius(radius)
349
+ .startAngle(startAngle)
350
+ .endAngle(valueAngle);
351
+
352
+ const item = svg.append("g")
353
+ .attr("class", "gauge")
354
+ .attr("data-source-index", d.sourceIndex)
355
+ .attr("data-category", d.category)
356
+ .attr("data-value", d.value)
357
+ .attr("data-scale-max", gaugeMax);
358
+
359
+ item.append("rect")
360
+ .attr("class", "metric-card gauge-card")
361
+ .attr("data-source-index", d.sourceIndex)
362
+ .attr("data-category", d.category)
363
+ .attr("data-value", d.value)
364
+ .attr("x", x)
365
+ .attr("y", y)
366
+ .attr("width", cardWidth)
367
+ .attr("height", metricHeight)
368
+ .attr("rx", 6)
369
+ .attr("fill", accent)
370
+ .attr("fill-opacity", 0.94);
371
+
372
+ const valueText = formatLocalValue(d.value);
373
+ fitText(
374
+ item.append("text")
375
+ .attr("class", "value metric-value")
376
+ .attr("data-source-index", d.sourceIndex)
377
+ .attr("data-category", d.category)
378
+ .attr("data-value", d.value)
379
+ .attr("x", x + cardWidth / 2)
380
+ .attr("y", y + metricHeight * 0.43)
381
+ .attr("text-anchor", "middle")
382
+ .attr("dominant-baseline", "middle")
383
+ .attr("fill", "#ffffff")
384
+ .style("font-family", annotationFamily)
385
+ .style("font-weight", valueWeight),
386
+ valueText,
387
+ cardWidth - 16,
388
+ Math.max(12, Math.min(18, metricHeight * 0.34)),
389
+ 8.2
390
+ );
391
+
392
+ fitText(
393
+ item.append("text")
394
+ .attr("class", "label metric-label")
395
+ .attr("data-source-index", d.sourceIndex)
396
+ .attr("data-category", d.category)
397
+ .attr("data-value", d.value)
398
+ .attr("x", x + cardWidth / 2)
399
+ .attr("y", y + metricHeight * 0.76)
400
+ .attr("text-anchor", "middle")
401
+ .attr("dominant-baseline", "middle")
402
+ .attr("fill", "#f4fff7")
403
+ .style("font-family", fontFamily)
404
+ .style("font-weight", labelWeight),
405
+ d.category,
406
+ cardWidth - 14,
407
+ 8.6,
408
+ 6.8
409
+ );
410
+
411
+ item.append("rect")
412
+ .attr("class", "gauge-stage")
413
+ .attr("x", x)
414
+ .attr("y", y + metricHeight + 8)
415
+ .attr("width", cardWidth)
416
+ .attr("height", rowHeight - metricHeight - 10)
417
+ .attr("rx", 8)
418
+ .attr("fill", softPanel)
419
+ .attr("stroke", "rgba(54, 88, 122, 0.11)");
420
+
421
+ const face = item.append("g")
422
+ .attr("class", "gauge-face")
423
+ .attr("transform", `translate(${gaugeCenterX}, ${gaugeCenterY})`);
424
+
425
+ face.append("path")
426
+ .attr("class", "gauge-track")
427
+ .attr("data-source-index", d.sourceIndex)
428
+ .attr("data-category", d.category)
429
+ .attr("data-value", d.value)
430
+ .attr("d", trackArc)
431
+ .attr("fill", isBlue ? "#d9edf9" : "#e7f3dc");
432
+
433
+ face.append("path")
434
+ .attr("class", "mark gauge-value-arc")
435
+ .attr("data-source-index", d.sourceIndex)
436
+ .attr("data-category", d.category)
437
+ .attr("data-value", d.value)
438
+ .attr("data-scale-max", gaugeMax)
439
+ .attr("d", valueArc)
440
+ .attr("fill", `url(#${gradientId})`)
441
+ .style("opacity", 0.96);
442
+
443
+ face.append("circle")
444
+ .attr("class", "gauge-center-dot")
445
+ .attr("cx", 0)
446
+ .attr("cy", 0)
447
+ .attr("r", Math.max(2.2, radius * 0.07))
448
+ .attr("fill", accentDark);
449
+
450
+ face.append("circle")
451
+ .attr("class", "gauge-end-dot")
452
+ .attr("data-source-index", d.sourceIndex)
453
+ .attr("data-category", d.category)
454
+ .attr("data-value", d.value)
455
+ .attr("cx", Math.sin(valueAngle) * (radius - arcThickness / 2))
456
+ .attr("cy", -Math.cos(valueAngle) * (radius - arcThickness / 2))
457
+ .attr("r", Math.max(2.4, arcThickness * 0.35))
458
+ .attr("fill", "#ffffff")
459
+ .attr("stroke", accentDark)
460
+ .attr("stroke-width", 1.2);
461
+
462
+ fitText(
463
+ item.append("text")
464
+ .attr("class", "gauge-scale-caption")
465
+ .attr("data-source-index", d.sourceIndex)
466
+ .attr("data-category", d.category)
467
+ .attr("data-value", d.value)
468
+ .attr("x", gaugeCenterX)
469
+ .attr("y", Math.min(y + rowHeight - 12, gaugeCenterY + radius + 16))
470
+ .attr("text-anchor", "middle")
471
+ .attr("fill", mutedText)
472
+ .style("font-family", annotationFamily)
473
+ .style("font-weight", "650"),
474
+ `${Math.round(percent * 100)}% of scale`,
475
+ cardWidth - 12,
476
+ 8.0,
477
+ 6.8
478
+ );
479
+ });
480
+
481
+ const footer = svg.append("g")
482
+ .attr("class", "reference-footer-card")
483
+ .attr("transform", `translate(${gridX}, ${footerY})`);
484
+ footer.append("rect")
485
+ .attr("class", "footer-card")
486
+ .attr("x", 0)
487
+ .attr("y", 0)
488
+ .attr("width", gridWidth)
489
+ .attr("height", footerHeight)
490
+ .attr("rx", 8)
491
+ .attr("fill", "#f8fbfd")
492
+ .attr("stroke", "rgba(38, 77, 116, 0.13)");
493
+
494
+ const footerItems = [
495
+ { label: "Scale", text: `0 to ${formatLocalValue(gaugeMax)}` },
496
+ { label: "Items", text: `${count} gauges` },
497
+ { label: "Encoding", text: "arc length" }
498
+ ];
499
+ const itemWidth = gridWidth / footerItems.length;
500
+ footerItems.forEach((item, index) => {
501
+ const itemX = index * itemWidth;
502
+ if (index > 0) {
503
+ footer.append("line")
504
+ .attr("class", "footer-divider")
505
+ .attr("x1", itemX)
506
+ .attr("x2", itemX)
507
+ .attr("y1", 12)
508
+ .attr("y2", footerHeight - 12)
509
+ .attr("stroke", "rgba(38, 77, 116, 0.14)");
510
+ }
511
+ footer.append("rect")
512
+ .attr("class", "footer-icon-slot")
513
+ .attr("data-slot", `footer-icon-${index + 1}`)
514
+ .attr("x", itemX + 13)
515
+ .attr("y", 18)
516
+ .attr("width", 25)
517
+ .attr("height", 25)
518
+ .attr("rx", 7)
519
+ .attr("fill", index === 1 ? "#eef7ef" : "#eef6fc")
520
+ .attr("stroke", "rgba(38, 77, 116, 0.18)");
521
+ footer.append("circle")
522
+ .attr("class", "footer-icon-slot-dot")
523
+ .attr("cx", itemX + 25.5)
524
+ .attr("cy", 30.5)
525
+ .attr("r", 5.5)
526
+ .attr("fill", index === 1 ? green : blue);
527
+ footer.append("text")
528
+ .attr("class", "footer-label")
529
+ .attr("x", itemX + 48)
530
+ .attr("y", 27)
531
+ .attr("fill", "#17324d")
532
+ .style("font-family", annotationFamily)
533
+ .style("font-size", "8.5px")
534
+ .style("font-weight", "800")
535
+ .text(item.label);
536
+ footer.append("text")
537
+ .attr("class", "footer-value")
538
+ .attr("x", itemX + 48)
539
+ .attr("y", 45)
540
+ .attr("fill", mutedText)
541
+ .style("font-family", annotationFamily)
542
+ .style("font-size", "7.7px")
543
+ .style("font-weight", "650")
544
+ .text(item.text);
545
+ });
546
+
547
+ return svg.node();
548
+ }