Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
modules/chart_engine/template/d3-js/type38_radial_area_chart/radial_area_chart_grid_01.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Radial Area Chart",
|
| 5 |
+
"chart_name": "radial_area_chart_grid_01",
|
| 6 |
+
"required_fields": ["x", "y"],
|
| 7 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 8 |
+
"required_fields_range": [[3, 12], [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": 400,
|
| 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 |
+
// 内联utils函数
|
| 27 |
+
const measureTextWidth = (text, fontSize) => {
|
| 28 |
+
return chartUtils.text.measure(null, text, { fontSize: fontSize }).width;
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
// 提取数据
|
| 32 |
+
const jsonData = data;
|
| 33 |
+
const chartData = jsonData.data.data;
|
| 34 |
+
const variables = jsonData.variables;
|
| 35 |
+
const colors = jsonData.colors || {};
|
| 36 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 37 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 38 |
+
|
| 39 |
+
// 清空容器
|
| 40 |
+
d3.select(containerSelector).html("");
|
| 41 |
+
|
| 42 |
+
// 获取字段名
|
| 43 |
+
const categoryField = chartUtils.schema.columnField(dataColumns, 0);
|
| 44 |
+
const valueField = chartUtils.schema.columnField(dataColumns, 1);
|
| 45 |
+
|
| 46 |
+
// 设置尺寸和边距
|
| 47 |
+
const width = variables.width;
|
| 48 |
+
const height = variables.height;
|
| 49 |
+
const margin = { top: 50, right: 50, bottom: 50, left: 50 };
|
| 50 |
+
|
| 51 |
+
// 创建SVG
|
| 52 |
+
const svg = d3.select(containerSelector)
|
| 53 |
+
.append("svg")
|
| 54 |
+
.attr("width", "100%")
|
| 55 |
+
.attr("height", height)
|
| 56 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 57 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 58 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 59 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 60 |
+
|
| 61 |
+
// 创建图表区域
|
| 62 |
+
const chartWidth = width - margin.left - margin.right;
|
| 63 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 64 |
+
const radius = Math.min(chartWidth, chartHeight) / 2;
|
| 65 |
+
|
| 66 |
+
const g = svg.append("g")
|
| 67 |
+
.attr("transform", `translate(${width/2}, ${height/2})`);
|
| 68 |
+
|
| 69 |
+
// 获取唯一类别和主色调
|
| 70 |
+
const categories = [...new Set(chartData.map(d => d[categoryField]))];
|
| 71 |
+
const mainColor = colorResolver.other("primary", { fallback: "#1f77b4" }).value;
|
| 72 |
+
|
| 73 |
+
// 创建比例尺
|
| 74 |
+
const angleScale = d3.scalePoint()
|
| 75 |
+
.domain(categories)
|
| 76 |
+
.range([0, 2 * Math.PI - (2 * Math.PI / categories.length)]);
|
| 77 |
+
|
| 78 |
+
const allValues = chartData.map(d => +d[valueField]);
|
| 79 |
+
const radiusScale = d3.scaleLinear()
|
| 80 |
+
.domain([Math.min(0, d3.min(allValues)), d3.max(allValues) * 1.2])
|
| 81 |
+
.range([0, radius])
|
| 82 |
+
.nice();
|
| 83 |
+
|
| 84 |
+
// 绘制网格线
|
| 85 |
+
const ticks = radiusScale.ticks(5);
|
| 86 |
+
|
| 87 |
+
// 绘制同心圆网格
|
| 88 |
+
g.selectAll(".gridline")
|
| 89 |
+
.data(ticks)
|
| 90 |
+
.enter()
|
| 91 |
+
.append("circle")
|
| 92 |
+
.attr("class", "gridline")
|
| 93 |
+
.attr("cx", 0)
|
| 94 |
+
.attr("cy", 0)
|
| 95 |
+
.attr("r", d => radiusScale(d))
|
| 96 |
+
.attr("fill", "none")
|
| 97 |
+
.attr("stroke", "#bbb")
|
| 98 |
+
.attr("stroke-width", 1)
|
| 99 |
+
.attr("stroke-dasharray", "4,4");
|
| 100 |
+
|
| 101 |
+
// 绘制径向轴线
|
| 102 |
+
g.selectAll(".axis")
|
| 103 |
+
.data(categories)
|
| 104 |
+
.enter()
|
| 105 |
+
.append("line")
|
| 106 |
+
.attr("class", "axis")
|
| 107 |
+
.attr("x1", 0)
|
| 108 |
+
.attr("y1", 0)
|
| 109 |
+
.attr("x2", d => radius * Math.cos(angleScale(d) - Math.PI/2))
|
| 110 |
+
.attr("y2", d => radius * Math.sin(angleScale(d) - Math.PI/2))
|
| 111 |
+
.attr("stroke", "#bbb")
|
| 112 |
+
.attr("stroke-width", 1);
|
| 113 |
+
|
| 114 |
+
// 添加类别标签
|
| 115 |
+
g.selectAll(".label")
|
| 116 |
+
.data(categories)
|
| 117 |
+
.enter()
|
| 118 |
+
.append("text")
|
| 119 |
+
.attr("class", "label")
|
| 120 |
+
.attr("x", d => (radius + 20) * Math.cos(angleScale(d) - Math.PI/2))
|
| 121 |
+
.attr("y", d => (radius + 20) * Math.sin(angleScale(d) - Math.PI/2))
|
| 122 |
+
.attr("text-anchor", d => {
|
| 123 |
+
const angle = angleScale(d);
|
| 124 |
+
if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) return "middle";
|
| 125 |
+
return angle > Math.PI ? "end" : "start";
|
| 126 |
+
})
|
| 127 |
+
.attr("dominant-baseline", d => {
|
| 128 |
+
const angle = angleScale(d);
|
| 129 |
+
if (Math.abs(angle) < 0.1 || Math.abs(angle - Math.PI) < 0.1) return "middle";
|
| 130 |
+
return angle < Math.PI ? "hanging" : "auto";
|
| 131 |
+
})
|
| 132 |
+
.attr("fill", "#333")
|
| 133 |
+
.attr("font-size", "16px")
|
| 134 |
+
.attr("font-weight", "bold")
|
| 135 |
+
.text(d => d);
|
| 136 |
+
|
| 137 |
+
// 添加刻度值标签
|
| 138 |
+
g.selectAll(".value")
|
| 139 |
+
.data(ticks)
|
| 140 |
+
.enter()
|
| 141 |
+
.append("text")
|
| 142 |
+
.attr("class", "value")
|
| 143 |
+
.attr("x", 5)
|
| 144 |
+
.attr("y", d => -radiusScale(d))
|
| 145 |
+
.attr("text-anchor", "start")
|
| 146 |
+
.attr("font-size", "14px")
|
| 147 |
+
.attr("fill", "#666")
|
| 148 |
+
.text(d => d);
|
| 149 |
+
|
| 150 |
+
// 创建���达路径
|
| 151 |
+
const points = categories.map(cat => {
|
| 152 |
+
const point = chartData.find(item => item[categoryField] === cat);
|
| 153 |
+
if (point) {
|
| 154 |
+
const angle = angleScale(cat) - Math.PI/2;
|
| 155 |
+
const distance = radiusScale(+point[valueField]);
|
| 156 |
+
return [distance * Math.cos(angle), distance * Math.sin(angle)];
|
| 157 |
+
}
|
| 158 |
+
return [0, 0];
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
// 绘制雷达面积
|
| 162 |
+
g.append("path")
|
| 163 |
+
.attr("class", "mark")
|
| 164 |
+
.attr("d", d3.line()(points) + "Z")
|
| 165 |
+
.attr("fill", mainColor)
|
| 166 |
+
.attr("fill-opacity", 0.2)
|
| 167 |
+
.attr("stroke", mainColor)
|
| 168 |
+
.attr("stroke-width", 6)
|
| 169 |
+
.attr("stroke-linejoin", "miter");
|
| 170 |
+
|
| 171 |
+
// 绘制数据点和数值标签
|
| 172 |
+
categories.forEach((cat, index) => {
|
| 173 |
+
const point = chartData.find(item => item[categoryField] === cat);
|
| 174 |
+
if (point) {
|
| 175 |
+
const angle = angleScale(cat) - Math.PI/2;
|
| 176 |
+
const distance = radiusScale(+point[valueField]);
|
| 177 |
+
const x = distance * Math.cos(angle);
|
| 178 |
+
const y = distance * Math.sin(angle);
|
| 179 |
+
|
| 180 |
+
// 数据点
|
| 181 |
+
g.append("circle")
|
| 182 |
+
.attr("class", "mark")
|
| 183 |
+
.attr("cx", x)
|
| 184 |
+
.attr("cy", y)
|
| 185 |
+
.attr("r", 6)
|
| 186 |
+
.attr("fill", mainColor)
|
| 187 |
+
.attr("stroke", "#fff")
|
| 188 |
+
.attr("stroke-width", 3);
|
| 189 |
+
|
| 190 |
+
// 数值标签
|
| 191 |
+
const labelText = chartUtils.format.number(point[valueField]).text;
|
| 192 |
+
const textWidth = measureTextWidth(labelText, 14);
|
| 193 |
+
const textX = index === 0 ? (distance + 30) * Math.cos(angle) - 20 : (distance + 30) * Math.cos(angle);
|
| 194 |
+
const textY = index === 0 ? (distance + 15) * Math.sin(angle) : (distance + 30) * Math.sin(angle);
|
| 195 |
+
|
| 196 |
+
// 标签背景
|
| 197 |
+
g.append("rect")
|
| 198 |
+
.attr("class", "background")
|
| 199 |
+
.attr("x", textX - textWidth/2 - 4)
|
| 200 |
+
.attr("y", textY - 8)
|
| 201 |
+
.attr("width", textWidth + 8)
|
| 202 |
+
.attr("height", 16)
|
| 203 |
+
.attr("fill", mainColor)
|
| 204 |
+
.attr("rx", 3);
|
| 205 |
+
|
| 206 |
+
// 标签文字
|
| 207 |
+
g.append("text")
|
| 208 |
+
.attr("class", "value")
|
| 209 |
+
.attr("x", textX)
|
| 210 |
+
.attr("y", textY)
|
| 211 |
+
.attr("text-anchor", "middle")
|
| 212 |
+
.attr("dominant-baseline", "middle")
|
| 213 |
+
.attr("font-size", "14px")
|
| 214 |
+
.attr("fill", "#fff")
|
| 215 |
+
.text(labelText);
|
| 216 |
+
}
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
return svg.node();
|
| 220 |
+
}
|
modules/chart_engine/template/d3-js/type38_radial_area_chart/radial_area_plain_chart_01.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Radial Area Chart",
|
| 5 |
+
"chart_name": "radial_area_plain_chart_01",
|
| 6 |
+
"required_fields": ["x", "y"],
|
| 7 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 8 |
+
"required_fields_range": [[3, 12], [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": 400,
|
| 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 |
+
const jsonData = data || {};
|
| 27 |
+
const sourceData = jsonData.data?.data || [];
|
| 28 |
+
const variables = jsonData.variables || {};
|
| 29 |
+
const typography = jsonData.typography || {};
|
| 30 |
+
const sourceColors = jsonData.colors || {};
|
| 31 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 32 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 33 |
+
const chartUtilsFormatSample = chartUtils.format.autoText;
|
| 34 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 35 |
+
const chartUtilsStandard = {
|
| 36 |
+
schema: chartUtils.schema,
|
| 37 |
+
format: chartUtils.format,
|
| 38 |
+
text: chartUtils.text,
|
| 39 |
+
color: chartUtils.color,
|
| 40 |
+
legendLayout: chartUtils.legend.layout,
|
| 41 |
+
legendDraw: chartUtils.legend.draw,
|
| 42 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 43 |
+
};
|
| 44 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 45 |
+
x: { fallbackIndex: 0 },
|
| 46 |
+
y: { fallbackIndex: 1 },
|
| 47 |
+
y2: { fallbackIndex: 2 },
|
| 48 |
+
y3: { fallbackIndex: 3 },
|
| 49 |
+
size: { fallbackIndex: 2 },
|
| 50 |
+
group: { fallbackIndex: 2 },
|
| 51 |
+
group2: { fallbackIndex: 3 },
|
| 52 |
+
group3: { fallbackIndex: 4 }
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
d3.select(containerSelector).html("");
|
| 56 |
+
|
| 57 |
+
const categoryField = chartUtils.schema.column(dataColumns, 0).raw?.name;
|
| 58 |
+
const valueField = chartUtils.schema.column(dataColumns, 1).raw?.name;
|
| 59 |
+
const categoryType = chartUtils.schema.column(dataColumns, 0).raw?.data_type || "categorical";
|
| 60 |
+
const categoryUnit = (chartUtils.schema.column(dataColumns, 0).raw?.unit || "").trim();
|
| 61 |
+
const valueUnit = (chartUtils.schema.column(dataColumns, 1).raw?.unit || "").trim();
|
| 62 |
+
const width = variables.width || 600;
|
| 63 |
+
const height = variables.height || 600;
|
| 64 |
+
|
| 65 |
+
const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
|
| 66 |
+
const titleFont = typography.title?.font_family || fontFamily;
|
| 67 |
+
const textColor = sourceColors.text_color || "#1f2937";
|
| 68 |
+
const mutedText = "#6b7280";
|
| 69 |
+
const gridColor = "#d8e2ea";
|
| 70 |
+
const mainColor = sourceColors.other?.primary || "#2563eb";
|
| 71 |
+
|
| 72 |
+
const readableName = value => String(value || "")
|
| 73 |
+
.replace(/_/g, " ")
|
| 74 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 75 |
+
.replace(/\s+/g, " ")
|
| 76 |
+
.trim();
|
| 77 |
+
|
| 78 |
+
const categoryLabel = readableName(chartUtils.schema.column(dataColumns, 0).raw?.label || chartUtils.schema.column(dataColumns, 0).raw?.name || categoryField);
|
| 79 |
+
const valueLabel = readableName(chartUtils.schema.column(dataColumns, 1).raw?.label || chartUtils.schema.column(dataColumns, 1).raw?.name || valueField);
|
| 80 |
+
|
| 81 |
+
const svg = d3.select(containerSelector)
|
| 82 |
+
.append("svg")
|
| 83 |
+
.attr("width", "100%")
|
| 84 |
+
.attr("height", height)
|
| 85 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 86 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 87 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 88 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 89 |
+
.attr("class", "radial-area-chart-root");
|
| 90 |
+
|
| 91 |
+
if (!categoryField || !valueField) {
|
| 92 |
+
svg.append("text")
|
| 93 |
+
.attr("x", width / 2)
|
| 94 |
+
.attr("y", height / 2)
|
| 95 |
+
.attr("text-anchor", "middle")
|
| 96 |
+
.attr("fill", textColor)
|
| 97 |
+
.style("font-family", fontFamily)
|
| 98 |
+
.style("font-size", "16px")
|
| 99 |
+
.text("Missing radial area fields");
|
| 100 |
+
return svg.node();
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const parseTemporal = value => {
|
| 104 |
+
if (value instanceof Date) return value;
|
| 105 |
+
const numeric = Number(value);
|
| 106 |
+
if (Number.isFinite(numeric) && String(value).trim().length <= 4) {
|
| 107 |
+
return new Date(numeric, 0, 1);
|
| 108 |
+
}
|
| 109 |
+
const parsed = new Date(value);
|
| 110 |
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
const compactNumber = value => {
|
| 114 |
+
const abs = Math.abs(value);
|
| 115 |
+
const spec = abs >= 100 ? ".0f" : abs >= 10 ? ".1f" : ".2f";
|
| 116 |
+
if (abs >= 1000000000) return `${d3.format(spec)(value / 1000000000)}B`;
|
| 117 |
+
if (abs >= 1000000) return `${d3.format(spec)(value / 1000000)}M`;
|
| 118 |
+
if (abs >= 1000) return `${d3.format(spec)(value / 1000)}K`;
|
| 119 |
+
return d3.format(spec)(value).replace(/\.0$/, "");
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
const formatLocalValue = value => {
|
| 123 |
+
const formatted = compactNumber(value);
|
| 124 |
+
const currencyWithSuffix = valueUnit.match(/^([£$€¥])\s*([A-Za-z]+)$/);
|
| 125 |
+
if (valueUnit === "%") return `${formatted}%`;
|
| 126 |
+
if (currencyWithSuffix) return `${currencyWithSuffix[1]}${formatted}${currencyWithSuffix[2]}`;
|
| 127 |
+
if (/^[£$€¥]$/.test(valueUnit)) return `${valueUnit}${formatted}`;
|
| 128 |
+
if (valueUnit) return `${formatted} ${valueUnit}`;
|
| 129 |
+
return formatted;
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
const categoryMap = new Map();
|
| 133 |
+
sourceData.forEach(d => {
|
| 134 |
+
const category = String(d[categoryField]);
|
| 135 |
+
const value = Number(d[valueField]);
|
| 136 |
+
if (!category || !Number.isFinite(value) || value < 0) return;
|
| 137 |
+
if (!categoryMap.has(category)) {
|
| 138 |
+
categoryMap.set(category, { rawCategory: d[categoryField], values: [] });
|
| 139 |
+
}
|
| 140 |
+
categoryMap.get(category).values.push(value);
|
| 141 |
+
});
|
| 142 |
+
const grouped = Array.from(categoryMap.entries()).map(([category, entry]) => {
|
| 143 |
+
const value = d3.mean(entry.values);
|
| 144 |
+
return {
|
| 145 |
+
category,
|
| 146 |
+
rawCategory: entry.rawCategory,
|
| 147 |
+
value
|
| 148 |
+
};
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
if (grouped.length < 3) {
|
| 152 |
+
svg.append("text")
|
| 153 |
+
.attr("x", width / 2)
|
| 154 |
+
.attr("y", height / 2)
|
| 155 |
+
.attr("text-anchor", "middle")
|
| 156 |
+
.attr("fill", textColor)
|
| 157 |
+
.style("font-family", fontFamily)
|
| 158 |
+
.style("font-size", "16px")
|
| 159 |
+
.text("Not enough data");
|
| 160 |
+
return svg.node();
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
let chartData = grouped.slice();
|
| 164 |
+
if (categoryType === "temporal") {
|
| 165 |
+
chartData.sort((a, b) => {
|
| 166 |
+
const aDate = parseTemporal(a.rawCategory);
|
| 167 |
+
const bDate = parseTemporal(b.rawCategory);
|
| 168 |
+
return d3.ascending(aDate ? +aDate : Number(a.rawCategory), bDate ? +bDate : Number(b.rawCategory));
|
| 169 |
+
});
|
| 170 |
+
} else {
|
| 171 |
+
chartData.sort((a, b) => d3.descending(a.value, b.value) || d3.ascending(a.category, b.category));
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
const maxLabelLength = d3.max(chartData, d => d.category.length) || 0;
|
| 175 |
+
const needsKey = categoryType !== "temporal" && (maxLabelLength > 12 || chartData.length > 10);
|
| 176 |
+
chartData.forEach((d, index) => {
|
| 177 |
+
d.displayLabel = needsKey ? `R${index + 1}` : d.category;
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
const margin = {
|
| 181 |
+
top: 48,
|
| 182 |
+
right: needsKey ? 168 : 44,
|
| 183 |
+
bottom: 56,
|
| 184 |
+
left: 44
|
| 185 |
+
};
|
| 186 |
+
const plotWidth = width - margin.left - margin.right;
|
| 187 |
+
const plotHeight = height - margin.top - margin.bottom;
|
| 188 |
+
const radius = Math.max(80, Math.min(plotWidth, plotHeight) / 2 - 26);
|
| 189 |
+
const centerX = margin.left + plotWidth / 2;
|
| 190 |
+
const centerY = margin.top + plotHeight / 2 + 10;
|
| 191 |
+
|
| 192 |
+
const maxValue = d3.max(chartData, d => d.value) || 1;
|
| 193 |
+
const radiusScale = d3.scaleLinear()
|
| 194 |
+
.domain([0, maxValue])
|
| 195 |
+
.range([0, radius])
|
| 196 |
+
.nice(4);
|
| 197 |
+
const radiusTicks = radiusScale.ticks(4).filter(tick => tick > 0);
|
| 198 |
+
const maxTick = d3.max(radiusTicks) || maxValue;
|
| 199 |
+
radiusScale.domain([0, maxTick]);
|
| 200 |
+
|
| 201 |
+
const angleStep = (2 * Math.PI) / chartData.length;
|
| 202 |
+
const angleForIndex = index => index * angleStep - Math.PI / 2;
|
| 203 |
+
|
| 204 |
+
const pointFor = (d, index, distance = radiusScale(d.value)) => {
|
| 205 |
+
const angle = angleForIndex(index);
|
| 206 |
+
return {
|
| 207 |
+
x: distance * Math.cos(angle),
|
| 208 |
+
y: distance * Math.sin(angle),
|
| 209 |
+
angle
|
| 210 |
+
};
|
| 211 |
+
};
|
| 212 |
+
|
| 213 |
+
const pathPoints = chartData.map((d, index) => {
|
| 214 |
+
const point = pointFor(d, index);
|
| 215 |
+
return [point.x, point.y];
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
const g = svg.append("g")
|
| 219 |
+
.attr("class", "radial-area-plot")
|
| 220 |
+
.attr("data-tag", "chart")
|
| 221 |
+
.attr("transform", `translate(${centerX}, ${centerY})`);
|
| 222 |
+
|
| 223 |
+
radiusTicks.forEach(tick => {
|
| 224 |
+
g.append("circle")
|
| 225 |
+
.attr("class", "radial-grid-ring")
|
| 226 |
+
.attr("r", radiusScale(tick))
|
| 227 |
+
.attr("fill", "none")
|
| 228 |
+
.attr("stroke", gridColor)
|
| 229 |
+
.attr("stroke-width", 1);
|
| 230 |
+
|
| 231 |
+
g.append("text")
|
| 232 |
+
.attr("class", "radial-grid-label")
|
| 233 |
+
.attr("x", 6)
|
| 234 |
+
.attr("y", -radiusScale(tick) - 3)
|
| 235 |
+
.attr("fill", mutedText)
|
| 236 |
+
.style("font-family", fontFamily)
|
| 237 |
+
.style("font-size", "10px")
|
| 238 |
+
.text(formatLocalValue(tick));
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
chartData.forEach((d, index) => {
|
| 242 |
+
const outer = pointFor(d, index, radius);
|
| 243 |
+
const labelDistance = radius + 15;
|
| 244 |
+
const labelPoint = pointFor(d, index, labelDistance);
|
| 245 |
+
const cos = Math.cos(outer.angle);
|
| 246 |
+
const sin = Math.sin(outer.angle);
|
| 247 |
+
|
| 248 |
+
g.append("line")
|
| 249 |
+
.attr("class", "radial-axis-line")
|
| 250 |
+
.attr("x1", 0)
|
| 251 |
+
.attr("y1", 0)
|
| 252 |
+
.attr("x2", outer.x)
|
| 253 |
+
.attr("y2", outer.y)
|
| 254 |
+
.attr("stroke", "#cbd5df")
|
| 255 |
+
.attr("stroke-width", 1);
|
| 256 |
+
|
| 257 |
+
g.append("text")
|
| 258 |
+
.attr("class", "category-label")
|
| 259 |
+
.attr("x", labelPoint.x)
|
| 260 |
+
.attr("y", labelPoint.y)
|
| 261 |
+
.attr("text-anchor", Math.abs(cos) < 0.25 ? "middle" : (cos > 0 ? "start" : "end"))
|
| 262 |
+
.attr("dominant-baseline", Math.abs(sin) < 0.25 ? "middle" : (sin > 0 ? "hanging" : "auto"))
|
| 263 |
+
.attr("fill", textColor)
|
| 264 |
+
.style("font-family", fontFamily)
|
| 265 |
+
.style("font-size", needsKey ? "10px" : "11px")
|
| 266 |
+
.style("font-weight", "700")
|
| 267 |
+
.text(d.displayLabel);
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
g.append("path")
|
| 271 |
+
.attr("class", "radial-area-path")
|
| 272 |
+
.attr("data-tag", "mark")
|
| 273 |
+
.attr("d", d3.line().curve(d3.curveLinearClosed)(pathPoints))
|
| 274 |
+
.attr("fill", mainColor)
|
| 275 |
+
.attr("fill-opacity", 0.22)
|
| 276 |
+
.attr("stroke", mainColor)
|
| 277 |
+
.attr("stroke-width", 3)
|
| 278 |
+
.attr("stroke-linejoin", "round");
|
| 279 |
+
|
| 280 |
+
chartData.forEach((d, index) => {
|
| 281 |
+
const point = pointFor(d, index);
|
| 282 |
+
g.append("circle")
|
| 283 |
+
.attr("class", "radial-area-point")
|
| 284 |
+
.attr("data-tag", "mark")
|
| 285 |
+
.attr("cx", point.x)
|
| 286 |
+
.attr("cy", point.y)
|
| 287 |
+
.attr("r", 4.2)
|
| 288 |
+
.attr("fill", mainColor)
|
| 289 |
+
.attr("stroke", "#ffffff")
|
| 290 |
+
.attr("stroke-width", 2);
|
| 291 |
+
});
|
| 292 |
+
|
| 293 |
+
let maxIndex = 0;
|
| 294 |
+
let minIndex = 0;
|
| 295 |
+
chartData.forEach((d, index) => {
|
| 296 |
+
if (d.value > chartData[maxIndex].value) maxIndex = index;
|
| 297 |
+
if (d.value < chartData[minIndex].value) minIndex = index;
|
| 298 |
+
});
|
| 299 |
+
const extrema = new Set([maxIndex, minIndex]);
|
| 300 |
+
chartData.forEach((d, index) => {
|
| 301 |
+
if (!extrema.has(index)) return;
|
| 302 |
+
const point = pointFor(d, index, radiusScale(d.value) + 18);
|
| 303 |
+
const label = formatLocalValue(d.value);
|
| 304 |
+
const labelWidth = Math.max(34, label.length * 6.2);
|
| 305 |
+
|
| 306 |
+
g.append("rect")
|
| 307 |
+
.attr("class", "extreme-value-background")
|
| 308 |
+
.attr("x", point.x - labelWidth / 2 - 4)
|
| 309 |
+
.attr("y", point.y - 9)
|
| 310 |
+
.attr("width", labelWidth + 8)
|
| 311 |
+
.attr("height", 18)
|
| 312 |
+
.attr("rx", 4)
|
| 313 |
+
.attr("fill", "#ffffff")
|
| 314 |
+
.attr("stroke", mainColor)
|
| 315 |
+
.attr("stroke-width", 1);
|
| 316 |
+
|
| 317 |
+
g.append("text")
|
| 318 |
+
.attr("class", "value-label")
|
| 319 |
+
.attr("x", point.x)
|
| 320 |
+
.attr("y", point.y + 1)
|
| 321 |
+
.attr("text-anchor", "middle")
|
| 322 |
+
.attr("dominant-baseline", "middle")
|
| 323 |
+
.attr("fill", mainColor)
|
| 324 |
+
.style("font-family", fontFamily)
|
| 325 |
+
.style("font-size", "10px")
|
| 326 |
+
.style("font-weight", "700")
|
| 327 |
+
.text(label);
|
| 328 |
+
});
|
| 329 |
+
|
| 330 |
+
g.append("text")
|
| 331 |
+
.attr("class", "radial-axis-title")
|
| 332 |
+
.attr("x", 0)
|
| 333 |
+
.attr("y", radius + 42)
|
| 334 |
+
.attr("text-anchor", "middle")
|
| 335 |
+
.attr("fill", mutedText)
|
| 336 |
+
.style("font-family", titleFont)
|
| 337 |
+
.style("font-size", "11px")
|
| 338 |
+
.text(valueUnit ? `${valueLabel} (${valueUnit})` : valueLabel);
|
| 339 |
+
|
| 340 |
+
if (categoryType === "temporal") {
|
| 341 |
+
const first = chartData[0]?.category;
|
| 342 |
+
const last = chartData[chartData.length - 1]?.category;
|
| 343 |
+
g.append("text")
|
| 344 |
+
.attr("class", "category-context-label")
|
| 345 |
+
.attr("x", 0)
|
| 346 |
+
.attr("y", -radius - 28)
|
| 347 |
+
.attr("text-anchor", "middle")
|
| 348 |
+
.attr("fill", mutedText)
|
| 349 |
+
.style("font-family", fontFamily)
|
| 350 |
+
.style("font-size", "10px")
|
| 351 |
+
.text(`${categoryLabel}${categoryUnit ? ` (${categoryUnit})` : ""}: ${first}-${last}`);
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
if (needsKey) {
|
| 355 |
+
const key = svg.append("g")
|
| 356 |
+
.attr("class", "category-key")
|
| 357 |
+
.attr("transform", `translate(${width - margin.right + 16}, ${margin.top + 10})`);
|
| 358 |
+
|
| 359 |
+
key.append("text")
|
| 360 |
+
.attr("class", "category-key-title")
|
| 361 |
+
.attr("x", 0)
|
| 362 |
+
.attr("y", 0)
|
| 363 |
+
.attr("fill", textColor)
|
| 364 |
+
.style("font-family", titleFont)
|
| 365 |
+
.style("font-size", "11px")
|
| 366 |
+
.style("font-weight", "700")
|
| 367 |
+
.text(categoryLabel);
|
| 368 |
+
|
| 369 |
+
chartData.forEach((d, index) => {
|
| 370 |
+
const row = key.append("g")
|
| 371 |
+
.attr("class", "category-key-row")
|
| 372 |
+
.attr("transform", `translate(0, ${18 + index * 18})`);
|
| 373 |
+
const label = d.category.length > 21 ? `${d.category.slice(0, 20)}...` : d.category;
|
| 374 |
+
|
| 375 |
+
row.append("text")
|
| 376 |
+
.attr("class", "category-key-label")
|
| 377 |
+
.attr("x", 0)
|
| 378 |
+
.attr("y", 0)
|
| 379 |
+
.attr("fill", textColor)
|
| 380 |
+
.style("font-family", fontFamily)
|
| 381 |
+
.style("font-size", "9.5px")
|
| 382 |
+
.text(`${d.displayLabel} ${label}`);
|
| 383 |
+
});
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
return svg.node();
|
| 387 |
+
}
|