Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
modules/chart_engine/template/d3-js/type25_line_graph/line_graph_plain_chart_01.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Line Graph",
|
| 5 |
+
"chart_name": "line_graph_plain_chart_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 30], ["-inf", "inf"], [2, 10]],
|
| 9 |
+
"required_fields_icons": ["group"],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 400,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "overlay",
|
| 18 |
+
"icon_label": "side",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes",
|
| 21 |
+
"chart_for": "comparison"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
// 提取数据
|
| 28 |
+
const jsonData = data;
|
| 29 |
+
const chartData = jsonData.data.data;
|
| 30 |
+
const variables = jsonData.variables;
|
| 31 |
+
const typography = jsonData.typography;
|
| 32 |
+
const colors = jsonData.colors || {};
|
| 33 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 34 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 35 |
+
const images = jsonData.images || {};
|
| 36 |
+
|
| 37 |
+
// 清空容器
|
| 38 |
+
d3.select(containerSelector).html("");
|
| 39 |
+
|
| 40 |
+
// 获取字段名
|
| 41 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 42 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 43 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
// 设置尺寸和边距
|
| 47 |
+
const width = variables.width;
|
| 48 |
+
const height = variables.height;
|
| 49 |
+
const margin = { top: 20, right: 30, bottom: 40, 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 |
+
|
| 65 |
+
// X轴文本的高度
|
| 66 |
+
const xAxisTextHeight = 30;
|
| 67 |
+
|
| 68 |
+
const g = svg.append("g")
|
| 69 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 70 |
+
|
| 71 |
+
// 获取唯一的组值
|
| 72 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 73 |
+
|
| 74 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
|
| 75 |
+
|
| 76 |
+
// 创建y轴比例尺 - 使用数据的实际范围
|
| 77 |
+
const yMin = d3.min(chartData, d => +d[yField]);
|
| 78 |
+
const yMax = d3.max(chartData, d => +d[yField]);
|
| 79 |
+
|
| 80 |
+
// 为了美观,稍微扩展Y轴范围
|
| 81 |
+
const yPadding = (yMax - yMin) * 0.3;
|
| 82 |
+
const yDomainMax = yMax + yPadding;
|
| 83 |
+
const yDomainMin = Math.min(0, yMin - yPadding);
|
| 84 |
+
|
| 85 |
+
const yScale = d3.scaleLinear()
|
| 86 |
+
.domain([yDomainMin, yDomainMax])
|
| 87 |
+
.range([chartHeight, 0]);
|
| 88 |
+
|
| 89 |
+
// 创建颜色比例尺
|
| 90 |
+
const colorScale = colorResolver.scale(groups, { palette: "category10" });
|
| 91 |
+
|
| 92 |
+
// 获取实际的Y轴刻度 - 减少刻度数量
|
| 93 |
+
const yTicks = yScale.ticks(5); // 保持5个刻度
|
| 94 |
+
const maxYTick = yTicks[yTicks.length - 1]; // 最大的Y轴刻度值
|
| 95 |
+
|
| 96 |
+
// 计算最大Y刻度的位置
|
| 97 |
+
const maxYTickPosition = yScale(maxYTick);
|
| 98 |
+
|
| 99 |
+
// 添加条纹背景 - 使用更合适的时间间隔
|
| 100 |
+
|
| 101 |
+
// 为每个X轴刻度创建条纹背景,使条纹以刻度为中心
|
| 102 |
+
for (let i = 0; i < xTicks.length - 1; i++) {
|
| 103 |
+
// 获取相邻两个刻度
|
| 104 |
+
const currentTick = xTicks[i];
|
| 105 |
+
const nextTick = xTicks[i + 1];
|
| 106 |
+
|
| 107 |
+
// 计算当前刻度和下一个刻度的位置
|
| 108 |
+
const x1 = xScale(currentTick);
|
| 109 |
+
const x2 = xScale(nextTick);
|
| 110 |
+
|
| 111 |
+
// 每隔一个刻度添加浅色背景
|
| 112 |
+
if (i % 2 === 0) {
|
| 113 |
+
g.append("rect")
|
| 114 |
+
.attr("x", x1)
|
| 115 |
+
.attr("y", maxYTickPosition) // 从最大Y刻度开始
|
| 116 |
+
.attr("width", x2 - x1)
|
| 117 |
+
.attr("height", chartHeight - maxYTickPosition + xAxisTextHeight) // 延伸到X轴文本下方
|
| 118 |
+
.attr("fill", "#ececec")
|
| 119 |
+
.attr("class", "background")
|
| 120 |
+
.attr("opacity", 0.8);
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
// 将条纹背景移到最底层
|
| 126 |
+
g.selectAll("rect").lower();
|
| 127 |
+
|
| 128 |
+
// 添加图标水印(如果有)
|
| 129 |
+
if (images && images.field) {
|
| 130 |
+
// 创建一个滤镜使图像黑白化并变淡
|
| 131 |
+
const defs = svg.append("defs");
|
| 132 |
+
|
| 133 |
+
// 添加淡灰色滤镜 - 修改参数使颜色变淡
|
| 134 |
+
const lightGrayFilter = defs.append("filter")
|
| 135 |
+
.attr("id", "lightgray");
|
| 136 |
+
|
| 137 |
+
// 先转为灰度
|
| 138 |
+
lightGrayFilter.append("feColorMatrix")
|
| 139 |
+
.attr("type", "matrix")
|
| 140 |
+
.attr("values", "0.3333 0.3333 0.3333 0 0 0.3333 0.3333 0.3333 0 0 0.3333 0.3333 0.3333 0 0 0 0 0 1 0");
|
| 141 |
+
|
| 142 |
+
// 再提亮颜色 - 使用亮度组件
|
| 143 |
+
lightGrayFilter.append("feComponentTransfer")
|
| 144 |
+
.append("feFuncR")
|
| 145 |
+
.attr("type", "linear")
|
| 146 |
+
.attr("slope", "0.6")
|
| 147 |
+
.attr("intercept", "0.4");
|
| 148 |
+
|
| 149 |
+
lightGrayFilter.append("feComponentTransfer")
|
| 150 |
+
.append("feFuncG")
|
| 151 |
+
.attr("type", "linear")
|
| 152 |
+
.attr("slope", "0.6")
|
| 153 |
+
.attr("intercept", "0.4");
|
| 154 |
+
|
| 155 |
+
lightGrayFilter.append("feComponentTransfer")
|
| 156 |
+
.append("feFuncB")
|
| 157 |
+
.attr("type", "linear")
|
| 158 |
+
.attr("slope", "0.6")
|
| 159 |
+
.attr("intercept", "0.4");
|
| 160 |
+
|
| 161 |
+
// 计算每个组的线条中心位置
|
| 162 |
+
const groupCenters = new Map();
|
| 163 |
+
|
| 164 |
+
// 按组分组数据
|
| 165 |
+
const groupedData = d3.group(chartData, d => d[groupField]);
|
| 166 |
+
|
| 167 |
+
groupedData.forEach((values, group) => {
|
| 168 |
+
// 确保数据按日期排序
|
| 169 |
+
values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 170 |
+
|
| 171 |
+
// 计算该组线条的中心点
|
| 172 |
+
const xPoints = values.map(d => xScale(chartUtils.format.parseDate(d[xField])));
|
| 173 |
+
const yPoints = values.map(d => yScale(+d[yField]));
|
| 174 |
+
|
| 175 |
+
// 找到线条的中间点
|
| 176 |
+
const midIndex = Math.floor(values.length / 2);
|
| 177 |
+
const centerX = xPoints[midIndex];
|
| 178 |
+
const centerY = yPoints[midIndex];
|
| 179 |
+
|
| 180 |
+
// 存储中心点
|
| 181 |
+
groupCenters.set(group, { x: centerX, y: centerY });
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
// 添加组图标水印 - 均匀分布在X轴上,Y位置对应X位置处的线条值
|
| 185 |
+
groups.forEach((group, groupIndex) => {
|
| 186 |
+
if (images.field[group]) {
|
| 187 |
+
const values = groupedData.get(group);
|
| 188 |
+
if (!values || values.length === 0) return;
|
| 189 |
+
|
| 190 |
+
// 确保数据按日期排序
|
| 191 |
+
values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 192 |
+
|
| 193 |
+
const iconSize = 120;
|
| 194 |
+
|
| 195 |
+
// 计算X轴位置 - 将X轴均匀分成组数量的区域,但避开边缘
|
| 196 |
+
// 使用更窄的区域,避开起止点标签
|
| 197 |
+
const usableWidth = chartWidth * 0.7; // 使用70%的图表宽度
|
| 198 |
+
const margin = (chartWidth - usableWidth) / 2; // 两侧边距
|
| 199 |
+
|
| 200 |
+
const sectionWidth = usableWidth / groups.length;
|
| 201 |
+
const xPos = margin + sectionWidth * (groupIndex + 0.5); // 区域中心点
|
| 202 |
+
|
| 203 |
+
// 找到最接近xPos的数据点
|
| 204 |
+
// 首先将xPos转换回日期域
|
| 205 |
+
const xDate = xScale.invert(xPos);
|
| 206 |
+
|
| 207 |
+
// 找到最接近该日期的数据点
|
| 208 |
+
let closestPoint = values[0];
|
| 209 |
+
let minDistance = Math.abs(chartUtils.format.parseDate(closestPoint[xField]) - xDate);
|
| 210 |
+
|
| 211 |
+
for (let i = 1; i < values.length; i++) {
|
| 212 |
+
const distance = Math.abs(chartUtils.format.parseDate(values[i][xField]) - xDate);
|
| 213 |
+
if (distance < minDistance) {
|
| 214 |
+
minDistance = distance;
|
| 215 |
+
closestPoint = values[i];
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// 使用最接近点的Y值
|
| 220 |
+
const yPos = yScale(closestPoint[yField]);
|
| 221 |
+
|
| 222 |
+
const watermark = g.append("image")
|
| 223 |
+
.attr("x", xPos - iconSize / 2) // 水印中心与区域中心对齐
|
| 224 |
+
.attr("y", yPos - iconSize / 2) // 使用对应X位置处的Y值
|
| 225 |
+
.attr("width", iconSize)
|
| 226 |
+
.attr("height", iconSize)
|
| 227 |
+
.attr("href", images.field[group])
|
| 228 |
+
.attr("opacity", 1) // 保持完全不透明
|
| 229 |
+
.attr("preserveAspectRatio", "xMidYMid meet")
|
| 230 |
+
.attr("filter", "url(#lightgray)"); // 使用淡灰色滤镜
|
| 231 |
+
|
| 232 |
+
// 确保水印在条纹背景上方,线条下方
|
| 233 |
+
watermark.lower();
|
| 234 |
+
g.selectAll("rect").lower();
|
| 235 |
+
}
|
| 236 |
+
});
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// 添加水平网格线
|
| 240 |
+
yTicks.forEach(tick => {
|
| 241 |
+
g.append("line")
|
| 242 |
+
.attr("x1", 0)
|
| 243 |
+
.attr("y1", yScale(tick))
|
| 244 |
+
.attr("x2", chartWidth)
|
| 245 |
+
.attr("y2", yScale(tick))
|
| 246 |
+
.attr("class", "background")
|
| 247 |
+
.attr("stroke", "#e0e0e0")
|
| 248 |
+
.attr("stroke-width", 1)
|
| 249 |
+
.attr("stroke-dasharray", "2,2");
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
// 按组分组数据
|
| 253 |
+
const groupedData = d3.group(chartData, d => d[groupField]);
|
| 254 |
+
|
| 255 |
+
// 定义线条粗细
|
| 256 |
+
const lineWidth = 4;
|
| 257 |
+
|
| 258 |
+
// 创建线条生成器
|
| 259 |
+
const line = d3.line()
|
| 260 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 261 |
+
.y(d => yScale(+d[yField]))
|
| 262 |
+
.curve(d3.curveLinear);
|
| 263 |
+
|
| 264 |
+
// 绘制每个组的线条
|
| 265 |
+
groupedData.forEach((values, group) => {
|
| 266 |
+
// 确保数据按日期排序
|
| 267 |
+
values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 268 |
+
|
| 269 |
+
const color = colorScale(group);
|
| 270 |
+
|
| 271 |
+
// 绘制线条
|
| 272 |
+
g.append("path")
|
| 273 |
+
.datum(values)
|
| 274 |
+
.attr("fill", "none")
|
| 275 |
+
.attr("stroke", color)
|
| 276 |
+
.attr("stroke-width", lineWidth)
|
| 277 |
+
.attr("d", line);
|
| 278 |
+
|
| 279 |
+
// 添加数据点 - 根据是否为起止点使用不同样式
|
| 280 |
+
values.forEach((d, i) => {
|
| 281 |
+
const isEndpoint = i === 0 || i === values.length - 1;
|
| 282 |
+
|
| 283 |
+
if (isEndpoint) {
|
| 284 |
+
// 起止点:白色填充,带有颜色描边
|
| 285 |
+
g.append("circle")
|
| 286 |
+
.attr("cx", xScale(chartUtils.format.parseDate(d[xField])))
|
| 287 |
+
.attr("cy", yScale(+d[yField]))
|
| 288 |
+
.attr("r", lineWidth * 1.2)
|
| 289 |
+
.attr("fill", "#fff")
|
| 290 |
+
.attr("stroke", color)
|
| 291 |
+
.attr("stroke-width", lineWidth);
|
| 292 |
+
} else {
|
| 293 |
+
// 中间点:实心颜色填充,无描边
|
| 294 |
+
g.append("circle")
|
| 295 |
+
.attr("cx", xScale(chartUtils.format.parseDate(d[xField])))
|
| 296 |
+
.attr("cy", yScale(+d[yField]))
|
| 297 |
+
.attr("r", lineWidth)
|
| 298 |
+
.attr("fill", color)
|
| 299 |
+
.attr("stroke", "none");
|
| 300 |
+
}
|
| 301 |
+
});
|
| 302 |
+
|
| 303 |
+
// 添加起点和终点标注 - 简化为直接文本
|
| 304 |
+
const firstPoint = values[0];
|
| 305 |
+
const lastPoint = values[values.length - 1];
|
| 306 |
+
|
| 307 |
+
// 添加起点标注
|
| 308 |
+
addDataLabel(firstPoint, true);
|
| 309 |
+
|
| 310 |
+
// 添加终点标注
|
| 311 |
+
addDataLabel(lastPoint, false);
|
| 312 |
+
});
|
| 313 |
+
|
| 314 |
+
// 添加X轴文本 - 放置在条纹背景的中间
|
| 315 |
+
for (let i = 0; i < xTicks.length - 1; i++) {
|
| 316 |
+
// 获取相邻两个刻度
|
| 317 |
+
const currentTick = xTicks[i];
|
| 318 |
+
const nextTick = xTicks[i + 1];
|
| 319 |
+
|
| 320 |
+
// 计算当前刻度和下一个刻度的位置
|
| 321 |
+
const x1 = xScale(currentTick);
|
| 322 |
+
const x2 = xScale(nextTick);
|
| 323 |
+
|
| 324 |
+
// 计算中点位置
|
| 325 |
+
const midX = (x1 + x2) / 2;
|
| 326 |
+
|
| 327 |
+
g.append("text")
|
| 328 |
+
.attr("x", midX)
|
| 329 |
+
.attr("y", chartHeight + 20)
|
| 330 |
+
.attr("text-anchor", "middle")
|
| 331 |
+
.attr("fill", "#666")
|
| 332 |
+
.style("font-size", "12px")
|
| 333 |
+
.text(xFormat(currentTick));
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
// 添加Y轴文本
|
| 338 |
+
yTicks.forEach(tick => {
|
| 339 |
+
g.append("text")
|
| 340 |
+
.attr("x", -20)
|
| 341 |
+
.attr("y", yScale(tick))
|
| 342 |
+
.attr("text-anchor", "end")
|
| 343 |
+
.attr("dominant-baseline", "middle")
|
| 344 |
+
.attr("fill", "#666")
|
| 345 |
+
.style("font-size", "12px")
|
| 346 |
+
.text(chartUtils.format.fixed(tick).text);
|
| 347 |
+
});
|
| 348 |
+
|
| 349 |
+
// 添加图例 - 整体居中,放在最大Y轴刻度上方
|
| 350 |
+
const legendGroup = g.append("g");
|
| 351 |
+
|
| 352 |
+
const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
|
| 353 |
+
x: 0,
|
| 354 |
+
y: 0,
|
| 355 |
+
fontSize: 14,
|
| 356 |
+
fontWeight: "bold",
|
| 357 |
+
align: "left",
|
| 358 |
+
maxWidth: chartWidth,
|
| 359 |
+
shape: "circle",
|
| 360 |
+
});
|
| 361 |
+
|
| 362 |
+
// 居中legend
|
| 363 |
+
legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
|
| 364 |
+
|
| 365 |
+
// 添加数据标注函数 - 文本放在线条上方,不加粗
|
| 366 |
+
function addDataLabel(point, isStart) {
|
| 367 |
+
const x = xScale(chartUtils.format.parseDate(point[xField]));
|
| 368 |
+
const y = yScale(+point[yField]);
|
| 369 |
+
|
| 370 |
+
// 添加文本 - 放在数据点上方,使用黑色
|
| 371 |
+
g.append("text")
|
| 372 |
+
.attr("x", x + (isStart ? -10 : 10))
|
| 373 |
+
.attr("y", y) // 放在数据点上方
|
| 374 |
+
.attr("text-anchor", isStart ? "end" : "start")
|
| 375 |
+
.attr("dominant-baseline", "middle")
|
| 376 |
+
.attr("fill", "#000") // 黑色文本
|
| 377 |
+
.attr("font-weight", "normal") // 移除加粗
|
| 378 |
+
.style("font-size", "12px")
|
| 379 |
+
.text(chartUtils.format.fixed(+point[yField], 2).text);
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
return svg.node();
|
| 383 |
+
}
|
modules/chart_engine/template/d3-js/type25_line_graph/line_graph_plain_chart_02.js
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Line Graph",
|
| 5 |
+
"chart_name": "line_graph_plain_chart_02",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[3, 30], ["-inf", "inf"], [2, 6]],
|
| 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": 800,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
// 提取数据
|
| 27 |
+
const jsonData = data;
|
| 28 |
+
const chartData = jsonData.data.data;
|
| 29 |
+
const variables = jsonData.variables;
|
| 30 |
+
const typography = jsonData.typography;
|
| 31 |
+
const colors = jsonData.colors || {};
|
| 32 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 33 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 34 |
+
const images = jsonData.images || {};
|
| 35 |
+
|
| 36 |
+
// 清空容器
|
| 37 |
+
d3.select(containerSelector).html("");
|
| 38 |
+
|
| 39 |
+
// 获取字段名
|
| 40 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 41 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 42 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 43 |
+
|
| 44 |
+
// 设置尺寸和边距
|
| 45 |
+
const width = variables.width;
|
| 46 |
+
const height = variables.height;
|
| 47 |
+
const margin = { top: 60, right: 30, bottom: 60, left: 60 };
|
| 48 |
+
|
| 49 |
+
// 创建SVG
|
| 50 |
+
const svg = d3.select(containerSelector)
|
| 51 |
+
.append("svg")
|
| 52 |
+
.attr("width", "100%")
|
| 53 |
+
.attr("height", height)
|
| 54 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 55 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 56 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 57 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 58 |
+
|
| 59 |
+
// 创建图表区域
|
| 60 |
+
const chartWidth = width - margin.left - margin.right;
|
| 61 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 62 |
+
|
| 63 |
+
const g = svg.append("g")
|
| 64 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 65 |
+
|
| 66 |
+
// 获取唯一的组值
|
| 67 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 68 |
+
|
| 69 |
+
// 按组分组数据
|
| 70 |
+
const groupedData = d3.group(chartData, d => d[groupField]);
|
| 71 |
+
|
| 72 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
|
| 73 |
+
|
| 74 |
+
// 创建y轴比例尺 - 使用数据的实际范围
|
| 75 |
+
const yMin = d3.min(chartData, d => +d[yField]);
|
| 76 |
+
const yMax = d3.max(chartData, d => +d[yField]);
|
| 77 |
+
|
| 78 |
+
// 为了美观,稍微扩展Y轴范围
|
| 79 |
+
const yPadding = (yMax - yMin) * 0.3;
|
| 80 |
+
const yDomainMax = yMax + yPadding;
|
| 81 |
+
const yDomainMin = Math.min(0, yMin - yPadding);
|
| 82 |
+
|
| 83 |
+
const yScale = d3.scaleLinear()
|
| 84 |
+
.domain([yDomainMin, yDomainMax])
|
| 85 |
+
.range([chartHeight, 0]);
|
| 86 |
+
|
| 87 |
+
// 创建颜色比例尺 - 使用提供的颜色或默认颜色
|
| 88 |
+
const colorScale = d => colorResolver.field(d, groups.indexOf(d), { palette: "category10" }).value;
|
| 89 |
+
|
| 90 |
+
// 获取实际的Y轴刻度
|
| 91 |
+
const yTicks = yScale.ticks(5);
|
| 92 |
+
|
| 93 |
+
// 添加水平网格线 - 包括刻度之间的额外网格线
|
| 94 |
+
// 首先添加主要刻度的网格线
|
| 95 |
+
yTicks.forEach(tick => {
|
| 96 |
+
g.append("line")
|
| 97 |
+
.attr("x1", 0)
|
| 98 |
+
.attr("y1", yScale(tick))
|
| 99 |
+
.attr("x2", chartWidth)
|
| 100 |
+
.attr("y2", yScale(tick))
|
| 101 |
+
.attr("stroke", "#dddddd")
|
| 102 |
+
.attr("stroke-width", 1)
|
| 103 |
+
.attr("class", "background");
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
// 添加刻度之间的额外网格线
|
| 107 |
+
if (yTicks.length > 1) {
|
| 108 |
+
for (let i = 0; i < yTicks.length - 1; i++) {
|
| 109 |
+
const currentTick = yTicks[i];
|
| 110 |
+
const nextTick = yTicks[i + 1];
|
| 111 |
+
const midValue = (currentTick + nextTick) / 2;
|
| 112 |
+
|
| 113 |
+
g.append("line")
|
| 114 |
+
.attr("x1", 0)
|
| 115 |
+
.attr("y1", yScale(midValue))
|
| 116 |
+
.attr("x2", chartWidth)
|
| 117 |
+
.attr("y2", yScale(midValue))
|
| 118 |
+
.attr("stroke", "#dddddd")
|
| 119 |
+
.attr("stroke-width", 1)
|
| 120 |
+
.attr("class", "background");
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// 添加Y轴刻度文本 - 保留0位小数,不带百分号
|
| 125 |
+
yTicks.forEach(tick => {
|
| 126 |
+
g.append("text")
|
| 127 |
+
.attr("x", -10)
|
| 128 |
+
.attr("y", yScale(tick))
|
| 129 |
+
.attr("text-anchor", "end")
|
| 130 |
+
.attr("dominant-baseline", "middle")
|
| 131 |
+
.attr("fill", "#666")
|
| 132 |
+
.style("font-size", "14px")
|
| 133 |
+
.text(chartUtils.format.integer(tick).text); // 四舍五入到整数,不带百分号
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
// 添加X轴刻度文本
|
| 137 |
+
xTicks.forEach(tick => {
|
| 138 |
+
|
| 139 |
+
g.append("text")
|
| 140 |
+
.attr("x", xScale(tick))
|
| 141 |
+
.attr("y", chartHeight + 20)
|
| 142 |
+
.attr("text-anchor", "middle")
|
| 143 |
+
.attr("fill", "#666")
|
| 144 |
+
.style("font-size", "14px")
|
| 145 |
+
.text(xFormat(tick));
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
// 添加X轴线
|
| 149 |
+
g.append("line")
|
| 150 |
+
.attr("x1", 0)
|
| 151 |
+
.attr("y1", chartHeight)
|
| 152 |
+
.attr("x2", chartWidth)
|
| 153 |
+
.attr("y2", chartHeight)
|
| 154 |
+
.attr("stroke", "#aaa")
|
| 155 |
+
.attr("stroke-width", 1);
|
| 156 |
+
|
| 157 |
+
// 定义线条粗细
|
| 158 |
+
const lineWidth = 4;
|
| 159 |
+
|
| 160 |
+
// 创建线条生成器
|
| 161 |
+
const line = d3.line()
|
| 162 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 163 |
+
.y(d => yScale(+d[yField]))
|
| 164 |
+
.curve(d3.curveLinear); // 改为折线
|
| 165 |
+
|
| 166 |
+
// 收集需要标注的数据点 - 按起点/终点/中点分类
|
| 167 |
+
const startPoints = [];
|
| 168 |
+
const middlePoints = [];
|
| 169 |
+
const endPoints = [];
|
| 170 |
+
|
| 171 |
+
groupedData.forEach((values, group) => {
|
| 172 |
+
// 确保数据按日期排序
|
| 173 |
+
values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 174 |
+
|
| 175 |
+
const color = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 176 |
+
|
| 177 |
+
// 绘制线条
|
| 178 |
+
g.append("path")
|
| 179 |
+
.datum(values)
|
| 180 |
+
.attr("fill", "none")
|
| 181 |
+
.attr("stroke", color)
|
| 182 |
+
.attr("stroke-width", lineWidth)
|
| 183 |
+
.attr("d", line);
|
| 184 |
+
|
| 185 |
+
// 找出中间的一个X位置
|
| 186 |
+
const middleIndex = Math.floor(values.length / 2);
|
| 187 |
+
|
| 188 |
+
// 收集需要标注的点
|
| 189 |
+
values.forEach((d, i) => {
|
| 190 |
+
if (i === 0) {
|
| 191 |
+
// 起点
|
| 192 |
+
startPoints.push({
|
| 193 |
+
x: xScale(chartUtils.format.parseDate(d[xField])),
|
| 194 |
+
y: yScale(+d[yField]),
|
| 195 |
+
value: d[yField],
|
| 196 |
+
color: color,
|
| 197 |
+
group: group,
|
| 198 |
+
point: d
|
| 199 |
+
});
|
| 200 |
+
} else if (i === values.length - 1) {
|
| 201 |
+
// 终点
|
| 202 |
+
endPoints.push({
|
| 203 |
+
x: xScale(chartUtils.format.parseDate(d[xField])),
|
| 204 |
+
y: yScale(+d[yField]),
|
| 205 |
+
value: d[yField],
|
| 206 |
+
color: color,
|
| 207 |
+
group: group,
|
| 208 |
+
point: d
|
| 209 |
+
});
|
| 210 |
+
} else if (i === middleIndex) {
|
| 211 |
+
// 中点
|
| 212 |
+
middlePoints.push({
|
| 213 |
+
x: xScale(chartUtils.format.parseDate(d[xField])),
|
| 214 |
+
y: yScale(+d[yField]),
|
| 215 |
+
value: d[yField],
|
| 216 |
+
color: color,
|
| 217 |
+
group: group,
|
| 218 |
+
point: d
|
| 219 |
+
});
|
| 220 |
+
}
|
| 221 |
+
});
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
// 分别对起点、中点和终点应用动态规划 - 添加g参数
|
| 225 |
+
const startLabelPositions = placeLabelsDP(startPoints, yTicks.map(tick => yScale(tick)), "起点", chartHeight, chartWidth, g);
|
| 226 |
+
const middleLabelPositions = placeLabelsDP(middlePoints, yTicks.map(tick => yScale(tick)), "中点", chartHeight, chartWidth, g);
|
| 227 |
+
const endLabelPositions = placeLabelsDP(endPoints, yTicks.map(tick => yScale(tick)), "终点", chartHeight, chartWidth, g);
|
| 228 |
+
|
| 229 |
+
// 绘制标签函数 - 保持multiple_line_graph_10的样式
|
| 230 |
+
function drawLabels(labelPositions) {
|
| 231 |
+
labelPositions.forEach(placement => {
|
| 232 |
+
const point = placement.point;
|
| 233 |
+
const labelY = placement.labelY;
|
| 234 |
+
|
| 235 |
+
// 计算标签文本和宽度
|
| 236 |
+
const labelText = chartUtils.format.autoText(point.value);
|
| 237 |
+
const labelWidth = chartUtils.text.measure(null, labelText, { fontSize: 14, fontWeight: "bold" }).width + 10;
|
| 238 |
+
const labelHeight = 24;
|
| 239 |
+
|
| 240 |
+
// 添加圆角矩形背景
|
| 241 |
+
g.append("rect")
|
| 242 |
+
.attr("x", point.x - labelWidth / 2)
|
| 243 |
+
.attr("y", labelY + labelHeight / 2)
|
| 244 |
+
.attr("width", labelWidth)
|
| 245 |
+
.attr("height", labelHeight)
|
| 246 |
+
.attr("rx", 5)
|
| 247 |
+
.attr("ry", 5)
|
| 248 |
+
.attr("fill", point.color);
|
| 249 |
+
|
| 250 |
+
// 添加文本 - 白色粗体
|
| 251 |
+
g.append("text")
|
| 252 |
+
.attr("x", point.x)
|
| 253 |
+
.attr("y", labelY + labelHeight)
|
| 254 |
+
.attr("text-anchor", "middle")
|
| 255 |
+
.attr("dominant-baseline", "middle")
|
| 256 |
+
.attr("fill", "#fff")
|
| 257 |
+
.attr("font-weight", "bold")
|
| 258 |
+
.style("font-size", "14px")
|
| 259 |
+
.text(labelText);
|
| 260 |
+
});
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// 绘制所有标签
|
| 264 |
+
drawLabels(startLabelPositions);
|
| 265 |
+
drawLabels(middleLabelPositions);
|
| 266 |
+
drawLabels(endLabelPositions);
|
| 267 |
+
|
| 268 |
+
// 添加图例 - 整体居中,向上移动
|
| 269 |
+
const legendGroup = g.append("g");
|
| 270 |
+
|
| 271 |
+
const legendSize = chartUtils.legend.draw(legendGroup, groups, colors, {
|
| 272 |
+
x: 0,
|
| 273 |
+
y: 0,
|
| 274 |
+
fontSize: 14,
|
| 275 |
+
fontWeight: "bold",
|
| 276 |
+
align: "left",
|
| 277 |
+
maxWidth: chartWidth,
|
| 278 |
+
shape: "line",
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
const maxYTickPosition = yScale(yTicks[yTicks.length - 1]);
|
| 282 |
+
|
| 283 |
+
// 居中legend
|
| 284 |
+
legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
|
| 285 |
+
|
| 286 |
+
return svg.node();
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
// 动态规划标签放置算法 - 添加g参数
|
| 290 |
+
function placeLabelsDP(points, avoidYPositions = [], debugName = "", chartHeight, chartWidth, g) {
|
| 291 |
+
// 每个格点的高度(像素)
|
| 292 |
+
const GRID_SIZE = 3;
|
| 293 |
+
// 圆点周围的保护区域(格点数)
|
| 294 |
+
const PROTECTION_RADIUS = 3;
|
| 295 |
+
// 标签高度(格点数)
|
| 296 |
+
const LABEL_HEIGHT = 10;
|
| 297 |
+
|
| 298 |
+
// 离散化Y坐标,创建格点系统
|
| 299 |
+
const minY = 0;
|
| 300 |
+
const maxY = chartHeight; // 使用传入的chartHeight
|
| 301 |
+
const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
|
| 302 |
+
|
| 303 |
+
// 按照Y坐标排序点
|
| 304 |
+
points.sort((a, b) => a.y - b.y);
|
| 305 |
+
|
| 306 |
+
// 创建格点占用标记
|
| 307 |
+
const occupied = new Array(gridCount).fill(false);
|
| 308 |
+
// 存储占用原因,用于可视化
|
| 309 |
+
const occupiedReason = new Array(gridCount).fill("");
|
| 310 |
+
|
| 311 |
+
// 标记圆点周围的保护区域为已占用
|
| 312 |
+
points.forEach((point, idx) => {
|
| 313 |
+
const gridY = Math.floor(point.y / GRID_SIZE) - 3;
|
| 314 |
+
for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
|
| 315 |
+
occupied[i] = true;
|
| 316 |
+
occupiedReason[i] = `数据点${idx + 1}保护区`;
|
| 317 |
+
}
|
| 318 |
+
});
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
// 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
|
| 322 |
+
const n = points.length;
|
| 323 |
+
const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
|
| 324 |
+
const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
|
| 325 |
+
|
| 326 |
+
// 初始条件:第一个点的标签放置
|
| 327 |
+
const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
|
| 328 |
+
|
| 329 |
+
// 尝试将第一个点的标签放在可行的位置
|
| 330 |
+
for (let j = 0; j < gridCount; j++) {
|
| 331 |
+
// 检查是否可行(不在保护区域内且有足够空间放置标签)
|
| 332 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 333 |
+
// 检查标签是否会与其他标签重叠
|
| 334 |
+
let canPlace = true;
|
| 335 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 336 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 337 |
+
canPlace = false;
|
| 338 |
+
break;
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
if (canPlace) {
|
| 343 |
+
// 计算代价:与圆点位置的距离
|
| 344 |
+
const cost = Math.abs(j - firstPointGridY);
|
| 345 |
+
dp[0][j] = cost;
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
// 填充dp表
|
| 351 |
+
for (let i = 1; i < n; i++) {
|
| 352 |
+
const pointGridY = Math.floor(points[i].y / GRID_SIZE);
|
| 353 |
+
|
| 354 |
+
for (let j = 0; j < gridCount; j++) {
|
| 355 |
+
// 检查当前位置是否可行
|
| 356 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 357 |
+
// 检查标签是否会与其他标签重叠
|
| 358 |
+
let canPlace = true;
|
| 359 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 360 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 361 |
+
canPlace = false;
|
| 362 |
+
break;
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
if (canPlace) {
|
| 367 |
+
// 根据上一个点的标签位置计算当前最小代价
|
| 368 |
+
for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
|
| 369 |
+
if (dp[i-1][k] !== Infinity) {
|
| 370 |
+
|
| 371 |
+
// 当前标签的代价
|
| 372 |
+
const curCost = Math.abs(j - pointGridY);
|
| 373 |
+
const totalCost = dp[i-1][k] + curCost;
|
| 374 |
+
|
| 375 |
+
if (totalCost < dp[i][j]) {
|
| 376 |
+
dp[i][j] = totalCost;
|
| 377 |
+
prev[i][j] = k; // 记录前驱
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
// 找出最后一个点的最优放置位置
|
| 387 |
+
let minCost = Infinity;
|
| 388 |
+
let bestPos = -1;
|
| 389 |
+
|
| 390 |
+
for (let j = 0; j < gridCount; j++) {
|
| 391 |
+
if (dp[n-1][j] < minCost) {
|
| 392 |
+
minCost = dp[n-1][j];
|
| 393 |
+
bestPos = j;
|
| 394 |
+
}
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// 存储最终选择的标签位置(格点索引)
|
| 398 |
+
const selectedGridPositions = [];
|
| 399 |
+
|
| 400 |
+
// 回溯构建结果
|
| 401 |
+
const labelPositions = [];
|
| 402 |
+
if (bestPos !== -1) {
|
| 403 |
+
// 从后向前回溯
|
| 404 |
+
let pos = bestPos;
|
| 405 |
+
|
| 406 |
+
for (let i = n - 1; i >= 0; i--) {
|
| 407 |
+
labelPositions.unshift({
|
| 408 |
+
point: points[i],
|
| 409 |
+
labelY: pos * GRID_SIZE
|
| 410 |
+
});
|
| 411 |
+
|
| 412 |
+
// 记录选择的位置
|
| 413 |
+
selectedGridPositions.unshift(pos);
|
| 414 |
+
|
| 415 |
+
pos = prev[i][pos];
|
| 416 |
+
}
|
| 417 |
+
} else {
|
| 418 |
+
// 无可行解,退化为简单方案处理
|
| 419 |
+
let lastY = 0;
|
| 420 |
+
for (let i = 0; i < n; i++) {
|
| 421 |
+
const point = points[i];
|
| 422 |
+
// 确保标签不超过下一个圆点的位置
|
| 423 |
+
let maxY = chartHeight;
|
| 424 |
+
if (i < n - 1) {
|
| 425 |
+
maxY = points[i+1].y;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
|
| 429 |
+
|
| 430 |
+
labelPositions.push({
|
| 431 |
+
point: point,
|
| 432 |
+
labelY: labelY
|
| 433 |
+
});
|
| 434 |
+
|
| 435 |
+
lastY = labelY;
|
| 436 |
+
}
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
const debug = false;
|
| 440 |
+
|
| 441 |
+
// 添加格子系统可视化
|
| 442 |
+
if (debug && points.length > 0) {
|
| 443 |
+
// 确定可视化的位置 - 使用数据点的X位置
|
| 444 |
+
const mainPointX = points.length > 0 ? points[0].x : 0; // 使用该组第一个点的X位置
|
| 445 |
+
const visX = mainPointX;
|
| 446 |
+
const visY = 10; // 从图表顶部开始
|
| 447 |
+
const visWidth = 50; // 减小宽度以免干扰图表
|
| 448 |
+
|
| 449 |
+
// 创建一个组用于添加可视化元素,并在需要时应用剪切
|
| 450 |
+
const visGroup = g.append("g");
|
| 451 |
+
|
| 452 |
+
// 限制可视化高度,避免太长
|
| 453 |
+
const maxVisHeight = chartHeight - 20;
|
| 454 |
+
const visibleGridCount = Math.min(gridCount, Math.floor(maxVisHeight / GRID_SIZE));
|
| 455 |
+
|
| 456 |
+
// 添加半透明背景
|
| 457 |
+
visGroup.append("rect")
|
| 458 |
+
.attr("x", visX - 5)
|
| 459 |
+
.attr("y", visY - 15)
|
| 460 |
+
.attr("width", visWidth + 10)
|
| 461 |
+
.attr("height", visibleGridCount * GRID_SIZE + 50)
|
| 462 |
+
.attr("fill", "#fff")
|
| 463 |
+
.attr("opacity", 0.8)
|
| 464 |
+
.attr("rx", 5);
|
| 465 |
+
|
| 466 |
+
// 添加标题
|
| 467 |
+
visGroup.append("text")
|
| 468 |
+
.attr("x", visX + visWidth/2)
|
| 469 |
+
.attr("y", visY - 5)
|
| 470 |
+
.attr("text-anchor", "middle")
|
| 471 |
+
.attr("fill", "#333")
|
| 472 |
+
.style("font-size", "10px")
|
| 473 |
+
.text(debugName + "标签位置");
|
| 474 |
+
|
| 475 |
+
// 绘制每个格子
|
| 476 |
+
for (let i = 0; i < visibleGridCount; i++) {
|
| 477 |
+
// 计算格子的颜色
|
| 478 |
+
let fillColor = "#ddd"; // 默认颜色 - 可用格子
|
| 479 |
+
|
| 480 |
+
if (occupied[i]) {
|
| 481 |
+
fillColor = "#ffcccc"; // 不可用格子 - 红色
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
// 标记最终选择的位置
|
| 485 |
+
const isSelected = selectedGridPositions.some(pos => {
|
| 486 |
+
// 检查格子是否在标签范围内
|
| 487 |
+
for (let j = 0; j < LABEL_HEIGHT; j++) {
|
| 488 |
+
if (pos + j === i) return true;
|
| 489 |
+
}
|
| 490 |
+
return false;
|
| 491 |
+
});
|
| 492 |
+
|
| 493 |
+
if (isSelected) {
|
| 494 |
+
fillColor = "#99ff99"; // 选中的格子 - 绿色
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
// 绘制格子
|
| 498 |
+
visGroup.append("rect")
|
| 499 |
+
.attr("x", visX)
|
| 500 |
+
.attr("y", visY + i * GRID_SIZE)
|
| 501 |
+
.attr("width", visWidth)
|
| 502 |
+
.attr("height", GRID_SIZE)
|
| 503 |
+
.attr("fill", fillColor)
|
| 504 |
+
.attr("stroke", "#ccc")
|
| 505 |
+
.attr("stroke-width", 0.2);
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
// 添加数据点位置标记 - 只显示该组的点
|
| 509 |
+
points.forEach((point, idx) => {
|
| 510 |
+
const gridY = Math.floor(point.y / GRID_SIZE);
|
| 511 |
+
|
| 512 |
+
// 仅当点位置在可视范围内时添加
|
| 513 |
+
if (gridY < visibleGridCount) {
|
| 514 |
+
// 绘制数据点位置线
|
| 515 |
+
visGroup.append("line")
|
| 516 |
+
.attr("x1", visX - 3)
|
| 517 |
+
.attr("y1", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 518 |
+
.attr("x2", visX + visWidth)
|
| 519 |
+
.attr("y2", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 520 |
+
.attr("stroke", point.color)
|
| 521 |
+
.attr("stroke-width", 1)
|
| 522 |
+
.attr("stroke-dasharray", "2,1");
|
| 523 |
+
|
| 524 |
+
// 添加简短的数据点标识
|
| 525 |
+
visGroup.append("text")
|
| 526 |
+
.attr("x", visX - 5)
|
| 527 |
+
.attr("y", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 528 |
+
.attr("text-anchor", "end")
|
| 529 |
+
.attr("dominant-baseline", "middle")
|
| 530 |
+
.attr("fill", point.color)
|
| 531 |
+
.style("font-size", "8px")
|
| 532 |
+
.text("点" + (idx + 1));
|
| 533 |
+
}
|
| 534 |
+
});
|
| 535 |
+
|
| 536 |
+
// 在底部添加简化的图例
|
| 537 |
+
const legendY = visY + visibleGridCount * GRID_SIZE + 5;
|
| 538 |
+
const legendItems = [
|
| 539 |
+
{ label: "可用", color: "#ddd" },
|
| 540 |
+
{ label: "占用", color: "#ffcccc" },
|
| 541 |
+
{ label: "选中", color: "#99ff99" }
|
| 542 |
+
];
|
| 543 |
+
|
| 544 |
+
legendItems.forEach((item, idx) => {
|
| 545 |
+
// 水平放置图例项
|
| 546 |
+
const itemX = visX + idx * (visWidth / 3);
|
| 547 |
+
|
| 548 |
+
visGroup.append("rect")
|
| 549 |
+
.attr("x", itemX)
|
| 550 |
+
.attr("y", legendY)
|
| 551 |
+
.attr("width", 6)
|
| 552 |
+
.attr("height", 6)
|
| 553 |
+
.attr("fill", item.color);
|
| 554 |
+
|
| 555 |
+
visGroup.append("text")
|
| 556 |
+
.attr("x", itemX + 8)
|
| 557 |
+
.attr("y", legendY + 3)
|
| 558 |
+
.attr("text-anchor", "start")
|
| 559 |
+
.attr("dominant-baseline", "middle")
|
| 560 |
+
.attr("fill", "#333")
|
| 561 |
+
.style("font-size", "6px")
|
| 562 |
+
.text(item.label);
|
| 563 |
+
});
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
return labelPositions;
|
| 567 |
+
}
|
modules/chart_engine/template/d3-js/type25_line_graph/line_graph_plain_chart_03.js
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Line Graph",
|
| 5 |
+
"chart_name": "line_graph_plain_chart_03",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 30], ["-inf", "inf"], [2, 7]],
|
| 9 |
+
"required_fields_icons": ["group"],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 400,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "dark",
|
| 17 |
+
"icon_mark": "side",
|
| 18 |
+
"icon_label": "side",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes"
|
| 21 |
+
}
|
| 22 |
+
REQUIREMENTS_END
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
function makeChart(containerSelector, data) {
|
| 26 |
+
const jsonData = data;
|
| 27 |
+
const chartData = jsonData.data.data;
|
| 28 |
+
const variables = jsonData.variables;
|
| 29 |
+
const typography = jsonData.typography;
|
| 30 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 31 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 32 |
+
const images = jsonData.images || {};
|
| 33 |
+
|
| 34 |
+
const sourceTitle = (jsonData.titles && jsonData.titles.main_title) || jsonData.metadata?.title || "";
|
| 35 |
+
const sourceSubtitle = (jsonData.titles && jsonData.titles.sub_title) || jsonData.metadata?.description || "";
|
| 36 |
+
const sourceTitleMatch = String(sourceTitle).match(/\s+v(?:s\.?|ersus)\s+/i);
|
| 37 |
+
if (jsonData.titles && sourceTitleMatch) {
|
| 38 |
+
const lead = sourceTitle.slice(0, sourceTitleMatch.index).trim().toUpperCase();
|
| 39 |
+
const focus = sourceTitle.slice(sourceTitleMatch.index + sourceTitleMatch[0].length).trim().toUpperCase();
|
| 40 |
+
jsonData.titles.main_title = `${lead} vs. ${focus}`;
|
| 41 |
+
jsonData.titles.sub_title = String(sourceSubtitle).replace(/^"|"$/g, "");
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
d3.select(containerSelector).html("");
|
| 45 |
+
|
| 46 |
+
const width = variables.width;
|
| 47 |
+
const height = variables.height;
|
| 48 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 49 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 50 |
+
const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
|
| 51 |
+
const yLabel = yColumn.rawLabel || yField;
|
| 52 |
+
const yUnit = yColumn.unit || "";
|
| 53 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 54 |
+
|
| 55 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 56 |
+
const parsedRows = chartData
|
| 57 |
+
.map(d => ({
|
| 58 |
+
raw: d,
|
| 59 |
+
x: chartUtils.format.parseDate(d[xField]),
|
| 60 |
+
y: +d[yField],
|
| 61 |
+
group: d[groupField]
|
| 62 |
+
}))
|
| 63 |
+
.filter(d => Number.isFinite(d.x.getTime()) && Number.isFinite(d.y));
|
| 64 |
+
|
| 65 |
+
const groupSeries = groups.map(group => {
|
| 66 |
+
const values = parsedRows
|
| 67 |
+
.filter(d => d.group === group)
|
| 68 |
+
.sort((a, b) => a.x - b.x);
|
| 69 |
+
return { group, values };
|
| 70 |
+
}).filter(series => series.values.length > 0);
|
| 71 |
+
|
| 72 |
+
const getColor = (group) => colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 73 |
+
const brighten = (hex, amount) => d3.color(hex).brighter(amount).formatHex();
|
| 74 |
+
const darken = (hex, amount) => d3.color(hex).darker(amount).formatHex();
|
| 75 |
+
const textFont = typography.label.font_family || "Arial";
|
| 76 |
+
|
| 77 |
+
const svg = d3.select(containerSelector)
|
| 78 |
+
.append("svg")
|
| 79 |
+
.attr("width", "100%")
|
| 80 |
+
.attr("height", height)
|
| 81 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 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 |
+
|
| 86 |
+
const defs = svg.append("defs");
|
| 87 |
+
|
| 88 |
+
svg.append("style").text(`
|
| 89 |
+
svg > rect:first-of-type { fill: #011222 !important; }
|
| 90 |
+
image.image[data-type="image"] { display: none !important; }
|
| 91 |
+
.text[data-type="title"] {
|
| 92 |
+
display: none !important;
|
| 93 |
+
}
|
| 94 |
+
.text[data-type="title"] text:nth-of-type(1) {
|
| 95 |
+
fill: #f7fbff !important;
|
| 96 |
+
font-weight: 900 !important;
|
| 97 |
+
letter-spacing: 0 !important;
|
| 98 |
+
}
|
| 99 |
+
.text[data-type="title"] text:nth-of-type(2) {
|
| 100 |
+
fill: #a8fbf4 !important;
|
| 101 |
+
font-weight: 900 !important;
|
| 102 |
+
letter-spacing: 0 !important;
|
| 103 |
+
text-transform: uppercase;
|
| 104 |
+
}
|
| 105 |
+
.text[data-type="title"] text:nth-of-type(3) {
|
| 106 |
+
fill: #d7e5ee !important;
|
| 107 |
+
font-weight: 500 !important;
|
| 108 |
+
}
|
| 109 |
+
.line03-internal-title { display: inline !important; }
|
| 110 |
+
`);
|
| 111 |
+
|
| 112 |
+
const bg = defs.append("radialGradient")
|
| 113 |
+
.attr("id", "line03-bg")
|
| 114 |
+
.attr("cx", "58%")
|
| 115 |
+
.attr("cy", "20%")
|
| 116 |
+
.attr("r", "90%");
|
| 117 |
+
bg.append("stop").attr("offset", "0%").attr("stop-color", "#073e62");
|
| 118 |
+
bg.append("stop").attr("offset", "52%").attr("stop-color", "#021f35");
|
| 119 |
+
bg.append("stop").attr("offset", "100%").attr("stop-color", "#011222");
|
| 120 |
+
|
| 121 |
+
const cyanGlow = defs.append("filter")
|
| 122 |
+
.attr("id", "line03-soft-glow")
|
| 123 |
+
.attr("x", "-30%")
|
| 124 |
+
.attr("y", "-30%")
|
| 125 |
+
.attr("width", "160%")
|
| 126 |
+
.attr("height", "160%");
|
| 127 |
+
cyanGlow.append("feGaussianBlur").attr("stdDeviation", 2.2).attr("result", "blur");
|
| 128 |
+
const merge = cyanGlow.append("feMerge");
|
| 129 |
+
merge.append("feMergeNode").attr("in", "blur");
|
| 130 |
+
merge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 131 |
+
|
| 132 |
+
const contentLayer = svg.append("g")
|
| 133 |
+
.attr("class", "line03-content-layer")
|
| 134 |
+
.attr("data-layout", "full-poster-composition")
|
| 135 |
+
.attr("transform", "translate(-500,-1230)");
|
| 136 |
+
|
| 137 |
+
const poster = {
|
| 138 |
+
x: -50,
|
| 139 |
+
y: -43,
|
| 140 |
+
width: width + 720,
|
| 141 |
+
height: height + 1260
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
contentLayer.append("rect")
|
| 145 |
+
.attr("class", "line03-background-extension")
|
| 146 |
+
.attr("x", poster.x)
|
| 147 |
+
.attr("y", poster.y)
|
| 148 |
+
.attr("width", poster.width)
|
| 149 |
+
.attr("height", poster.height)
|
| 150 |
+
.attr("fill", "url(#line03-bg)");
|
| 151 |
+
|
| 152 |
+
contentLayer.append("rect")
|
| 153 |
+
.attr("class", "line03-poster-vignette")
|
| 154 |
+
.attr("x", poster.x + 8)
|
| 155 |
+
.attr("y", poster.y + 8)
|
| 156 |
+
.attr("width", poster.width - 16)
|
| 157 |
+
.attr("height", poster.height - 16)
|
| 158 |
+
.attr("rx", 18)
|
| 159 |
+
.attr("fill", "rgba(4,36,62,0.20)")
|
| 160 |
+
.attr("stroke", "rgba(99,221,232,0.10)");
|
| 161 |
+
|
| 162 |
+
const titleText = (jsonData.titles && jsonData.titles.main_title) || jsonData.metadata?.title || "";
|
| 163 |
+
const subtitleText = (jsonData.titles && jsonData.titles.sub_title) || jsonData.metadata?.description || "";
|
| 164 |
+
const titleMatch = String(titleText).match(/\s+v(?:s\.?|ersus)\s+/i);
|
| 165 |
+
const titleLead = titleMatch ? titleText.slice(0, titleMatch.index).trim() : titleText;
|
| 166 |
+
const titleFocus = titleMatch ? titleText.slice(titleMatch.index + titleMatch[0].length).trim() : "";
|
| 167 |
+
const titleMaxWidth = poster.width - 110;
|
| 168 |
+
const estimateTitleSize = (text, base, min) => Math.max(min, Math.min(base, titleMaxWidth / Math.max(1, String(text).length * 0.58)));
|
| 169 |
+
const focusLine = titleFocus ? `VS. ${String(titleFocus).toUpperCase()}` : "";
|
| 170 |
+
|
| 171 |
+
contentLayer.append("text")
|
| 172 |
+
.attr("class", "line03-internal-title line03-poster-title line03-title-lead")
|
| 173 |
+
.attr("x", poster.x + 72)
|
| 174 |
+
.attr("y", -320)
|
| 175 |
+
.style("font-family", textFont)
|
| 176 |
+
.style("font-size", `${estimateTitleSize(titleLead, focusLine ? 68 : 58, focusLine ? 42 : 28)}px`)
|
| 177 |
+
.style("font-weight", 900)
|
| 178 |
+
.style("fill", "#f7fbff")
|
| 179 |
+
.style("letter-spacing", "0")
|
| 180 |
+
.text(String(titleLead).toUpperCase());
|
| 181 |
+
|
| 182 |
+
if (focusLine) {
|
| 183 |
+
contentLayer.append("text")
|
| 184 |
+
.attr("class", "line03-internal-title line03-poster-title line03-title-focus")
|
| 185 |
+
.attr("x", poster.x + 72)
|
| 186 |
+
.attr("y", -220)
|
| 187 |
+
.style("font-family", textFont)
|
| 188 |
+
.style("font-size", `${estimateTitleSize(focusLine, 96, 58)}px`)
|
| 189 |
+
.style("font-weight", 900)
|
| 190 |
+
.style("fill", "#a8fbf4")
|
| 191 |
+
.style("letter-spacing", "0")
|
| 192 |
+
.text(focusLine);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
contentLayer.append("line")
|
| 196 |
+
.attr("class", "line03-internal-title")
|
| 197 |
+
.attr("x1", poster.x + 88)
|
| 198 |
+
.attr("x2", poster.x + poster.width - 88)
|
| 199 |
+
.attr("y1", -130)
|
| 200 |
+
.attr("y2", -130)
|
| 201 |
+
.attr("stroke", "rgba(213,242,248,0.28)")
|
| 202 |
+
.attr("stroke-width", 1.2);
|
| 203 |
+
|
| 204 |
+
contentLayer.append("g")
|
| 205 |
+
.attr("class", "line03-internal-title reserved-asset-slot title-divider-slot")
|
| 206 |
+
.attr("data-asset-slot", "line03-title-divider-icon")
|
| 207 |
+
.attr("data-asset-source-policy", "neutral-placeholder")
|
| 208 |
+
.attr("data-anchor", "title-divider-center")
|
| 209 |
+
.attr("data-collision-rule", "between-title-and-subtitle-only")
|
| 210 |
+
.attr("data-bbox", `${poster.x + poster.width / 2 - 12},-142,24,24`)
|
| 211 |
+
.attr("data-no-embedded-raster", "true")
|
| 212 |
+
.attr("data-no-copied-reference-art", "true")
|
| 213 |
+
.append("circle")
|
| 214 |
+
.attr("cx", poster.x + poster.width / 2)
|
| 215 |
+
.attr("cy", -130)
|
| 216 |
+
.attr("r", 12)
|
| 217 |
+
.attr("fill", "#061f33")
|
| 218 |
+
.attr("stroke", "rgba(213,242,248,0.35)");
|
| 219 |
+
|
| 220 |
+
addWrappedText(contentLayer, String(subtitleText).replace(/^"|"$/g, ""), poster.x + poster.width / 2, -75, poster.width - 300, 27, 2, {
|
| 221 |
+
fontSize: "23px",
|
| 222 |
+
fontWeight: "500",
|
| 223 |
+
fill: "#d7e5ee",
|
| 224 |
+
textAnchor: "middle"
|
| 225 |
+
}).attr("class", "line03-internal-title line03-poster-subtitle");
|
| 226 |
+
|
| 227 |
+
const cardsTop = 65;
|
| 228 |
+
const cardGap = 26;
|
| 229 |
+
const cardWidth = (poster.width - 116 - cardGap) / 2;
|
| 230 |
+
const cardHeight = 180;
|
| 231 |
+
|
| 232 |
+
function addWrappedText(group, text, x, y, maxWidth, lineHeight, maxLines, attrs = {}) {
|
| 233 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 234 |
+
let line = [];
|
| 235 |
+
let lineNo = 0;
|
| 236 |
+
let tspan = group.append("text")
|
| 237 |
+
.attr("x", x)
|
| 238 |
+
.attr("y", y)
|
| 239 |
+
.attr("text-anchor", attrs.textAnchor || "start")
|
| 240 |
+
.style("font-family", textFont)
|
| 241 |
+
.style("font-size", attrs.fontSize || "12px")
|
| 242 |
+
.style("font-weight", attrs.fontWeight || "400")
|
| 243 |
+
.style("fill", attrs.fill || "#eaf9ff")
|
| 244 |
+
.style("letter-spacing", attrs.letterSpacing || "0");
|
| 245 |
+
|
| 246 |
+
let current = tspan.append("tspan").attr("x", x).attr("dy", 0);
|
| 247 |
+
for (const word of words) {
|
| 248 |
+
line.push(word);
|
| 249 |
+
current.text(line.join(" "));
|
| 250 |
+
if (current.node().getComputedTextLength() > maxWidth && line.length > 1) {
|
| 251 |
+
line.pop();
|
| 252 |
+
current.text(line.join(" "));
|
| 253 |
+
line = [word];
|
| 254 |
+
lineNo += 1;
|
| 255 |
+
if (lineNo >= maxLines) {
|
| 256 |
+
current.text(current.text().replace(/\s+\S*$/, "") + "...");
|
| 257 |
+
break;
|
| 258 |
+
}
|
| 259 |
+
current = tspan.append("tspan")
|
| 260 |
+
.attr("x", x)
|
| 261 |
+
.attr("dy", lineHeight)
|
| 262 |
+
.text(word);
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
return tspan;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
function endpointText(value) {
|
| 269 |
+
const abs = Math.abs(value);
|
| 270 |
+
if (String(yUnit).toLowerCase().includes("m") || String(yLabel).toLowerCase().includes("million")) {
|
| 271 |
+
const rounded = abs < 0.5 ? 0 : Math.round(value);
|
| 272 |
+
return `${rounded}M`;
|
| 273 |
+
}
|
| 274 |
+
const rounded = abs >= 10 ? Math.round(value) : Math.round(value * 10) / 10;
|
| 275 |
+
return String(rounded);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
function trendText(series) {
|
| 279 |
+
const first = series.values[0].y;
|
| 280 |
+
const last = series.values[series.values.length - 1].y;
|
| 281 |
+
const lastPhrase = Math.abs(last) < 0.5 && (String(yUnit).toLowerCase().includes("m") || String(yLabel).toLowerCase().includes("million"))
|
| 282 |
+
? "near zero"
|
| 283 |
+
: endpointText(last);
|
| 284 |
+
if (last < first * 0.65) return `Profits steadily decline, reaching ${lastPhrase} by ${series.values[series.values.length - 1].raw[xField]}.`;
|
| 285 |
+
if (last > first * 1.35) return `Profits rise consistently, reaching ${endpointText(last)} by ${series.values[series.values.length - 1].raw[xField]}.`;
|
| 286 |
+
return `Profits remain near ${endpointText(last)} by ${series.values[series.values.length - 1].raw[xField]}.`;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
function addIconSlot(parent, groupName, x, y, size) {
|
| 290 |
+
const slot = parent.append("g")
|
| 291 |
+
.attr("class", "reserved-asset-slot group-icon-slot")
|
| 292 |
+
.attr("data-asset-slot", `line03-group-icon-${String(groupName).toLowerCase().replace(/[^a-z0-9]+/g, "-")}`)
|
| 293 |
+
.attr("data-asset-source-policy", images.field && images.field[groupName] ? "input-provided" : "neutral-placeholder")
|
| 294 |
+
.attr("data-anchor", "top-metric-card")
|
| 295 |
+
.attr("data-collision-rule", "stay-inside-card-left-medallion")
|
| 296 |
+
.attr("data-bbox", `${x},${y},${size},${size}`)
|
| 297 |
+
.attr("data-no-copied-reference-art", "true");
|
| 298 |
+
|
| 299 |
+
if (!(images.field && images.field[groupName])) {
|
| 300 |
+
slot.attr("data-no-embedded-raster", "true");
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
slot.append("circle")
|
| 304 |
+
.attr("cx", x + size / 2)
|
| 305 |
+
.attr("cy", y + size / 2)
|
| 306 |
+
.attr("r", size / 2)
|
| 307 |
+
.attr("fill", "#eef8f5")
|
| 308 |
+
.attr("stroke", "rgba(255,255,255,0.75)")
|
| 309 |
+
.attr("stroke-width", 1.5);
|
| 310 |
+
|
| 311 |
+
if (images.field && images.field[groupName]) {
|
| 312 |
+
slot.append("clipPath")
|
| 313 |
+
.attr("id", `line03-icon-clip-${String(groupName).replace(/[^a-zA-Z0-9]/g, "-")}`)
|
| 314 |
+
.append("circle")
|
| 315 |
+
.attr("cx", x + size / 2)
|
| 316 |
+
.attr("cy", y + size / 2)
|
| 317 |
+
.attr("r", size / 2 - 3);
|
| 318 |
+
slot.append("image")
|
| 319 |
+
.attr("x", x + 3)
|
| 320 |
+
.attr("y", y + 3)
|
| 321 |
+
.attr("width", size - 6)
|
| 322 |
+
.attr("height", size - 6)
|
| 323 |
+
.attr("data-input-provided-asset", "true")
|
| 324 |
+
.attr("data-no-copied-reference-art", "true")
|
| 325 |
+
.attr("preserveAspectRatio", "xMidYMid slice")
|
| 326 |
+
.attr("clip-path", `url(#line03-icon-clip-${String(groupName).replace(/[^a-zA-Z0-9]/g, "-")})`)
|
| 327 |
+
.attr("xlink:href", images.field[groupName]);
|
| 328 |
+
} else {
|
| 329 |
+
slot.append("circle")
|
| 330 |
+
.attr("cx", x + size / 2)
|
| 331 |
+
.attr("cy", y + size / 2)
|
| 332 |
+
.attr("r", 8)
|
| 333 |
+
.attr("fill", "rgba(6,40,62,0.28)");
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
groupSeries.slice(0, 2).forEach((series, i) => {
|
| 338 |
+
const group = series.group;
|
| 339 |
+
const color = getColor(group);
|
| 340 |
+
const x = 24 + i * (cardWidth + cardGap);
|
| 341 |
+
const groupLabel = String(group).toUpperCase();
|
| 342 |
+
const groupFontSize = Math.max(15, Math.min(26, (cardWidth - 152) / Math.max(1, groupLabel.length * 0.62)));
|
| 343 |
+
const card = contentLayer.append("g")
|
| 344 |
+
.attr("class", "line03-metric-card")
|
| 345 |
+
.attr("data-series", group)
|
| 346 |
+
.attr("data-relationship", "independent-header-card")
|
| 347 |
+
.attr("data-anchor", "header-safe-area");
|
| 348 |
+
|
| 349 |
+
card.append("rect")
|
| 350 |
+
.attr("x", x)
|
| 351 |
+
.attr("y", cardsTop)
|
| 352 |
+
.attr("width", cardWidth)
|
| 353 |
+
.attr("height", cardHeight)
|
| 354 |
+
.attr("rx", 7)
|
| 355 |
+
.attr("fill", i === 0 ? "rgba(0,78,98,0.44)" : "rgba(45,25,88,0.42)")
|
| 356 |
+
.attr("stroke", color)
|
| 357 |
+
.attr("stroke-width", 1.2)
|
| 358 |
+
.attr("opacity", 0.95);
|
| 359 |
+
|
| 360 |
+
addIconSlot(card, group, x + 16, cardsTop + 26, 92);
|
| 361 |
+
|
| 362 |
+
card.append("text")
|
| 363 |
+
.attr("x", x + 124)
|
| 364 |
+
.attr("y", cardsTop + 49)
|
| 365 |
+
.style("font-family", textFont)
|
| 366 |
+
.style("font-size", `${groupFontSize}px`)
|
| 367 |
+
.style("font-weight", 800)
|
| 368 |
+
.style("fill", brighten(color, 0.55))
|
| 369 |
+
.style("letter-spacing", "0.03em")
|
| 370 |
+
.text(groupLabel);
|
| 371 |
+
|
| 372 |
+
addWrappedText(card, trendText(series), x + 124, cardsTop + 84, cardWidth - 144, 24, 2, {
|
| 373 |
+
fontSize: "20px",
|
| 374 |
+
fill: "#f4fbff"
|
| 375 |
+
});
|
| 376 |
+
});
|
| 377 |
+
|
| 378 |
+
const plot = {
|
| 379 |
+
x: poster.x + 84,
|
| 380 |
+
y: 405,
|
| 381 |
+
width: poster.width - 156,
|
| 382 |
+
height: 1065
|
| 383 |
+
};
|
| 384 |
+
const labelReserve = groupSeries.length > 2 ? 150 : 118;
|
| 385 |
+
const plotWidth = plot.width - labelReserve;
|
| 386 |
+
const plotHeight = plot.height;
|
| 387 |
+
const xExtent = d3.extent(parsedRows, d => d.x);
|
| 388 |
+
const yMinData = d3.min(parsedRows, d => d.y);
|
| 389 |
+
const yMaxData = d3.max(parsedRows, d => d.y);
|
| 390 |
+
const yMin = Math.min(0, yMinData);
|
| 391 |
+
const yMax = Math.ceil((yMaxData * 1.4) / 5) * 5;
|
| 392 |
+
|
| 393 |
+
const xScale = d3.scaleTime().domain(xExtent).range([0, plotWidth]);
|
| 394 |
+
const yScale = d3.scaleLinear().domain([yMin, yMax]).nice(5).range([plotHeight, 0]);
|
| 395 |
+
const uniqueDates = [...new Map(parsedRows.map(d => [+d.x, d.x])).values()].sort((a, b) => a - b);
|
| 396 |
+
const xTicks = uniqueDates.length <= 12
|
| 397 |
+
? uniqueDates
|
| 398 |
+
: uniqueDates.filter((_, i) => i % Math.ceil(uniqueDates.length / 10) === 0 || i === uniqueDates.length - 1);
|
| 399 |
+
const yTicks = yScale.ticks(5).filter(d => d >= yMin && d <= yMax);
|
| 400 |
+
const xFormat = d3.timeFormat("%Y");
|
| 401 |
+
const yZero = yScale(0);
|
| 402 |
+
|
| 403 |
+
const chart = contentLayer.append("g")
|
| 404 |
+
.attr("class", "line03-plot")
|
| 405 |
+
.attr("data-relationship", "wide-chart-panel-below-header-cards")
|
| 406 |
+
.attr("transform", `translate(${plot.x},${plot.y})`);
|
| 407 |
+
|
| 408 |
+
chart.append("text")
|
| 409 |
+
.attr("x", -20)
|
| 410 |
+
.attr("y", -14)
|
| 411 |
+
.style("font-family", textFont)
|
| 412 |
+
.style("font-size", "13px")
|
| 413 |
+
.style("font-weight", 600)
|
| 414 |
+
.style("fill", "#eefbff")
|
| 415 |
+
.text(`${yLabel}${yUnit && !String(yLabel).includes(yUnit) ? ` (${String(yUnit).toLowerCase() === "$m" ? "millions" : yUnit})` : ""}`);
|
| 416 |
+
|
| 417 |
+
chart.append("rect")
|
| 418 |
+
.attr("class", "reserved-asset-slot plot-scenic-commercial")
|
| 419 |
+
.attr("data-asset-slot", "line03-plot-commercial-scenic-art")
|
| 420 |
+
.attr("data-asset-source-policy", "image2-generated")
|
| 421 |
+
.attr("data-anchor", "lower-left-under-commercial-series")
|
| 422 |
+
.attr("data-collision-rule", "behind-lines-and-below-data-labels")
|
| 423 |
+
.attr("data-bbox", `${8},${plotHeight * 0.58},${plotWidth * 0.36},${plotHeight * 0.32}`)
|
| 424 |
+
.attr("data-no-embedded-raster", "true")
|
| 425 |
+
.attr("data-no-copied-reference-art", "true")
|
| 426 |
+
.attr("x", 8)
|
| 427 |
+
.attr("y", plotHeight * 0.58)
|
| 428 |
+
.attr("width", plotWidth * 0.36)
|
| 429 |
+
.attr("height", plotHeight * 0.32)
|
| 430 |
+
.attr("fill", "rgba(28,112,130,0.075)")
|
| 431 |
+
.attr("stroke", "rgba(70,210,218,0.13)")
|
| 432 |
+
.attr("stroke-dasharray", "4 8")
|
| 433 |
+
.attr("rx", 18);
|
| 434 |
+
|
| 435 |
+
chart.append("rect")
|
| 436 |
+
.attr("class", "reserved-asset-slot plot-scenic-ecotourism")
|
| 437 |
+
.attr("data-asset-slot", "line03-plot-ecotourism-scenic-art")
|
| 438 |
+
.attr("data-asset-source-policy", "image2-generated")
|
| 439 |
+
.attr("data-anchor", "lower-right-under-ecotourism-area")
|
| 440 |
+
.attr("data-collision-rule", "behind-lines-and-below-data-labels")
|
| 441 |
+
.attr("data-bbox", `${plotWidth * 0.48},${plotHeight * 0.42},${plotWidth * 0.48},${plotHeight * 0.40}`)
|
| 442 |
+
.attr("data-no-embedded-raster", "true")
|
| 443 |
+
.attr("data-no-copied-reference-art", "true")
|
| 444 |
+
.attr("x", plotWidth * 0.48)
|
| 445 |
+
.attr("y", plotHeight * 0.42)
|
| 446 |
+
.attr("width", plotWidth * 0.48)
|
| 447 |
+
.attr("height", plotHeight * 0.40)
|
| 448 |
+
.attr("fill", "rgba(86,56,142,0.075)")
|
| 449 |
+
.attr("stroke", "rgba(184,119,255,0.13)")
|
| 450 |
+
.attr("stroke-dasharray", "4 8")
|
| 451 |
+
.attr("rx", 20);
|
| 452 |
+
|
| 453 |
+
chart.selectAll("line.grid-y")
|
| 454 |
+
.data(yTicks)
|
| 455 |
+
.enter()
|
| 456 |
+
.append("line")
|
| 457 |
+
.attr("class", "grid-y")
|
| 458 |
+
.attr("x1", 0)
|
| 459 |
+
.attr("x2", plotWidth)
|
| 460 |
+
.attr("y1", d => yScale(d))
|
| 461 |
+
.attr("y2", d => yScale(d))
|
| 462 |
+
.attr("stroke", "rgba(190,218,232,0.18)")
|
| 463 |
+
.attr("stroke-dasharray", "1.5 3");
|
| 464 |
+
|
| 465 |
+
chart.selectAll("line.grid-x")
|
| 466 |
+
.data(xTicks)
|
| 467 |
+
.enter()
|
| 468 |
+
.append("line")
|
| 469 |
+
.attr("class", "grid-x")
|
| 470 |
+
.attr("x1", d => xScale(d))
|
| 471 |
+
.attr("x2", d => xScale(d))
|
| 472 |
+
.attr("y1", 0)
|
| 473 |
+
.attr("y2", plotHeight)
|
| 474 |
+
.attr("stroke", "rgba(190,218,232,0.14)")
|
| 475 |
+
.attr("stroke-dasharray", "1.5 3");
|
| 476 |
+
|
| 477 |
+
chart.append("line")
|
| 478 |
+
.attr("x1", 0)
|
| 479 |
+
.attr("x2", 0)
|
| 480 |
+
.attr("y1", 0)
|
| 481 |
+
.attr("y2", plotHeight)
|
| 482 |
+
.attr("stroke", "#eff8ff")
|
| 483 |
+
.attr("stroke-width", 1.1);
|
| 484 |
+
|
| 485 |
+
chart.append("line")
|
| 486 |
+
.attr("x1", 0)
|
| 487 |
+
.attr("x2", plotWidth)
|
| 488 |
+
.attr("y1", yZero)
|
| 489 |
+
.attr("y2", yZero)
|
| 490 |
+
.attr("stroke", "#eff8ff")
|
| 491 |
+
.attr("stroke-width", 1.1);
|
| 492 |
+
|
| 493 |
+
const area = d3.area()
|
| 494 |
+
.curve(d3.curveMonotoneX)
|
| 495 |
+
.x(d => xScale(d.x))
|
| 496 |
+
.y0(yZero)
|
| 497 |
+
.y1(d => yScale(d.y));
|
| 498 |
+
|
| 499 |
+
const line = d3.line()
|
| 500 |
+
.curve(d3.curveMonotoneX)
|
| 501 |
+
.x(d => xScale(d.x))
|
| 502 |
+
.y(d => yScale(d.y));
|
| 503 |
+
|
| 504 |
+
groupSeries.forEach((series, i) => {
|
| 505 |
+
const color = getColor(series.group);
|
| 506 |
+
const gradientId = `line03-area-${i}`;
|
| 507 |
+
const grad = defs.append("linearGradient")
|
| 508 |
+
.attr("id", gradientId)
|
| 509 |
+
.attr("x1", "0%")
|
| 510 |
+
.attr("x2", "0%")
|
| 511 |
+
.attr("y1", "0%")
|
| 512 |
+
.attr("y2", "100%");
|
| 513 |
+
grad.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 0.5);
|
| 514 |
+
grad.append("stop").attr("offset", "72%").attr("stop-color", color).attr("stop-opacity", 0.22);
|
| 515 |
+
grad.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0.06);
|
| 516 |
+
|
| 517 |
+
chart.append("path")
|
| 518 |
+
.datum(series.values)
|
| 519 |
+
.attr("class", "line03-area")
|
| 520 |
+
.attr("data-series", series.group)
|
| 521 |
+
.attr("fill", `url(#${gradientId})`)
|
| 522 |
+
.attr("d", area)
|
| 523 |
+
.attr("opacity", 0.78);
|
| 524 |
+
});
|
| 525 |
+
|
| 526 |
+
groupSeries.forEach(series => {
|
| 527 |
+
const color = getColor(series.group);
|
| 528 |
+
chart.append("path")
|
| 529 |
+
.datum(series.values)
|
| 530 |
+
.attr("class", "line03-line-halo")
|
| 531 |
+
.attr("data-series", series.group)
|
| 532 |
+
.attr("fill", "none")
|
| 533 |
+
.attr("stroke", darken(color, 1.15))
|
| 534 |
+
.attr("stroke-width", 7)
|
| 535 |
+
.attr("stroke-linecap", "round")
|
| 536 |
+
.attr("stroke-linejoin", "round")
|
| 537 |
+
.attr("opacity", 0.9)
|
| 538 |
+
.attr("d", line);
|
| 539 |
+
|
| 540 |
+
chart.append("path")
|
| 541 |
+
.datum(series.values)
|
| 542 |
+
.attr("class", "line03-line")
|
| 543 |
+
.attr("data-series", series.group)
|
| 544 |
+
.attr("fill", "none")
|
| 545 |
+
.attr("stroke", brighten(color, 0.45))
|
| 546 |
+
.attr("stroke-width", 4)
|
| 547 |
+
.attr("stroke-linecap", "round")
|
| 548 |
+
.attr("stroke-linejoin", "round")
|
| 549 |
+
.attr("filter", "url(#line03-soft-glow)")
|
| 550 |
+
.attr("d", line);
|
| 551 |
+
});
|
| 552 |
+
|
| 553 |
+
chart.selectAll("text.y-tick")
|
| 554 |
+
.data(yTicks)
|
| 555 |
+
.enter()
|
| 556 |
+
.append("text")
|
| 557 |
+
.attr("class", "y-tick")
|
| 558 |
+
.attr("x", -9)
|
| 559 |
+
.attr("y", d => yScale(d) + 4)
|
| 560 |
+
.attr("text-anchor", "end")
|
| 561 |
+
.style("font-family", textFont)
|
| 562 |
+
.style("font-size", "13px")
|
| 563 |
+
.style("fill", "#f3f7fb")
|
| 564 |
+
.text(d => d);
|
| 565 |
+
|
| 566 |
+
chart.selectAll("text.x-tick")
|
| 567 |
+
.data(xTicks)
|
| 568 |
+
.enter()
|
| 569 |
+
.append("text")
|
| 570 |
+
.attr("class", "x-tick")
|
| 571 |
+
.attr("x", d => xScale(d))
|
| 572 |
+
.attr("y", plotHeight + 22)
|
| 573 |
+
.attr("text-anchor", "middle")
|
| 574 |
+
.style("font-family", textFont)
|
| 575 |
+
.style("font-size", "12px")
|
| 576 |
+
.style("fill", "#f3f7fb")
|
| 577 |
+
.text(d => xFormat(d));
|
| 578 |
+
|
| 579 |
+
chart.append("text")
|
| 580 |
+
.attr("x", plotWidth / 2)
|
| 581 |
+
.attr("y", plotHeight + 48)
|
| 582 |
+
.attr("text-anchor", "middle")
|
| 583 |
+
.style("font-family", textFont)
|
| 584 |
+
.style("font-size", "15px")
|
| 585 |
+
.style("font-weight", 700)
|
| 586 |
+
.style("fill", "#ffffff")
|
| 587 |
+
.text(xField);
|
| 588 |
+
|
| 589 |
+
const endpointLayouts = (() => {
|
| 590 |
+
const layouts = groupSeries.map(series => {
|
| 591 |
+
const last = series.values[series.values.length - 1];
|
| 592 |
+
return {
|
| 593 |
+
series,
|
| 594 |
+
pointY: yScale(last.y),
|
| 595 |
+
centerY: yScale(last.y)
|
| 596 |
+
};
|
| 597 |
+
}).sort((a, b) => a.centerY - b.centerY);
|
| 598 |
+
|
| 599 |
+
const minGap = Math.max(72, Math.min(96, plotHeight / Math.max(1, layouts.length) * 0.78));
|
| 600 |
+
layouts.forEach((layout, i) => {
|
| 601 |
+
const minYForIndex = 28 + i * minGap;
|
| 602 |
+
layout.centerY = Math.max(layout.centerY, minYForIndex);
|
| 603 |
+
});
|
| 604 |
+
for (let i = layouts.length - 2; i >= 0; i--) {
|
| 605 |
+
layouts[i].centerY = Math.min(layouts[i].centerY, layouts[i + 1].centerY - minGap);
|
| 606 |
+
}
|
| 607 |
+
layouts.forEach(layout => {
|
| 608 |
+
layout.centerY = Math.max(28, Math.min(plotHeight - 44, layout.centerY));
|
| 609 |
+
});
|
| 610 |
+
|
| 611 |
+
const byGroup = new Map();
|
| 612 |
+
layouts.forEach(layout => byGroup.set(layout.series.group, layout));
|
| 613 |
+
return byGroup;
|
| 614 |
+
})();
|
| 615 |
+
|
| 616 |
+
function addEndpointBadge(series) {
|
| 617 |
+
const color = getColor(series.group);
|
| 618 |
+
const last = series.values[series.values.length - 1];
|
| 619 |
+
const pointX = xScale(last.x);
|
| 620 |
+
const pointY = yScale(last.y);
|
| 621 |
+
const badgeW = groupSeries.length > 2 ? 64 : 76;
|
| 622 |
+
const badgeH = 42;
|
| 623 |
+
const badgeX = pointX + 30;
|
| 624 |
+
const layout = endpointLayouts.get(series.group) || { centerY: pointY };
|
| 625 |
+
const badgeY = layout.centerY - badgeH / 2;
|
| 626 |
+
const labelY = badgeY + badgeH + 18;
|
| 627 |
+
const badgeText = endpointText(last.y);
|
| 628 |
+
|
| 629 |
+
chart.append("circle")
|
| 630 |
+
.attr("class", "line03-endpoint-dot")
|
| 631 |
+
.attr("data-series", series.group)
|
| 632 |
+
.attr("cx", pointX)
|
| 633 |
+
.attr("cy", pointY)
|
| 634 |
+
.attr("r", 7)
|
| 635 |
+
.attr("fill", "#f4f5ff")
|
| 636 |
+
.attr("stroke", brighten(color, 0.35))
|
| 637 |
+
.attr("stroke-width", 4);
|
| 638 |
+
|
| 639 |
+
chart.append("path")
|
| 640 |
+
.attr("class", "line03-endpoint-leader")
|
| 641 |
+
.attr("data-series", series.group)
|
| 642 |
+
.attr("d", `M${pointX + 7},${pointY} L${badgeX - 6},${badgeY + badgeH / 2}`)
|
| 643 |
+
.attr("fill", "none")
|
| 644 |
+
.attr("stroke", color)
|
| 645 |
+
.attr("stroke-width", 2)
|
| 646 |
+
.attr("opacity", 0.92);
|
| 647 |
+
|
| 648 |
+
const badge = chart.append("g")
|
| 649 |
+
.attr("class", "line03-endpoint-badge")
|
| 650 |
+
.attr("data-series", series.group)
|
| 651 |
+
.attr("data-relationship", "leader-connected-to-terminal-point")
|
| 652 |
+
.attr("data-alignment-basis", "leader endpoint from final data point to badge left edge");
|
| 653 |
+
|
| 654 |
+
badge.append("rect")
|
| 655 |
+
.attr("x", badgeX)
|
| 656 |
+
.attr("y", badgeY)
|
| 657 |
+
.attr("width", badgeW)
|
| 658 |
+
.attr("height", badgeH)
|
| 659 |
+
.attr("rx", 5)
|
| 660 |
+
.attr("fill", d3.color(color).copy({ opacity: 0.62 }).formatRgb())
|
| 661 |
+
.attr("stroke", brighten(color, 0.45))
|
| 662 |
+
.attr("stroke-width", 1.2);
|
| 663 |
+
|
| 664 |
+
badge.append("text")
|
| 665 |
+
.attr("x", badgeX + badgeW / 2)
|
| 666 |
+
.attr("y", badgeY + 29)
|
| 667 |
+
.attr("text-anchor", "middle")
|
| 668 |
+
.style("font-family", textFont)
|
| 669 |
+
.style("font-size", groupSeries.length > 2 ? "19px" : "25px")
|
| 670 |
+
.style("font-weight", 900)
|
| 671 |
+
.style("fill", "#f7f5ff")
|
| 672 |
+
.text(badgeText);
|
| 673 |
+
|
| 674 |
+
addWrappedText(badge, `${series.group} ${yLabel}`, badgeX + 1, labelY, labelReserve - 8, 14, groupSeries.length > 2 ? 2 : 3, {
|
| 675 |
+
fontSize: groupSeries.length > 2 ? "10px" : "12px",
|
| 676 |
+
fill: "#dff6ff"
|
| 677 |
+
});
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
groupSeries.forEach(addEndpointBadge);
|
| 681 |
+
|
| 682 |
+
const legendBand = contentLayer.append("g")
|
| 683 |
+
.attr("class", "line03-bottom-legend-band")
|
| 684 |
+
.attr("data-relationship", "chart-owned-bottom-legend")
|
| 685 |
+
.attr("data-alignment-basis", "independent bottom band below plot, not a narrative takeaway");
|
| 686 |
+
|
| 687 |
+
const legendY = 1588;
|
| 688 |
+
const legendHeight = groupSeries.length > 2 ? 150 : 106;
|
| 689 |
+
legendBand.append("rect")
|
| 690 |
+
.attr("x", poster.x + 40)
|
| 691 |
+
.attr("y", legendY)
|
| 692 |
+
.attr("width", poster.width - 80)
|
| 693 |
+
.attr("height", legendHeight)
|
| 694 |
+
.attr("rx", 12)
|
| 695 |
+
.attr("fill", "rgba(7,48,76,0.58)")
|
| 696 |
+
.attr("stroke", "rgba(108,224,234,0.22)")
|
| 697 |
+
.attr("stroke-width", 1.4);
|
| 698 |
+
|
| 699 |
+
legendBand.append("text")
|
| 700 |
+
.attr("x", poster.x + 92)
|
| 701 |
+
.attr("y", legendY + 42)
|
| 702 |
+
.style("font-family", textFont)
|
| 703 |
+
.style("font-size", "29px")
|
| 704 |
+
.style("font-weight", 900)
|
| 705 |
+
.style("fill", "#f6fbff")
|
| 706 |
+
.text("LEGEND");
|
| 707 |
+
|
| 708 |
+
legendBand.append("text")
|
| 709 |
+
.attr("x", poster.x + 92)
|
| 710 |
+
.attr("y", legendY + 72)
|
| 711 |
+
.style("font-family", textFont)
|
| 712 |
+
.style("font-size", "16px")
|
| 713 |
+
.style("font-weight", 500)
|
| 714 |
+
.style("fill", "#bfe4ef")
|
| 715 |
+
.text(`${yLabel}${yUnit ? ` by ${xField}` : ""}`);
|
| 716 |
+
|
| 717 |
+
legendBand.append("line")
|
| 718 |
+
.attr("x1", poster.x + 500)
|
| 719 |
+
.attr("x2", poster.x + 500)
|
| 720 |
+
.attr("y1", legendY + 24)
|
| 721 |
+
.attr("y2", legendY + legendHeight - 24)
|
| 722 |
+
.attr("stroke", "rgba(213,242,248,0.28)")
|
| 723 |
+
.attr("stroke-width", 1.2);
|
| 724 |
+
|
| 725 |
+
const legendItemX = poster.x + 532;
|
| 726 |
+
const legendItemY = legendY + 32;
|
| 727 |
+
groupSeries.slice(0, 7).forEach((series, i) => {
|
| 728 |
+
const color = getColor(series.group);
|
| 729 |
+
const row = groupSeries.length <= 2 ? i : i;
|
| 730 |
+
const x = legendItemX;
|
| 731 |
+
const y = legendItemY + row * (groupSeries.length <= 2 ? 30 : 24);
|
| 732 |
+
const item = legendBand.append("g")
|
| 733 |
+
.attr("class", "line03-data-legend-item")
|
| 734 |
+
.attr("data-series", series.group);
|
| 735 |
+
item.append("line")
|
| 736 |
+
.attr("x1", x)
|
| 737 |
+
.attr("x2", x + 48)
|
| 738 |
+
.attr("y1", y)
|
| 739 |
+
.attr("y2", y)
|
| 740 |
+
.attr("stroke", brighten(color, 0.42))
|
| 741 |
+
.attr("stroke-width", 7)
|
| 742 |
+
.attr("stroke-linecap", "round");
|
| 743 |
+
item.append("text")
|
| 744 |
+
.attr("x", x + 68)
|
| 745 |
+
.attr("y", y + 6)
|
| 746 |
+
.style("font-family", textFont)
|
| 747 |
+
.style("font-size", groupSeries.length > 2 ? "13px" : "15px")
|
| 748 |
+
.style("font-weight", 500)
|
| 749 |
+
.style("fill", "#edf7fb")
|
| 750 |
+
.text(groupSeries.length > 2 ? String(series.group) : `${series.group} ${yLabel}`);
|
| 751 |
+
});
|
| 752 |
+
|
| 753 |
+
return svg.node();
|
| 754 |
+
}
|