Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- modules/chart_engine/template/d3-js/line_graph/bump_chart_01.js +469 -0
- modules/chart_engine/template/d3-js/line_graph/bump_chart_02.js +617 -0
- modules/chart_engine/template/d3-js/line_graph/bump_chart_03.js +650 -0
- modules/chart_engine/template/d3-js/line_graph/bump_chart_04.js +662 -0
- modules/chart_engine/template/d3-js/line_graph/bump_chart_05.js +497 -0
- modules/chart_engine/template/d3-js/line_graph/bump_chart_06.js +476 -0
- modules/chart_engine/template/d3-js/line_graph/bump_chart_07.js +491 -0
- modules/chart_engine/template/d3-js/line_graph/bump_chart_09.js +415 -0
- modules/chart_engine/template/d3-js/line_graph/bump_chart_10.js +482 -0
- modules/chart_engine/template/d3-js/line_graph/bump_chart_11.js +541 -0
- modules/chart_engine/template/d3-js/line_graph/bump_chart_12.js +561 -0
- modules/chart_engine/template/d3-js/line_graph/bump_chart_13.js +439 -0
- modules/chart_engine/template/d3-js/line_graph/bump_chart_14.js +455 -0
- modules/chart_engine/template/d3-js/line_graph/line_chart_01.js +486 -0
- modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_01.js +655 -0
- modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_03.js +593 -0
- modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_06.js +550 -0
- modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_07.js +894 -0
- modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_08.js +343 -0
- modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_09.js +383 -0
- modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_10.js +563 -0
- modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_11.js +563 -0
- modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_12.js +466 -0
- modules/chart_engine/template/d3-js/line_graph/multiple_spline_graph_01.js +377 -0
- modules/chart_engine/template/d3-js/line_graph/multiple_spline_graph_02.js +377 -0
- modules/chart_engine/template/d3-js/line_graph/multiple_step_line_graph_01.js +563 -0
- modules/chart_engine/template/d3-js/line_graph/new_line_chart_01.js +344 -0
- modules/chart_engine/template/d3-js/line_graph/new_line_chart_02.js +332 -0
- modules/chart_engine/template/d3-js/line_graph/slope_chart_01.js +550 -0
- modules/chart_engine/template/d3-js/line_graph/slope_chart_02.js +551 -0
- modules/chart_engine/template/d3-js/line_graph/small_multiple_line_graph_01.js +564 -0
- modules/chart_engine/template/d3-js/line_graph/small_multiple_step_line_graph_01.js +288 -0
- modules/chart_engine/template/d3-js/line_graph/spline_graph_01.js +491 -0
- modules/chart_engine/template/d3-js/line_graph/step_line_graph_01.js +590 -0
modules/chart_engine/template/d3-js/line_graph/bump_chart_01.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Bump Chart",
|
| 5 |
+
"chart_name": "bump_chart_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"chart_for": "comparison",
|
| 8 |
+
"required_fields": ["x", "y", "group", "group2"],
|
| 9 |
+
"hierarchy":["group2"],
|
| 10 |
+
"required_fields_type": [["categorical"], ["numerical"], ["categorical"], ["categorical"]],
|
| 11 |
+
"required_fields_range": [
|
| 12 |
+
[2, 30],
|
| 13 |
+
[0, "inf"],
|
| 14 |
+
[2, 2],
|
| 15 |
+
[1, 10]
|
| 16 |
+
],
|
| 17 |
+
"required_fields_icons": [],
|
| 18 |
+
"required_other_icons": [],
|
| 19 |
+
"required_fields_colors": ["group"],
|
| 20 |
+
"required_other_colors": [],
|
| 21 |
+
"supported_effects": ["spacing"],
|
| 22 |
+
"min_height": 400,
|
| 23 |
+
"min_width": 400,
|
| 24 |
+
"background": "no",
|
| 25 |
+
"icon_mark": "none",
|
| 26 |
+
"icon_label": "none",
|
| 27 |
+
"has_x_axis": "no",
|
| 28 |
+
"has_y_axis": "no"
|
| 29 |
+
}
|
| 30 |
+
REQUIREMENTS_END
|
| 31 |
+
*/
|
| 32 |
+
|
| 33 |
+
// 水平分离组条形图 (左右独立排序,固定宽度条,新标签布局,带连接线)
|
| 34 |
+
function makeChart(containerSelector, data) {
|
| 35 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 36 |
+
// ---------- 1. 数据与配置 ----------
|
| 37 |
+
const jsonData = data;
|
| 38 |
+
const chartData = jsonData.data.data;
|
| 39 |
+
const variables = jsonData.variables || {};
|
| 40 |
+
const typography = jsonData.typography || {
|
| 41 |
+
label: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 42 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 43 |
+
};
|
| 44 |
+
const colors = jsonData.colors || {
|
| 45 |
+
text_color: "#FFFFFF", // 条内标签默认白色
|
| 46 |
+
field: {},
|
| 47 |
+
available_colors: ["#3366cc", "#dc3912", "#ff9900", "#109618", "#990099"]
|
| 48 |
+
};
|
| 49 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 50 |
+
|
| 51 |
+
variables.has_spacing = variables.has_spacing !== undefined ? variables.has_spacing : true;
|
| 52 |
+
|
| 53 |
+
// 清空容器 & 创建文本测量上下文
|
| 54 |
+
d3.select(containerSelector).html("");
|
| 55 |
+
const canvas = document.createElement('canvas');
|
| 56 |
+
const context = canvas.getContext('2d');
|
| 57 |
+
function measureTextWidth(text, fontFamily, fontSize, fontWeight) {
|
| 58 |
+
return chartUtils.text.measure(null, text, { fontFamily: fontFamily, fontSize: fontSize, fontWeight: fontWeight }).width;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// ---------- 2. 尺寸与边距 ----------
|
| 62 |
+
const width = variables.width || 600;
|
| 63 |
+
const height = variables.height || 600;
|
| 64 |
+
const margin = { top: 90, right: 30, bottom: 60, left: 30 };
|
| 65 |
+
const innerWidth = width - margin.left - margin.right;
|
| 66 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 67 |
+
|
| 68 |
+
// 布局: 左右图表区域和中心间隙 (35% / 30% / 35%)
|
| 69 |
+
const leftChartWidthRatio = 0.35;
|
| 70 |
+
const rightChartWidthRatio = 0.35;
|
| 71 |
+
// const centerGapRatio = 0.3; // 中心间隙宽度由左右宽度确定
|
| 72 |
+
const leftChartWidth = innerWidth * leftChartWidthRatio;
|
| 73 |
+
const rightChartWidth = innerWidth * rightChartWidthRatio;
|
| 74 |
+
const rightChartStartX = innerWidth - rightChartWidth;
|
| 75 |
+
|
| 76 |
+
// ---------- 3. 字段提取 ----------
|
| 77 |
+
const xChannel = chartUtils.schema.channel(jsonData, "x"); // 类别字段 (如:国家)
|
| 78 |
+
const yChannel = chartUtils.schema.channel(jsonData, "y"); // 数值字段
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
const categoryField = xChannel.key;
|
| 83 |
+
const valueField = yChannel.key;
|
| 84 |
+
const conditionField = chartUtils.schema.channel(jsonData, "group").key;
|
| 85 |
+
const colorField = chartUtils.schema.channel(jsonData, "group2").key;
|
| 86 |
+
let valueUnit = yChannel.unit;
|
| 87 |
+
|
| 88 |
+
// ---------- 4. 数据处理与排序 ----------
|
| 89 |
+
const conditions = [...new Set(chartData.map(d => d[conditionField]))];
|
| 90 |
+
if (conditions.length !== 2) {
|
| 91 |
+
console.error(`条件字段 '${conditionField}' 必须有2个唯一值`);
|
| 92 |
+
return;
|
| 93 |
+
}
|
| 94 |
+
const conditionLeft = conditions[0];
|
| 95 |
+
const conditionRight = conditions[1];
|
| 96 |
+
|
| 97 |
+
const colorGroups = [...new Set(chartData.map(d => d[colorField]))];
|
| 98 |
+
|
| 99 |
+
// 预处理数据,聚合左右两侧的值
|
| 100 |
+
const processedData = {};
|
| 101 |
+
chartData.forEach(d => {
|
| 102 |
+
const cat = d[categoryField];
|
| 103 |
+
if (!processedData[cat]) {
|
| 104 |
+
processedData[cat] = { leftValue: null, rightValue: null, colorGroup: null };
|
| 105 |
+
}
|
| 106 |
+
if (d[conditionField] === conditionLeft) {
|
| 107 |
+
processedData[cat].leftValue = +d[valueField] || 0;
|
| 108 |
+
} else if (d[conditionField] === conditionRight) {
|
| 109 |
+
processedData[cat].rightValue = +d[valueField] || 0;
|
| 110 |
+
}
|
| 111 |
+
processedData[cat].colorGroup = d[colorField]; // 假设同一类别的颜色组一致
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
// 基础数据数组,包含所有处理后的类别
|
| 115 |
+
const baseData = Object.entries(processedData)
|
| 116 |
+
.map(([category, values]) => ({ category, ...values }));
|
| 117 |
+
|
| 118 |
+
// 分别过滤和排序左右两侧的数据
|
| 119 |
+
const sortedDataLeft = baseData
|
| 120 |
+
.filter(d => d.leftValue !== null)
|
| 121 |
+
.sort((a, b) => b.leftValue - a.leftValue);
|
| 122 |
+
|
| 123 |
+
const sortedDataRight = baseData
|
| 124 |
+
.filter(d => d.rightValue !== null)
|
| 125 |
+
.sort((a, b) => b.rightValue - a.rightValue);
|
| 126 |
+
|
| 127 |
+
const sortedCategoriesLeft = sortedDataLeft.map(d => d.category);
|
| 128 |
+
const sortedCategoriesRight = sortedDataRight.map(d => d.category);
|
| 129 |
+
|
| 130 |
+
// ---------- 5. 比例尺与常量定义 ----------
|
| 131 |
+
const maxCount = Math.max(sortedDataLeft.length, sortedDataRight.length); // Y轴基于最大条目数
|
| 132 |
+
|
| 133 |
+
// 统一Y轴比例尺
|
| 134 |
+
const unifiedYDomain = d3.range(maxCount);
|
| 135 |
+
const yScale = d3.scaleBand()
|
| 136 |
+
.domain(unifiedYDomain)
|
| 137 |
+
.range([0, innerHeight])
|
| 138 |
+
.padding(variables.has_spacing ? 0.2 : 0.15);
|
| 139 |
+
|
| 140 |
+
// 颜色比例尺
|
| 141 |
+
const colorScale = colorResolver.scale(colorGroups, { palette: "category10" });
|
| 142 |
+
|
| 143 |
+
// 条形尺寸与位置常量
|
| 144 |
+
const barHeight = yScale.bandwidth();
|
| 145 |
+
const barRadius = barHeight / 2;
|
| 146 |
+
const fixedBarPixelWidth = leftChartWidth * 0.98; // 固定条形宽度
|
| 147 |
+
const leftBarLeftEdge = 0; // 左侧条形左边缘
|
| 148 |
+
const leftBarRightEdge = leftBarLeftEdge + fixedBarPixelWidth; // 左侧条形右边缘
|
| 149 |
+
const rightBarRightEdge = innerWidth; // 右侧条形右边缘
|
| 150 |
+
const rightBarLeftEdge = rightBarRightEdge - fixedBarPixelWidth; // 右侧条形左边缘
|
| 151 |
+
|
| 152 |
+
// ---------- 6. SVG 容器与主分组 ----------
|
| 153 |
+
const svg = d3.select(containerSelector)
|
| 154 |
+
.append("svg")
|
| 155 |
+
.attr("width", "100%")
|
| 156 |
+
.attr("height", height)
|
| 157 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 158 |
+
.attr("style", `max-width: 100%; height: auto; background-color: ${colorResolver.background({ fallback: 'transparent' }).value};`)
|
| 159 |
+
.attr("xmlns", "http://www.w3.org/2000/svg");
|
| 160 |
+
|
| 161 |
+
const g = svg.append("g")
|
| 162 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 163 |
+
|
| 164 |
+
// ---------- 7. 字体大小动态计算辅助函数 ----------
|
| 165 |
+
function calculateScaledFontSize(initialSize, scaleFactor, minSize = 6) {
|
| 166 |
+
return Math.max(minSize, initialSize * scaleFactor);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// ---------- 8. 标题与图例定位及绘制 (仅当有数据时) ----------
|
| 170 |
+
const initialHeaderFontSize = parseFloat(typography.label?.font_size || 16);
|
| 171 |
+
const headerFontFamily = typography.label?.font_family || "Arial";
|
| 172 |
+
const headerFontWeight = typography.label?.font_weight || "bold";
|
| 173 |
+
let finalHeaderFontSize = initialHeaderFontSize;
|
| 174 |
+
let headerY = -margin.top + initialHeaderFontSize; // 无数据时的默认标题Y位置
|
| 175 |
+
|
| 176 |
+
const initialLegendFontSize = parseFloat(typography.label?.font_size || 12);
|
| 177 |
+
const legendFontWeight = typography.label?.font_weight || "normal";
|
| 178 |
+
const legendFontFamily = typography.label?.font_family || "Arial";
|
| 179 |
+
const legendColor = colorResolver.text({ fallback: "#333333" }).value;
|
| 180 |
+
let legendSquareSize = 12;
|
| 181 |
+
const legendItemPadding = 8;
|
| 182 |
+
const legendColumnPadding = 20;
|
| 183 |
+
let finalLegendFontSize = initialLegendFontSize;
|
| 184 |
+
let legendY = headerY - finalLegendFontSize - 5; // 无数据时的默认图例Y位置
|
| 185 |
+
let legendStartX = 0;
|
| 186 |
+
const relevantColorGroups = [...new Set(baseData.map(d => d.colorGroup).filter(Boolean))]; // 有效的颜色分组
|
| 187 |
+
|
| 188 |
+
if (maxCount > 0 && barHeight > 0) { // 确保有数据且条形高度有效
|
| 189 |
+
// --- 标题尺寸与位置计算 ---
|
| 190 |
+
let leftHeaderWidth = measureTextWidth(conditionLeft, headerFontFamily, initialHeaderFontSize, headerFontWeight);
|
| 191 |
+
let rightHeaderWidth = measureTextWidth(conditionRight, headerFontFamily, initialHeaderFontSize, headerFontWeight);
|
| 192 |
+
let headerScaleFactor = 1;
|
| 193 |
+
if (leftHeaderWidth > leftChartWidth || rightHeaderWidth > rightChartWidth) {
|
| 194 |
+
const scaleLeft = leftHeaderWidth > leftChartWidth ? leftChartWidth / leftHeaderWidth : 1;
|
| 195 |
+
const scaleRight = rightHeaderWidth > rightChartWidth ? rightChartWidth / rightHeaderWidth : 1;
|
| 196 |
+
headerScaleFactor = Math.min(scaleLeft, scaleRight) * 0.98; // 统一缩小比例
|
| 197 |
+
}
|
| 198 |
+
finalHeaderFontSize = calculateScaledFontSize(initialHeaderFontSize, headerScaleFactor);
|
| 199 |
+
const headerPaddingBottom = 10;
|
| 200 |
+
headerY = yScale(0) - headerPaddingBottom; // 标题在第一个条形上方
|
| 201 |
+
|
| 202 |
+
// --- 图例尺寸与位置计算 ---
|
| 203 |
+
if (relevantColorGroups.length > 0) {
|
| 204 |
+
let totalLegendWidth = 0;
|
| 205 |
+
relevantColorGroups.forEach((cg) => {
|
| 206 |
+
const textWidth = measureTextWidth(cg, legendFontFamily, initialLegendFontSize, legendFontWeight);
|
| 207 |
+
totalLegendWidth += legendSquareSize + legendItemPadding + textWidth + legendColumnPadding;
|
| 208 |
+
});
|
| 209 |
+
totalLegendWidth -= legendColumnPadding;
|
| 210 |
+
|
| 211 |
+
let legendScaleFactor = 1;
|
| 212 |
+
if (totalLegendWidth > innerWidth) {
|
| 213 |
+
legendScaleFactor = (innerWidth / totalLegendWidth) * 0.98; // 统一缩小比例
|
| 214 |
+
}
|
| 215 |
+
finalLegendFontSize = calculateScaledFontSize(initialLegendFontSize, legendScaleFactor);
|
| 216 |
+
legendSquareSize *= legendScaleFactor;
|
| 217 |
+
|
| 218 |
+
// 重新计算最终图例宽度
|
| 219 |
+
let finalLegendWidth = 0;
|
| 220 |
+
relevantColorGroups.forEach((cg) => {
|
| 221 |
+
const textWidth = measureTextWidth(cg, legendFontFamily, finalLegendFontSize, legendFontWeight);
|
| 222 |
+
finalLegendWidth += legendSquareSize + legendItemPadding + textWidth + legendColumnPadding;
|
| 223 |
+
});
|
| 224 |
+
finalLegendWidth -= legendColumnPadding;
|
| 225 |
+
|
| 226 |
+
legendStartX = (innerWidth - finalLegendWidth) / 2; // 水平居中
|
| 227 |
+
const legendPaddingBottom = 5;
|
| 228 |
+
legendY = headerY - finalHeaderFontSize - legendPaddingBottom; // 图例在标题上方
|
| 229 |
+
|
| 230 |
+
chartUtils.legend.draw(g, relevantColorGroups, {
|
| 231 |
+
color: cg => colorScale(cg),
|
| 232 |
+
colorResolver,
|
| 233 |
+
colors,
|
| 234 |
+
}, {
|
| 235 |
+
x: legendStartX,
|
| 236 |
+
y: legendY,
|
| 237 |
+
maxWidth: innerWidth,
|
| 238 |
+
fitToWidth: true,
|
| 239 |
+
minFontSize: 8,
|
| 240 |
+
markerShape: "rect",
|
| 241 |
+
markerSize: legendSquareSize,
|
| 242 |
+
labelGap: legendItemPadding,
|
| 243 |
+
itemGap: legendColumnPadding,
|
| 244 |
+
fontSize: finalLegendFontSize,
|
| 245 |
+
fontFamily: legendFontFamily,
|
| 246 |
+
fontWeight: legendFontWeight,
|
| 247 |
+
textColor: legendColor,
|
| 248 |
+
});
|
| 249 |
+
} // end if relevantColorGroups
|
| 250 |
+
|
| 251 |
+
// --- 绘制标题 ---
|
| 252 |
+
g.append("text")
|
| 253 |
+
.attr("class", "header-left")
|
| 254 |
+
.attr("x", 0)
|
| 255 |
+
.attr("y", headerY)
|
| 256 |
+
.attr("text-anchor", "start")
|
| 257 |
+
.style("font-family", headerFontFamily)
|
| 258 |
+
.style("font-size", `${finalHeaderFontSize}px`)
|
| 259 |
+
.style("font-weight", headerFontWeight)
|
| 260 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 261 |
+
.text(conditionLeft);
|
| 262 |
+
|
| 263 |
+
g.append("text")
|
| 264 |
+
.attr("class", "header-right")
|
| 265 |
+
.attr("x", innerWidth)
|
| 266 |
+
.attr("y", headerY)
|
| 267 |
+
.attr("text-anchor", "end")
|
| 268 |
+
.style("font-family", headerFontFamily)
|
| 269 |
+
.style("font-size", `${finalHeaderFontSize}px`)
|
| 270 |
+
.style("font-weight", headerFontWeight)
|
| 271 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 272 |
+
.text(conditionRight);
|
| 273 |
+
} // end if maxCount > 0
|
| 274 |
+
|
| 275 |
+
// ---------- 9. 条形、连接线、标签 ----------
|
| 276 |
+
|
| 277 |
+
// 辅助函数:生成圆角矩形路径
|
| 278 |
+
function roundedRectPath(x, y, width, height, radius) {
|
| 279 |
+
const r = Math.min(radius, height / 2);
|
| 280 |
+
if (width <= 0 || height <= 0) return ""; // 避免无效路径
|
| 281 |
+
return `M${x + r},${y} H${x + width - r} A${r},${r} 0 0 1 ${x + width},${y + r} V${y + height - r} A${r},${r} 0 0 1 ${x + width - r},${y + height} H${x + r} A${r},${r} 0 0 1 ${x},${y + height - r} V${y + r} A${r},${r} 0 0 1 ${x + r},${y}`;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
// --- 计算条内标签最终字体大小 ---
|
| 285 |
+
const labelPadding = 8;
|
| 286 |
+
const initialLabelFontSize = calculateScaledFontSize(parseFloat(typography.annotation?.font_size || 12), 1);
|
| 287 |
+
// 初始目标字体大小:基于条高,但不超过20px
|
| 288 |
+
const targetLabelFontSize = barHeight > 0 ? Math.min(20, Math.max(barHeight * 0.6, initialLabelFontSize)) : initialLabelFontSize;
|
| 289 |
+
const labelFontWeight = typography.annotation?.font_weight || "normal";
|
| 290 |
+
const labelFontFamily = typography.annotation?.font_family || "Arial";
|
| 291 |
+
const labelColor = colorResolver.text({ fallback: "#FFFFFF" }).value;
|
| 292 |
+
|
| 293 |
+
let finalLabelFontSize = targetLabelFontSize;
|
| 294 |
+
let maxLeftValueWidth = 0; // 用于左侧标签对齐
|
| 295 |
+
let maxRightValueWidth = 0; // 用于右侧标签对齐
|
| 296 |
+
|
| 297 |
+
if (maxCount > 0 && barHeight > 0) { // 仅当有数据和有效条高时计算缩放
|
| 298 |
+
let maxRequiredWidthLeft = 0;
|
| 299 |
+
sortedDataLeft.forEach(d => {
|
| 300 |
+
const valueText = chartUtils.format.fixed(d.leftValue, 0, { unit: valueUnit }).text;
|
| 301 |
+
const valueWidth = measureTextWidth(valueText, labelFontFamily, targetLabelFontSize, labelFontWeight);
|
| 302 |
+
const nameWidth = measureTextWidth(d.category, labelFontFamily, targetLabelFontSize, labelFontWeight);
|
| 303 |
+
maxRequiredWidthLeft = Math.max(maxRequiredWidthLeft, labelPadding + valueWidth + labelPadding + nameWidth + labelPadding);
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
let maxRequiredWidthRight = 0;
|
| 307 |
+
sortedDataRight.forEach(d => {
|
| 308 |
+
const valueText = chartUtils.format.fixed(d.rightValue, 0, { unit: valueUnit }).text;
|
| 309 |
+
const valueWidth = measureTextWidth(valueText, labelFontFamily, targetLabelFontSize, labelFontWeight);
|
| 310 |
+
const nameWidth = measureTextWidth(d.category, labelFontFamily, targetLabelFontSize, labelFontWeight);
|
| 311 |
+
maxRequiredWidthRight = Math.max(maxRequiredWidthRight, labelPadding + nameWidth + labelPadding + valueWidth + labelPadding);
|
| 312 |
+
});
|
| 313 |
+
|
| 314 |
+
let labelScaleFactor = 1;
|
| 315 |
+
if (maxRequiredWidthLeft > fixedBarPixelWidth) {
|
| 316 |
+
labelScaleFactor = Math.min(labelScaleFactor, (fixedBarPixelWidth / maxRequiredWidthLeft) * 0.98);
|
| 317 |
+
}
|
| 318 |
+
if (maxRequiredWidthRight > fixedBarPixelWidth) {
|
| 319 |
+
labelScaleFactor = Math.min(labelScaleFactor, (fixedBarPixelWidth / maxRequiredWidthRight) * 0.98);
|
| 320 |
+
}
|
| 321 |
+
finalLabelFontSize = calculateScaledFontSize(targetLabelFontSize, labelScaleFactor);
|
| 322 |
+
|
| 323 |
+
// 重新计算用于对齐的最大宽度
|
| 324 |
+
sortedDataLeft.forEach(d => {
|
| 325 |
+
const text = chartUtils.format.fixed(d.leftValue, 0, { unit: valueUnit }).text;
|
| 326 |
+
maxLeftValueWidth = Math.max(maxLeftValueWidth, measureTextWidth(text, labelFontFamily, finalLabelFontSize, labelFontWeight));
|
| 327 |
+
});
|
| 328 |
+
sortedDataRight.forEach(d => {
|
| 329 |
+
const text = chartUtils.format.fixed(d.rightValue, 0, { unit: valueUnit }).text;
|
| 330 |
+
maxRightValueWidth = Math.max(maxRightValueWidth, measureTextWidth(text, labelFontFamily, finalLabelFontSize, labelFontWeight));
|
| 331 |
+
});
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
// --- 绘制连接线 ---
|
| 335 |
+
if (maxCount > 0 && barHeight > 0) {
|
| 336 |
+
g.append("g")
|
| 337 |
+
.attr("class", "connectors")
|
| 338 |
+
.selectAll("path.connector")
|
| 339 |
+
.data(baseData.filter(d => d.leftValue !== null && d.rightValue !== null)) // 仅连接两侧都有数据的类别
|
| 340 |
+
.enter()
|
| 341 |
+
.append("path")
|
| 342 |
+
.attr("class", "connector")
|
| 343 |
+
.attr("d", d => {
|
| 344 |
+
const category = d.category;
|
| 345 |
+
const indexLeft = sortedCategoriesLeft.indexOf(category);
|
| 346 |
+
const indexRight = sortedCategoriesRight.indexOf(category);
|
| 347 |
+
if (indexLeft === -1 || indexRight === -1) return null; // 确保类别在两侧都存在
|
| 348 |
+
|
| 349 |
+
const yPosLeft = yScale(indexLeft);
|
| 350 |
+
const yPosRight = yScale(indexRight);
|
| 351 |
+
if (yPosLeft === undefined || yPosRight === undefined) return null;
|
| 352 |
+
|
| 353 |
+
// 连接点位于条形主体与圆角的切点
|
| 354 |
+
const leftConnectX = leftBarLeftEdge + fixedBarPixelWidth - barRadius;
|
| 355 |
+
const rightConnectX = rightBarLeftEdge + barRadius;
|
| 356 |
+
return `M${leftConnectX},${yPosLeft} L${rightConnectX},${yPosRight} L${rightConnectX},${yPosRight + barHeight} L${leftConnectX},${yPosLeft + barHeight} Z`;
|
| 357 |
+
})
|
| 358 |
+
.attr("fill", d => colorScale(d.colorGroup));
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
// --- 绘制左侧条形与标签 ---
|
| 362 |
+
if (maxCount > 0 && barHeight > 0) {
|
| 363 |
+
sortedDataLeft.forEach((d) => {
|
| 364 |
+
const category = d.category;
|
| 365 |
+
const index = sortedCategoriesLeft.indexOf(category);
|
| 366 |
+
const yPos = yScale(index);
|
| 367 |
+
if (yPos === undefined || index === -1) {
|
| 368 |
+
console.warn(`左侧类别 '${category}' 在Y轴上无位置`);
|
| 369 |
+
return;
|
| 370 |
+
}
|
| 371 |
+
const color = colorScale(d.colorGroup);
|
| 372 |
+
const leftVal = d.leftValue;
|
| 373 |
+
|
| 374 |
+
// 绘制条形
|
| 375 |
+
g.append("path")
|
| 376 |
+
.attr("class", "bar-left")
|
| 377 |
+
.attr("d", roundedRectPath(leftBarLeftEdge, yPos, fixedBarPixelWidth, barHeight, barRadius))
|
| 378 |
+
.attr("fill", color);
|
| 379 |
+
|
| 380 |
+
// 绘制标签 (如果字体够大)
|
| 381 |
+
if (finalLabelFontSize >= 6) {
|
| 382 |
+
const leftValueText = chartUtils.format.fixed(leftVal, 0, { unit: valueUnit }).text;
|
| 383 |
+
const leftNameText = category;
|
| 384 |
+
|
| 385 |
+
// 值标签 (左对齐)
|
| 386 |
+
g.append("text")
|
| 387 |
+
.attr("class", "label-left-value")
|
| 388 |
+
.attr("x", leftBarLeftEdge + labelPadding)
|
| 389 |
+
.attr("y", yPos + barHeight / 2)
|
| 390 |
+
.attr("dominant-baseline", "middle")
|
| 391 |
+
.attr("text-anchor", "start")
|
| 392 |
+
.style("font-family", labelFontFamily)
|
| 393 |
+
.style("font-size", `${finalLabelFontSize}px`)
|
| 394 |
+
.style("font-weight", labelFontWeight)
|
| 395 |
+
.style("fill", labelColor)
|
| 396 |
+
.text(leftValueText);
|
| 397 |
+
|
| 398 |
+
// 类别标签 (左对齐,在值标签右侧)
|
| 399 |
+
g.append("text")
|
| 400 |
+
.attr("class", "label-left-name")
|
| 401 |
+
.attr("x", leftBarLeftEdge + labelPadding + maxLeftValueWidth + labelPadding) // 基于最大值宽度定位
|
| 402 |
+
.attr("y", yPos + barHeight / 2)
|
| 403 |
+
.attr("dominant-baseline", "middle")
|
| 404 |
+
.attr("text-anchor", "start")
|
| 405 |
+
.style("font-family", labelFontFamily)
|
| 406 |
+
.style("font-size", `${finalLabelFontSize}px`)
|
| 407 |
+
.style("font-weight", labelFontWeight)
|
| 408 |
+
.style("fill", labelColor)
|
| 409 |
+
.text(leftNameText);
|
| 410 |
+
}
|
| 411 |
+
});
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
// --- 绘制右侧条形与标签 ---
|
| 415 |
+
if (maxCount > 0 && barHeight > 0) {
|
| 416 |
+
sortedDataRight.forEach((d) => {
|
| 417 |
+
const category = d.category;
|
| 418 |
+
const index = sortedCategoriesRight.indexOf(category);
|
| 419 |
+
const yPos = yScale(index);
|
| 420 |
+
if (yPos === undefined || index === -1) {
|
| 421 |
+
console.warn(`右侧类别 '${category}' 在Y轴上无位置`);
|
| 422 |
+
return;
|
| 423 |
+
}
|
| 424 |
+
const color = colorScale(d.colorGroup);
|
| 425 |
+
const rightVal = d.rightValue;
|
| 426 |
+
|
| 427 |
+
// 绘制条形
|
| 428 |
+
g.append("path")
|
| 429 |
+
.attr("class", "bar-right")
|
| 430 |
+
.attr("d", roundedRectPath(rightBarLeftEdge, yPos, fixedBarPixelWidth, barHeight, barRadius))
|
| 431 |
+
.attr("fill", color);
|
| 432 |
+
|
| 433 |
+
// 绘制标签 (如果字体够大)
|
| 434 |
+
if (finalLabelFontSize >= 6) {
|
| 435 |
+
const rightValueText = chartUtils.format.fixed(rightVal, 0, { unit: valueUnit }).text;
|
| 436 |
+
const rightNameText = category;
|
| 437 |
+
|
| 438 |
+
// 值标签 (右对齐)
|
| 439 |
+
g.append("text")
|
| 440 |
+
.attr("class", "label-right-value")
|
| 441 |
+
.attr("x", rightBarRightEdge - labelPadding)
|
| 442 |
+
.attr("y", yPos + barHeight / 2)
|
| 443 |
+
.attr("dominant-baseline", "middle")
|
| 444 |
+
.attr("text-anchor", "end")
|
| 445 |
+
.style("font-family", labelFontFamily)
|
| 446 |
+
.style("font-size", `${finalLabelFontSize}px`)
|
| 447 |
+
.style("font-weight", labelFontWeight)
|
| 448 |
+
.style("fill", labelColor)
|
| 449 |
+
.text(rightValueText);
|
| 450 |
+
|
| 451 |
+
// 类别标签 (右对齐,在值标签左侧)
|
| 452 |
+
g.append("text")
|
| 453 |
+
.attr("class", "label-right-name")
|
| 454 |
+
.attr("x", rightBarRightEdge - labelPadding - maxRightValueWidth - labelPadding) // 基于最大值宽度定位
|
| 455 |
+
.attr("y", yPos + barHeight / 2)
|
| 456 |
+
.attr("dominant-baseline", "middle")
|
| 457 |
+
.attr("text-anchor", "end")
|
| 458 |
+
.style("font-family", labelFontFamily)
|
| 459 |
+
.style("font-size", `${finalLabelFontSize}px`)
|
| 460 |
+
.style("font-weight", labelFontWeight)
|
| 461 |
+
.style("fill", labelColor)
|
| 462 |
+
.text(rightNameText);
|
| 463 |
+
}
|
| 464 |
+
});
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
// ---------- 11. 返回 SVG 节点 ----------
|
| 468 |
+
return svg.node();
|
| 469 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/bump_chart_02.js
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Bump Chart",
|
| 5 |
+
"chart_name": "bump_chart_02",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"chart_for": "comparison",
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [
|
| 11 |
+
[2, 12],
|
| 12 |
+
[0, "inf"],
|
| 13 |
+
[3, 10]
|
| 14 |
+
],
|
| 15 |
+
"required_fields_icons": [],
|
| 16 |
+
"required_other_icons": [],
|
| 17 |
+
"required_fields_colors": ["group"],
|
| 18 |
+
"required_other_colors": [],
|
| 19 |
+
"supported_effects": ["spacing"],
|
| 20 |
+
"min_height": 400,
|
| 21 |
+
"min_width": 400,
|
| 22 |
+
"background": "dark",
|
| 23 |
+
"icon_mark": "none",
|
| 24 |
+
"icon_label": "none",
|
| 25 |
+
"has_x_axis": "no",
|
| 26 |
+
"has_y_axis": "no"
|
| 27 |
+
}
|
| 28 |
+
REQUIREMENTS_END
|
| 29 |
+
*/
|
| 30 |
+
|
| 31 |
+
function makeChart(containerSelector, data) {
|
| 32 |
+
const jsonData = data || {};
|
| 33 |
+
const sourceRows = jsonData.data?.data || [];
|
| 34 |
+
const variables = jsonData.variables || {};
|
| 35 |
+
const typography = jsonData.typography || {};
|
| 36 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 37 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 38 |
+
const titles = jsonData.titles || {};
|
| 39 |
+
const metadata = jsonData.metadata || {};
|
| 40 |
+
|
| 41 |
+
d3.select(containerSelector).html("");
|
| 42 |
+
|
| 43 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 44 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 45 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 46 |
+
const yColumn = dataColumns[1] || {};
|
| 47 |
+
const yUnit = yColumn.unit === "none" ? "" : (yColumn.unit || "");
|
| 48 |
+
|
| 49 |
+
const width = Math.max(620, Number(variables.width) || 650);
|
| 50 |
+
const height = Math.max(900, Number(variables.height) || 975);
|
| 51 |
+
const bgTop = "#06111f";
|
| 52 |
+
const bgBottom = "#0b243d";
|
| 53 |
+
const panel = "#0d2238";
|
| 54 |
+
const panel2 = "#102b49";
|
| 55 |
+
const stroke = "#1f4268";
|
| 56 |
+
const text = "#eef7ff";
|
| 57 |
+
const muted = "#8fa8c5";
|
| 58 |
+
const grid = "#244b72";
|
| 59 |
+
const titleBlue = "#b8c9ff";
|
| 60 |
+
const font = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
|
| 61 |
+
const titleFont = typography.title?.font_family || font;
|
| 62 |
+
|
| 63 |
+
const svg = d3.select(containerSelector)
|
| 64 |
+
.append("svg")
|
| 65 |
+
.attr("width", "100%")
|
| 66 |
+
.attr("height", height)
|
| 67 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 68 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 69 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 70 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 71 |
+
.attr("class", "bump-chart-02-reference");
|
| 72 |
+
|
| 73 |
+
const defs = svg.append("defs");
|
| 74 |
+
const bgGrad = defs.append("linearGradient")
|
| 75 |
+
.attr("id", "bump-chart-02-bg")
|
| 76 |
+
.attr("x1", "0%").attr("x2", "0%")
|
| 77 |
+
.attr("y1", "0%").attr("y2", "100%");
|
| 78 |
+
bgGrad.append("stop").attr("offset", "0%").attr("stop-color", bgTop);
|
| 79 |
+
bgGrad.append("stop").attr("offset", "100%").attr("stop-color", bgBottom);
|
| 80 |
+
|
| 81 |
+
const glow = defs.append("filter")
|
| 82 |
+
.attr("id", "bump-chart-02-glow")
|
| 83 |
+
.attr("x", "-25%").attr("y", "-25%")
|
| 84 |
+
.attr("width", "150%").attr("height", "150%");
|
| 85 |
+
glow.append("feDropShadow")
|
| 86 |
+
.attr("dx", 0)
|
| 87 |
+
.attr("dy", 7)
|
| 88 |
+
.attr("stdDeviation", 9)
|
| 89 |
+
.attr("flood-color", "#020812")
|
| 90 |
+
.attr("flood-opacity", 0.45);
|
| 91 |
+
|
| 92 |
+
svg.append("rect")
|
| 93 |
+
.attr("width", width)
|
| 94 |
+
.attr("height", height)
|
| 95 |
+
.attr("fill", "url(#bump-chart-02-bg)");
|
| 96 |
+
|
| 97 |
+
function humanize(value) {
|
| 98 |
+
return String(value || "")
|
| 99 |
+
.replace(/_/g, " ")
|
| 100 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 101 |
+
.replace(/\s+/g, " ")
|
| 102 |
+
.trim();
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function formatValue(value, unit) {
|
| 106 |
+
const n = Number(value);
|
| 107 |
+
if (!Number.isFinite(n)) return "";
|
| 108 |
+
let out;
|
| 109 |
+
const abs = Math.abs(n);
|
| 110 |
+
if (abs >= 1000000) out = d3.format(".3~s")(n);
|
| 111 |
+
else if (abs >= 1000) out = d3.format(".3~s")(n);
|
| 112 |
+
else if (abs >= 100) out = d3.format(".0f")(n);
|
| 113 |
+
else if (abs >= 10) out = d3.format(".1f")(n).replace(/\.0$/, "");
|
| 114 |
+
else out = d3.format(".2f")(n).replace(/\.?0+$/, "");
|
| 115 |
+
if (unit === "%") return `${out}%`;
|
| 116 |
+
if (/^[$€£¥]/.test(unit || "")) return `${unit}${out}`;
|
| 117 |
+
return unit ? `${out}${unit}` : out;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function compactLabel(value, maxChars) {
|
| 121 |
+
const clean = humanize(value);
|
| 122 |
+
if (clean.length <= maxChars) return clean;
|
| 123 |
+
const words = clean.split(" ");
|
| 124 |
+
if (words.length > 1) {
|
| 125 |
+
const firstLast = `${words[0]} ${words[words.length - 1]}`;
|
| 126 |
+
if (firstLast.length <= maxChars) return firstLast;
|
| 127 |
+
}
|
| 128 |
+
return `${clean.slice(0, Math.max(4, maxChars - 2))}.`;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function wrapLines(value, maxChars, maxLines) {
|
| 132 |
+
const words = humanize(value).split(/\s+/).filter(Boolean);
|
| 133 |
+
const lines = [];
|
| 134 |
+
let cur = "";
|
| 135 |
+
words.forEach(word => {
|
| 136 |
+
const next = cur ? `${cur} ${word}` : word;
|
| 137 |
+
if (next.length <= maxChars || !cur) cur = next;
|
| 138 |
+
else {
|
| 139 |
+
lines.push(cur);
|
| 140 |
+
cur = word;
|
| 141 |
+
}
|
| 142 |
+
});
|
| 143 |
+
if (cur) lines.push(cur);
|
| 144 |
+
if (lines.length <= maxLines) return lines;
|
| 145 |
+
return [...lines.slice(0, maxLines - 1), compactLabel(lines.slice(maxLines - 1).join(" "), maxChars)];
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function appendWrappedText(selection, lines, x, lineHeight) {
|
| 149 |
+
selection.selectAll("tspan")
|
| 150 |
+
.data(lines)
|
| 151 |
+
.enter()
|
| 152 |
+
.append("tspan")
|
| 153 |
+
.attr("x", x)
|
| 154 |
+
.attr("dy", (d, i) => i === 0 ? 0 : lineHeight)
|
| 155 |
+
.text(d => d);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
function parseX(value) {
|
| 159 |
+
if (value instanceof Date) return value.getTime();
|
| 160 |
+
const numeric = Number(value);
|
| 161 |
+
if (Number.isFinite(numeric)) return numeric;
|
| 162 |
+
const parsed = Date.parse(value);
|
| 163 |
+
return Number.isFinite(parsed) ? parsed : String(value);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
const rows = sourceRows
|
| 167 |
+
.map((d, i) => ({
|
| 168 |
+
raw: d,
|
| 169 |
+
sourceIndex: i,
|
| 170 |
+
xRaw: d[xField],
|
| 171 |
+
xKey: String(d[xField]),
|
| 172 |
+
xSort: parseX(d[xField]),
|
| 173 |
+
y: Number(d[yField]),
|
| 174 |
+
group: String(d[groupField])
|
| 175 |
+
}))
|
| 176 |
+
.filter(d => d.group && d.xKey && Number.isFinite(d.y));
|
| 177 |
+
|
| 178 |
+
if (!xField || !yField || !groupField || !rows.length) {
|
| 179 |
+
svg.append("text")
|
| 180 |
+
.attr("x", width / 2)
|
| 181 |
+
.attr("y", height / 2)
|
| 182 |
+
.attr("text-anchor", "middle")
|
| 183 |
+
.attr("fill", text)
|
| 184 |
+
.style("font-family", font)
|
| 185 |
+
.style("font-size", "18px")
|
| 186 |
+
.text("Missing bump chart data");
|
| 187 |
+
return svg.node();
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
const xValues = Array.from(new Map(rows
|
| 191 |
+
.sort((a, b) => d3.ascending(a.xSort, b.xSort))
|
| 192 |
+
.map(d => [d.xKey, d.xRaw])).entries())
|
| 193 |
+
.map(([key, raw]) => ({ key, raw, sort: parseX(raw) }));
|
| 194 |
+
|
| 195 |
+
const byGroup = d3.group(rows, d => d.group);
|
| 196 |
+
const groups = Array.from(byGroup.keys())
|
| 197 |
+
.sort((a, b) => {
|
| 198 |
+
const aLast = byGroup.get(a).find(d => d.xKey === xValues[xValues.length - 1].key)?.y ?? d3.mean(byGroup.get(a), d => d.y);
|
| 199 |
+
const bLast = byGroup.get(b).find(d => d.xKey === xValues[xValues.length - 1].key)?.y ?? d3.mean(byGroup.get(b), d => d.y);
|
| 200 |
+
return d3.descending(aLast, bLast) || d3.ascending(a, b);
|
| 201 |
+
})
|
| 202 |
+
.slice(0, 10);
|
| 203 |
+
|
| 204 |
+
const palette = ["#4f8cff", "#9b5de5", "#2dd4bf", "#f9c74f", "#f37272", "#43aa8b", "#f8961e", "#577590", "#90be6d", "#f15bb5"];
|
| 205 |
+
function colorFor(group, index) {
|
| 206 |
+
return palette[index % palette.length];
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
const outer = 24;
|
| 210 |
+
const cardX = 34;
|
| 211 |
+
const cardY = 28;
|
| 212 |
+
const cardW = width - cardX * 2;
|
| 213 |
+
const cardH = height - cardY - outer;
|
| 214 |
+
svg.append("rect")
|
| 215 |
+
.attr("class", "bump-main-panel")
|
| 216 |
+
.attr("x", cardX)
|
| 217 |
+
.attr("y", cardY)
|
| 218 |
+
.attr("width", cardW)
|
| 219 |
+
.attr("height", cardH)
|
| 220 |
+
.attr("rx", 24)
|
| 221 |
+
.attr("fill", "rgba(7, 22, 39, 0.02)")
|
| 222 |
+
.attr("stroke", "none");
|
| 223 |
+
|
| 224 |
+
const header = svg.append("g")
|
| 225 |
+
.attr("class", "bump-header")
|
| 226 |
+
.attr("data-reserved-slot", "header_title_art_slot")
|
| 227 |
+
.attr("data-slot-anchor", "top-left-header")
|
| 228 |
+
.attr("data-collision-rule", "avoid-year-chips-and-mini-panels")
|
| 229 |
+
.attr("transform", `translate(${cardX + 28},${cardY + 50})`);
|
| 230 |
+
|
| 231 |
+
const titleText = titles.main_title || metadata.title || `${humanize(groupField)} over ${humanize(xField)}`;
|
| 232 |
+
const headlineText = /short passes/i.test(titleText)
|
| 233 |
+
? "for Short Passes Over Time"
|
| 234 |
+
: titleText.replace(/^Higher\s+/i, "");
|
| 235 |
+
const titleLines = wrapLines(headlineText, headlineText.length > 42 ? 24 : 30, 2);
|
| 236 |
+
header.append("text")
|
| 237 |
+
.attr("class", "title-kicker")
|
| 238 |
+
.attr("x", 0)
|
| 239 |
+
.attr("y", 0)
|
| 240 |
+
.attr("fill", "#ffffff")
|
| 241 |
+
.attr("opacity", 0.94)
|
| 242 |
+
.style("font-family", font)
|
| 243 |
+
.style("font-size", "13px")
|
| 244 |
+
.style("font-weight", 800)
|
| 245 |
+
.style("letter-spacing", "1.8px")
|
| 246 |
+
.text("HIGHER COMPLETION PERCENTAGES".slice(0, Math.max(14, Math.min(32, titleText.length))));
|
| 247 |
+
|
| 248 |
+
const title = header.append("text")
|
| 249 |
+
.attr("class", "main-title")
|
| 250 |
+
.attr("x", 0)
|
| 251 |
+
.attr("y", 28)
|
| 252 |
+
.style("font-family", titleFont)
|
| 253 |
+
.style("font-size", titleLines.length > 1 ? "34px" : "42px")
|
| 254 |
+
.style("font-weight", 900);
|
| 255 |
+
if (/short passes/i.test(headlineText)) {
|
| 256 |
+
title.append("tspan").attr("fill", "#ffffff").text("for ");
|
| 257 |
+
title.append("tspan").attr("fill", "#77a0ff").text("Short Passes");
|
| 258 |
+
title.append("tspan").attr("fill", "#ffffff").text(" Over Time");
|
| 259 |
+
} else {
|
| 260 |
+
title.selectAll("tspan")
|
| 261 |
+
.data(titleLines)
|
| 262 |
+
.enter()
|
| 263 |
+
.append("tspan")
|
| 264 |
+
.attr("x", 0)
|
| 265 |
+
.attr("dy", (d, i) => i === 0 ? 0 : 39)
|
| 266 |
+
.attr("fill", titleBlue)
|
| 267 |
+
.text(d => d);
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
header.append("line")
|
| 271 |
+
.attr("class", "title-divider")
|
| 272 |
+
.attr("x1", 0)
|
| 273 |
+
.attr("x2", 66)
|
| 274 |
+
.attr("y1", 74)
|
| 275 |
+
.attr("y2", 74)
|
| 276 |
+
.attr("stroke", "#69a0ff")
|
| 277 |
+
.attr("stroke-width", 3)
|
| 278 |
+
.attr("stroke-linecap", "round");
|
| 279 |
+
|
| 280 |
+
const subtitle = titles.sub_title || metadata.description || `Values by ${humanize(groupField)} over time.`;
|
| 281 |
+
const subtitleNode = header.append("text")
|
| 282 |
+
.attr("class", "subtitle")
|
| 283 |
+
.attr("x", 0)
|
| 284 |
+
.attr("y", 104)
|
| 285 |
+
.attr("fill", muted)
|
| 286 |
+
.style("font-family", font)
|
| 287 |
+
.style("font-size", "17px");
|
| 288 |
+
subtitleNode.selectAll("tspan")
|
| 289 |
+
.data(wrapLines(subtitle.replace(/\*\*/g, ""), 39, 2))
|
| 290 |
+
.enter()
|
| 291 |
+
.append("tspan")
|
| 292 |
+
.attr("x", 0)
|
| 293 |
+
.attr("dy", (d, i) => i === 0 ? 0 : 16)
|
| 294 |
+
.text(d => d);
|
| 295 |
+
|
| 296 |
+
const chartX = cardX + 164;
|
| 297 |
+
const chartW = cardW - 155;
|
| 298 |
+
const chipsY = cardY + 222;
|
| 299 |
+
const rowTop = cardY + 302;
|
| 300 |
+
const rowGap = 18;
|
| 301 |
+
const maxRows = Math.min(groups.length, 5);
|
| 302 |
+
const infoH = 86;
|
| 303 |
+
const available = height - rowTop - infoH - 74;
|
| 304 |
+
const rowH = Math.max(78, Math.min(150, (available - rowGap * (maxRows - 1)) / maxRows));
|
| 305 |
+
const rowW = chartW;
|
| 306 |
+
const xScale = d3.scalePoint()
|
| 307 |
+
.domain(xValues.map(d => d.key))
|
| 308 |
+
.range([50, rowW - 40])
|
| 309 |
+
.padding(0.16);
|
| 310 |
+
|
| 311 |
+
const chipLayer = svg.append("g")
|
| 312 |
+
.attr("class", "year-chip-row")
|
| 313 |
+
.attr("transform", `translate(${chartX},${chipsY})`);
|
| 314 |
+
chipLayer.append("rect")
|
| 315 |
+
.attr("class", "year-chip-rail")
|
| 316 |
+
.attr("x", 0)
|
| 317 |
+
.attr("y", -21)
|
| 318 |
+
.attr("width", rowW)
|
| 319 |
+
.attr("height", 42)
|
| 320 |
+
.attr("rx", 16)
|
| 321 |
+
.attr("fill", "#102844")
|
| 322 |
+
.attr("stroke", "#2b527c")
|
| 323 |
+
.attr("opacity", 0.9);
|
| 324 |
+
chipLayer.selectAll(".year-chip-divider")
|
| 325 |
+
.data(xValues.slice(1))
|
| 326 |
+
.enter()
|
| 327 |
+
.append("line")
|
| 328 |
+
.attr("class", "year-chip-divider")
|
| 329 |
+
.attr("x1", d => (xScale(d.key) + xScale(xValues[xValues.findIndex(v => v.key === d.key) - 1].key)) / 2)
|
| 330 |
+
.attr("x2", d => (xScale(d.key) + xScale(xValues[xValues.findIndex(v => v.key === d.key) - 1].key)) / 2)
|
| 331 |
+
.attr("y1", -15)
|
| 332 |
+
.attr("y2", 15)
|
| 333 |
+
.attr("stroke", "#27496e")
|
| 334 |
+
.attr("opacity", 0.4);
|
| 335 |
+
chipLayer.selectAll(".year-chip")
|
| 336 |
+
.data(xValues)
|
| 337 |
+
.enter()
|
| 338 |
+
.append("g")
|
| 339 |
+
.attr("class", "year-chip")
|
| 340 |
+
.attr("transform", d => `translate(${xScale(d.key)},0)`)
|
| 341 |
+
.each(function(d) {
|
| 342 |
+
const g = d3.select(this);
|
| 343 |
+
g.append("text")
|
| 344 |
+
.attr("text-anchor", "middle")
|
| 345 |
+
.attr("y", 4)
|
| 346 |
+
.attr("fill", "#ffffff")
|
| 347 |
+
.style("font-family", font)
|
| 348 |
+
.style("font-size", "14px")
|
| 349 |
+
.style("font-weight", 500)
|
| 350 |
+
.text(d.raw);
|
| 351 |
+
});
|
| 352 |
+
|
| 353 |
+
function rowDomainAndTicks(series) {
|
| 354 |
+
const vals = series.map(d => d.y).filter(Number.isFinite);
|
| 355 |
+
const extent = d3.extent(vals);
|
| 356 |
+
if (!extent || !Number.isFinite(extent[0]) || !Number.isFinite(extent[1])) {
|
| 357 |
+
return { domain: [0, 1], ticks: [1, 0.5, 0] };
|
| 358 |
+
}
|
| 359 |
+
if (extent[0] >= 0 && extent[1] <= 100) {
|
| 360 |
+
const step = 20;
|
| 361 |
+
let high = Math.ceil((extent[1] + step * 0.25) / step) * step;
|
| 362 |
+
high = Math.max(step * 2, high);
|
| 363 |
+
let low = high - step * 2;
|
| 364 |
+
while (extent[0] < low && low > 0) low -= step;
|
| 365 |
+
if (low === high) low = Math.max(0, high - step * 2);
|
| 366 |
+
const mid = (high + low) / 2;
|
| 367 |
+
return { domain: [low, high], ticks: [high, mid, low] };
|
| 368 |
+
}
|
| 369 |
+
const pad = Math.max(1, (extent[1] - extent[0]) * 0.18);
|
| 370 |
+
const scale = d3.scaleLinear().domain([extent[0] - pad, extent[1] + pad]).nice(3);
|
| 371 |
+
const ticks = scale.ticks(3).slice(0, 3).sort((a, b) => d3.descending(a, b));
|
| 372 |
+
return { domain: scale.domain(), ticks };
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
const rowLayer = svg.append("g").attr("class", "bump-row-layer");
|
| 376 |
+
if (maxRows > 1) {
|
| 377 |
+
svg.append("line")
|
| 378 |
+
.attr("class", "left-icon-rail")
|
| 379 |
+
.attr("x1", cardX + 42)
|
| 380 |
+
.attr("x2", cardX + 42)
|
| 381 |
+
.attr("y1", rowTop + rowH / 2)
|
| 382 |
+
.attr("y2", rowTop + (maxRows - 1) * (rowH + rowGap) + rowH / 2)
|
| 383 |
+
.attr("stroke", "#27496e")
|
| 384 |
+
.attr("stroke-width", 2)
|
| 385 |
+
.attr("opacity", 0.8);
|
| 386 |
+
}
|
| 387 |
+
const finishSlot = svg.append("g")
|
| 388 |
+
.attr("class", "left-finish-marker-slot")
|
| 389 |
+
.attr("data-reserved-slot", "left_finish_marker_slot")
|
| 390 |
+
.attr("data-asset-slot", "left_finish_marker_slot")
|
| 391 |
+
.attr("data-slot-anchor", "upper-left-before-first-row")
|
| 392 |
+
.attr("data-collision-rule", "avoid-header-subtitle-year-rail-and-first-row-label")
|
| 393 |
+
.attr("transform", `translate(${cardX + 3},${rowTop - 82})`);
|
| 394 |
+
finishSlot.append("path")
|
| 395 |
+
.attr("d", "M0,12 L104,0 L132,19 L104,38 L0,50 Z")
|
| 396 |
+
.attr("fill", "#c7435b")
|
| 397 |
+
.attr("opacity", 0.42)
|
| 398 |
+
.attr("stroke", "#ff8aa0")
|
| 399 |
+
.attr("stroke-width", 1);
|
| 400 |
+
finishSlot.append("rect")
|
| 401 |
+
.attr("x", 34)
|
| 402 |
+
.attr("y", 46)
|
| 403 |
+
.attr("width", 9)
|
| 404 |
+
.attr("height", 42)
|
| 405 |
+
.attr("rx", 3)
|
| 406 |
+
.attr("fill", "#d1a23b")
|
| 407 |
+
.attr("opacity", 0.38);
|
| 408 |
+
|
| 409 |
+
groups.slice(0, maxRows).forEach((group, index) => {
|
| 410 |
+
const color = colorFor(group, index);
|
| 411 |
+
const rowY = rowTop + index * (rowH + rowGap);
|
| 412 |
+
const rowGroup = rowLayer.append("g")
|
| 413 |
+
.attr("class", "series-row")
|
| 414 |
+
.attr("data-group", group)
|
| 415 |
+
.attr("transform", `translate(${chartX},${rowY})`);
|
| 416 |
+
|
| 417 |
+
rowGroup.append("rect")
|
| 418 |
+
.attr("class", "series-card")
|
| 419 |
+
.attr("x", 0)
|
| 420 |
+
.attr("y", 0)
|
| 421 |
+
.attr("width", rowW)
|
| 422 |
+
.attr("height", rowH)
|
| 423 |
+
.attr("rx", 13)
|
| 424 |
+
.attr("fill", panel)
|
| 425 |
+
.attr("stroke", stroke);
|
| 426 |
+
|
| 427 |
+
rowGroup.append("rect")
|
| 428 |
+
.attr("x", 0)
|
| 429 |
+
.attr("y", 0)
|
| 430 |
+
.attr("width", rowW)
|
| 431 |
+
.attr("height", rowH)
|
| 432 |
+
.attr("rx", 13)
|
| 433 |
+
.attr("fill", color)
|
| 434 |
+
.attr("opacity", 0.08);
|
| 435 |
+
|
| 436 |
+
const icon = svg.append("g")
|
| 437 |
+
.attr("class", "group-icon-slot")
|
| 438 |
+
.attr("data-reserved-slot", `group_icon_slot_${index + 1}`)
|
| 439 |
+
.attr("data-asset-slot", "group_icon_slot_N")
|
| 440 |
+
.attr("data-slot-anchor", "row-centerline-left")
|
| 441 |
+
.attr("data-collision-rule", "inside-left-label-column")
|
| 442 |
+
.attr("transform", `translate(${cardX + 42},${rowY + rowH / 2})`);
|
| 443 |
+
icon.append("circle")
|
| 444 |
+
.attr("r", 26)
|
| 445 |
+
.attr("fill", color)
|
| 446 |
+
.attr("opacity", 0.95);
|
| 447 |
+
icon.append("circle")
|
| 448 |
+
.attr("r", 11)
|
| 449 |
+
.attr("fill", "none")
|
| 450 |
+
.attr("stroke", "#ffffff")
|
| 451 |
+
.attr("stroke-width", 2)
|
| 452 |
+
.attr("opacity", 0.82);
|
| 453 |
+
svg.append("text")
|
| 454 |
+
.attr("class", "left-group-label")
|
| 455 |
+
.attr("x", cardX + 86)
|
| 456 |
+
.attr("y", rowY + rowH / 2 - 3)
|
| 457 |
+
.attr("fill", color)
|
| 458 |
+
.style("font-family", font)
|
| 459 |
+
.style("font-size", "14px")
|
| 460 |
+
.style("font-weight", 900)
|
| 461 |
+
.text(compactLabel(group, 12).toUpperCase());
|
| 462 |
+
const desc = svg.append("text")
|
| 463 |
+
.attr("class", "left-group-description")
|
| 464 |
+
.attr("x", cardX + 86)
|
| 465 |
+
.attr("y", rowY + rowH / 2 + 15)
|
| 466 |
+
.attr("fill", muted)
|
| 467 |
+
.style("font-family", font)
|
| 468 |
+
.style("font-size", "10px");
|
| 469 |
+
const descText = /pass/i.test(titleText)
|
| 470 |
+
? `${humanize(group)} passes completion ${yUnit || ""}`
|
| 471 |
+
: `${humanize(group)} ${humanize(yField).toLowerCase()} ${yUnit || ""}`;
|
| 472 |
+
appendWrappedText(desc, wrapLines(descText, 15, 2), cardX + 86, 13);
|
| 473 |
+
|
| 474 |
+
const series = xValues.map(x => {
|
| 475 |
+
const found = byGroup.get(group).find(d => d.xKey === x.key);
|
| 476 |
+
return found || { xKey: x.key, xRaw: x.raw, y: null, group };
|
| 477 |
+
}).filter(d => Number.isFinite(d.y));
|
| 478 |
+
const rowGuide = rowDomainAndTicks(series);
|
| 479 |
+
const localY = d3.scaleLinear()
|
| 480 |
+
.domain(rowGuide.domain)
|
| 481 |
+
.range([rowH - 24, 26]);
|
| 482 |
+
const line = d3.line()
|
| 483 |
+
.defined(d => Number.isFinite(d.y))
|
| 484 |
+
.x(d => xScale(d.xKey))
|
| 485 |
+
.y(d => localY(d.y))
|
| 486 |
+
.curve(d3.curveMonotoneX);
|
| 487 |
+
|
| 488 |
+
rowGroup.selectAll(".v-grid")
|
| 489 |
+
.data(xValues)
|
| 490 |
+
.enter()
|
| 491 |
+
.append("line")
|
| 492 |
+
.attr("class", "v-grid")
|
| 493 |
+
.attr("x1", d => xScale(d.key))
|
| 494 |
+
.attr("x2", d => xScale(d.key))
|
| 495 |
+
.attr("y1", 18)
|
| 496 |
+
.attr("y2", rowH - 20)
|
| 497 |
+
.attr("stroke", grid)
|
| 498 |
+
.attr("opacity", 0.32)
|
| 499 |
+
.attr("stroke-dasharray", "3 5");
|
| 500 |
+
|
| 501 |
+
rowGroup.selectAll(".row-y-tick")
|
| 502 |
+
.data(rowGuide.ticks)
|
| 503 |
+
.enter()
|
| 504 |
+
.append("text")
|
| 505 |
+
.attr("class", "row-y-tick")
|
| 506 |
+
.attr("x", 10)
|
| 507 |
+
.attr("y", d => localY(d) + 4)
|
| 508 |
+
.attr("fill", "#6f88a7")
|
| 509 |
+
.style("font-family", font)
|
| 510 |
+
.style("font-size", "12px")
|
| 511 |
+
.text(d => formatValue(d, ""));
|
| 512 |
+
|
| 513 |
+
rowGroup.append("path")
|
| 514 |
+
.datum(series)
|
| 515 |
+
.attr("class", "series-line")
|
| 516 |
+
.attr("fill", "none")
|
| 517 |
+
.attr("stroke", color)
|
| 518 |
+
.attr("stroke-width", 4)
|
| 519 |
+
.attr("d", line);
|
| 520 |
+
|
| 521 |
+
rowGroup.selectAll(".series-point")
|
| 522 |
+
.data(series)
|
| 523 |
+
.enter()
|
| 524 |
+
.append("circle")
|
| 525 |
+
.attr("class", "series-point")
|
| 526 |
+
.attr("cx", d => xScale(d.xKey))
|
| 527 |
+
.attr("cy", d => localY(d.y))
|
| 528 |
+
.attr("r", 7.5)
|
| 529 |
+
.attr("fill", color)
|
| 530 |
+
.attr("stroke", "#dbeafe")
|
| 531 |
+
.attr("stroke-width", 2);
|
| 532 |
+
|
| 533 |
+
rowGroup.selectAll(".value-label")
|
| 534 |
+
.data(series)
|
| 535 |
+
.enter()
|
| 536 |
+
.append("text")
|
| 537 |
+
.attr("class", "value-label")
|
| 538 |
+
.attr("x", d => xScale(d.xKey))
|
| 539 |
+
.attr("y", d => localY(d.y) + (localY(d.y) < 36 ? 18 : -12))
|
| 540 |
+
.attr("text-anchor", "middle")
|
| 541 |
+
.attr("fill", "#cfe0f7")
|
| 542 |
+
.style("font-family", font)
|
| 543 |
+
.style("font-size", "12px")
|
| 544 |
+
.style("font-weight", 800)
|
| 545 |
+
.text(d => formatValue(d.y, ""));
|
| 546 |
+
|
| 547 |
+
});
|
| 548 |
+
|
| 549 |
+
const infoY = rowTop + maxRows * rowH + (maxRows - 1) * rowGap + 28;
|
| 550 |
+
const info = svg.append("g")
|
| 551 |
+
.attr("class", "bottom-info-card")
|
| 552 |
+
.attr("transform", `translate(${cardX + 14},${infoY})`);
|
| 553 |
+
info.append("rect")
|
| 554 |
+
.attr("width", cardW - 28)
|
| 555 |
+
.attr("height", infoH)
|
| 556 |
+
.attr("rx", 14)
|
| 557 |
+
.attr("fill", panel2)
|
| 558 |
+
.attr("stroke", stroke)
|
| 559 |
+
.attr("opacity", 0.94);
|
| 560 |
+
const infoIcon = info.append("g")
|
| 561 |
+
.attr("class", "bottom-info-icon-slot")
|
| 562 |
+
.attr("data-reserved-slot", "bottom_info_icon_slot")
|
| 563 |
+
.attr("data-asset-slot", "bottom_info_icon_slot")
|
| 564 |
+
.attr("data-slot-anchor", "bottom-card-left")
|
| 565 |
+
.attr("data-collision-rule", "avoid-bottom-card-text")
|
| 566 |
+
.attr("transform", "translate(54,43)");
|
| 567 |
+
infoIcon.append("circle")
|
| 568 |
+
.attr("r", 20)
|
| 569 |
+
.attr("fill", "#244b72");
|
| 570 |
+
infoIcon.append("path")
|
| 571 |
+
.attr("d", "M-8,7 L-8,-1 L-2,-1 L-2,-9 L4,-9 L4,7 M-14,7 L14,7")
|
| 572 |
+
.attr("fill", "none")
|
| 573 |
+
.attr("stroke", "#dbeafe")
|
| 574 |
+
.attr("stroke-width", 2)
|
| 575 |
+
.attr("stroke-linecap", "round");
|
| 576 |
+
|
| 577 |
+
const lastKey = xValues[xValues.length - 1].key;
|
| 578 |
+
const leader = groups.map(group => byGroup.get(group).find(d => d.xKey === lastKey))
|
| 579 |
+
.filter(Boolean)
|
| 580 |
+
.sort((a, b) => d3.descending(a.y, b.y))[0];
|
| 581 |
+
const topGroup = leader ? humanize(leader.group) : humanize(groups[0]);
|
| 582 |
+
const firstInfoLine = /pass/i.test(titleText)
|
| 583 |
+
? `${topGroup} passes have the highest completion rates`
|
| 584 |
+
: `${topGroup} leads at ${leader ? formatValue(leader.y, yUnit) : ""} ${leader ? `in ${leader.xRaw}` : ""}.`;
|
| 585 |
+
const remainingGroups = groups.filter(g => g !== (leader && leader.group));
|
| 586 |
+
function trendPhrase(group) {
|
| 587 |
+
const vals = xValues.map(x => byGroup.get(group).find(d => d.xKey === x.key)?.y).filter(Number.isFinite);
|
| 588 |
+
const diffs = vals.slice(1).map((v, i) => v - vals[i]);
|
| 589 |
+
if (diffs.length && diffs.every(d => d >= -0.01)) return `${humanize(group)} shows steady improvement`;
|
| 590 |
+
if (diffs.some(d => d > 0) && diffs.some(d => d < 0)) return `${humanize(group)} remains more variable`;
|
| 591 |
+
if (diffs.length && diffs.every(d => d <= 0.01)) return `${humanize(group)} trends lower`;
|
| 592 |
+
return `${humanize(group)} remains lower`;
|
| 593 |
+
}
|
| 594 |
+
const secondInfoLine = remainingGroups.length >= 2
|
| 595 |
+
? `while ${trendPhrase(remainingGroups[0])} and ${trendPhrase(remainingGroups[1])}.`
|
| 596 |
+
: `across ${xValues.length} time points.`;
|
| 597 |
+
const infoTitle = info.append("text")
|
| 598 |
+
.attr("class", "bottom-info-title")
|
| 599 |
+
.attr("x", 104)
|
| 600 |
+
.attr("y", 30)
|
| 601 |
+
.attr("fill", "#78a0ff")
|
| 602 |
+
.style("font-family", font)
|
| 603 |
+
.style("font-size", "14px")
|
| 604 |
+
.style("font-weight", 800);
|
| 605 |
+
appendWrappedText(infoTitle, wrapLines(firstInfoLine, 48, 1), 104, 17);
|
| 606 |
+
const infoSubtitle = info.append("text")
|
| 607 |
+
.attr("class", "bottom-info-subtitle")
|
| 608 |
+
.attr("x", 104)
|
| 609 |
+
.attr("y", 52)
|
| 610 |
+
.attr("fill", "#ffffff")
|
| 611 |
+
.style("font-family", font)
|
| 612 |
+
.style("font-size", "11px")
|
| 613 |
+
.style("font-weight", 400);
|
| 614 |
+
appendWrappedText(infoSubtitle, wrapLines(secondInfoLine, 58, 2), 104, 15);
|
| 615 |
+
|
| 616 |
+
return svg.node();
|
| 617 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/bump_chart_03.js
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Bump Chart",
|
| 5 |
+
"chart_name": "bump_chart_03",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"chart_for": "comparison",
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [
|
| 11 |
+
[2, 12],
|
| 12 |
+
[0, "inf"],
|
| 13 |
+
[3, 10]
|
| 14 |
+
],
|
| 15 |
+
"required_fields_icons": [],
|
| 16 |
+
"required_other_icons": [],
|
| 17 |
+
"required_fields_colors": ["group"],
|
| 18 |
+
"required_other_colors": [],
|
| 19 |
+
"supported_effects": ["spacing"],
|
| 20 |
+
"min_height": 400,
|
| 21 |
+
"min_width": 400,
|
| 22 |
+
"background": "dark",
|
| 23 |
+
"icon_mark": "none",
|
| 24 |
+
"icon_label": "none",
|
| 25 |
+
"has_x_axis": "no",
|
| 26 |
+
"has_y_axis": "no"
|
| 27 |
+
}
|
| 28 |
+
REQUIREMENTS_END
|
| 29 |
+
*/
|
| 30 |
+
|
| 31 |
+
function makeChart(containerSelector, data) {
|
| 32 |
+
const jsonData = data || {};
|
| 33 |
+
const sourceRows = jsonData.data?.data || [];
|
| 34 |
+
const variables = jsonData.variables || {};
|
| 35 |
+
const typography = jsonData.typography || {};
|
| 36 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 37 |
+
const titles = jsonData.titles || {};
|
| 38 |
+
const metadata = jsonData.metadata || {};
|
| 39 |
+
|
| 40 |
+
d3.select(containerSelector).html("");
|
| 41 |
+
|
| 42 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 43 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 44 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 45 |
+
const yColumn = dataColumns[1] || {};
|
| 46 |
+
const yUnit = yColumn.unit === "none" ? "" : (yColumn.unit || "");
|
| 47 |
+
|
| 48 |
+
const width = Math.max(620, Number(variables.width) || 650);
|
| 49 |
+
const height = Math.max(900, Number(variables.height) || 975);
|
| 50 |
+
const font = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
|
| 51 |
+
const titleFont = typography.title?.font_family || font;
|
| 52 |
+
|
| 53 |
+
const colors = {
|
| 54 |
+
bgTop: "#061024",
|
| 55 |
+
bgMid: "#061a32",
|
| 56 |
+
bgBottom: "#081427",
|
| 57 |
+
panel: "#0a1a31",
|
| 58 |
+
panel2: "#0d223d",
|
| 59 |
+
stroke: "#24395f",
|
| 60 |
+
text: "#f5f7ff",
|
| 61 |
+
muted: "#b5c1d8",
|
| 62 |
+
rail: "#0e203d"
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const svg = d3.select(containerSelector)
|
| 66 |
+
.append("svg")
|
| 67 |
+
.attr("width", "100%")
|
| 68 |
+
.attr("height", height)
|
| 69 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 70 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 71 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 72 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 73 |
+
.attr("class", "bump-chart-03-reference");
|
| 74 |
+
|
| 75 |
+
const defs = svg.append("defs");
|
| 76 |
+
const bg = defs.append("radialGradient")
|
| 77 |
+
.attr("id", "bump-chart-03-bg")
|
| 78 |
+
.attr("cx", "50%")
|
| 79 |
+
.attr("cy", "30%")
|
| 80 |
+
.attr("r", "80%");
|
| 81 |
+
bg.append("stop").attr("offset", "0%").attr("stop-color", colors.bgMid);
|
| 82 |
+
bg.append("stop").attr("offset", "62%").attr("stop-color", colors.bgTop);
|
| 83 |
+
bg.append("stop").attr("offset", "100%").attr("stop-color", colors.bgBottom);
|
| 84 |
+
|
| 85 |
+
const glow = defs.append("filter")
|
| 86 |
+
.attr("id", "bump-chart-03-glow")
|
| 87 |
+
.attr("x", "-30%")
|
| 88 |
+
.attr("y", "-30%")
|
| 89 |
+
.attr("width", "160%")
|
| 90 |
+
.attr("height", "160%");
|
| 91 |
+
glow.append("feDropShadow")
|
| 92 |
+
.attr("dx", 0)
|
| 93 |
+
.attr("dy", 8)
|
| 94 |
+
.attr("stdDeviation", 10)
|
| 95 |
+
.attr("flood-color", "#020915")
|
| 96 |
+
.attr("flood-opacity", 0.48);
|
| 97 |
+
|
| 98 |
+
svg.append("rect")
|
| 99 |
+
.attr("width", width)
|
| 100 |
+
.attr("height", height)
|
| 101 |
+
.attr("fill", "url(#bump-chart-03-bg)");
|
| 102 |
+
|
| 103 |
+
function humanize(value) {
|
| 104 |
+
return String(value || "")
|
| 105 |
+
.replace(/_/g, " ")
|
| 106 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 107 |
+
.replace(/\s+/g, " ")
|
| 108 |
+
.trim();
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function compactLabel(value, maxChars) {
|
| 112 |
+
const clean = humanize(value);
|
| 113 |
+
if (clean.length <= maxChars) return clean;
|
| 114 |
+
const words = clean.split(/\s+/);
|
| 115 |
+
if (words.length > 1) {
|
| 116 |
+
const firstLast = `${words[0]} ${words[words.length - 1]}`;
|
| 117 |
+
if (firstLast.length <= maxChars) return firstLast;
|
| 118 |
+
}
|
| 119 |
+
return `${clean.slice(0, Math.max(4, maxChars - 1))}.`;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function wrapLines(value, maxChars, maxLines) {
|
| 123 |
+
const words = humanize(value).split(/\s+/).filter(Boolean);
|
| 124 |
+
const lines = [];
|
| 125 |
+
let cur = "";
|
| 126 |
+
words.forEach(word => {
|
| 127 |
+
const next = cur ? `${cur} ${word}` : word;
|
| 128 |
+
if (next.length <= maxChars || !cur) cur = next;
|
| 129 |
+
else {
|
| 130 |
+
lines.push(cur);
|
| 131 |
+
cur = word;
|
| 132 |
+
}
|
| 133 |
+
});
|
| 134 |
+
if (cur) lines.push(cur);
|
| 135 |
+
if (lines.length <= maxLines) return lines;
|
| 136 |
+
return [...lines.slice(0, maxLines - 1), compactLabel(lines.slice(maxLines - 1).join(" "), maxChars)];
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function appendTspans(textNode, lines, x, lineHeight) {
|
| 140 |
+
textNode.selectAll("tspan")
|
| 141 |
+
.data(lines)
|
| 142 |
+
.enter()
|
| 143 |
+
.append("tspan")
|
| 144 |
+
.attr("x", x)
|
| 145 |
+
.attr("dy", (d, i) => i === 0 ? 0 : lineHeight)
|
| 146 |
+
.text(d => d);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
function parseX(value) {
|
| 150 |
+
if (value instanceof Date) return value.getTime();
|
| 151 |
+
const n = Number(value);
|
| 152 |
+
if (Number.isFinite(n)) return n;
|
| 153 |
+
const parsed = Date.parse(value);
|
| 154 |
+
return Number.isFinite(parsed) ? parsed : String(value);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
function formatValue(value, unit) {
|
| 158 |
+
const n = Number(value);
|
| 159 |
+
if (!Number.isFinite(n)) return "";
|
| 160 |
+
let out;
|
| 161 |
+
const abs = Math.abs(n);
|
| 162 |
+
if (abs >= 1000000) out = d3.format(".3~s")(n);
|
| 163 |
+
else if (abs >= 1000) out = d3.format(".3~s")(n);
|
| 164 |
+
else if (abs >= 100) out = d3.format(".0f")(n);
|
| 165 |
+
else if (abs >= 10) out = d3.format(".1f")(n).replace(/\.0$/, "");
|
| 166 |
+
else out = d3.format(".2f")(n).replace(/\.?0+$/, "");
|
| 167 |
+
if (unit === "%") return `${out}%`;
|
| 168 |
+
if (/^[$€£¥]/.test(unit || "")) return `${unit}${out}`;
|
| 169 |
+
return unit ? `${out}${unit}` : out;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
const rows = sourceRows.map((d, i) => ({
|
| 173 |
+
raw: d,
|
| 174 |
+
sourceIndex: i,
|
| 175 |
+
xRaw: d[xField],
|
| 176 |
+
xKey: String(d[xField]),
|
| 177 |
+
xSort: parseX(d[xField]),
|
| 178 |
+
y: Number(d[yField]),
|
| 179 |
+
group: String(d[groupField])
|
| 180 |
+
})).filter(d => d.group && d.xKey && Number.isFinite(d.y));
|
| 181 |
+
|
| 182 |
+
if (!xField || !yField || !groupField || !rows.length) {
|
| 183 |
+
svg.append("text")
|
| 184 |
+
.attr("x", width / 2)
|
| 185 |
+
.attr("y", height / 2)
|
| 186 |
+
.attr("text-anchor", "middle")
|
| 187 |
+
.attr("fill", colors.text)
|
| 188 |
+
.style("font-family", font)
|
| 189 |
+
.style("font-size", "18px")
|
| 190 |
+
.text("Missing bump chart data");
|
| 191 |
+
return svg.node();
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
const xValues = Array.from(new Map(rows
|
| 195 |
+
.sort((a, b) => d3.ascending(a.xSort, b.xSort))
|
| 196 |
+
.map(d => [d.xKey, d.xRaw])).entries())
|
| 197 |
+
.map(([key, raw]) => ({ key, raw, sort: parseX(raw) }));
|
| 198 |
+
|
| 199 |
+
const byGroup = d3.group(rows, d => d.group);
|
| 200 |
+
const lastKey = xValues[xValues.length - 1].key;
|
| 201 |
+
const groups = Array.from(byGroup.keys())
|
| 202 |
+
.sort((a, b) => {
|
| 203 |
+
const aLast = byGroup.get(a).find(d => d.xKey === lastKey)?.y ?? d3.mean(byGroup.get(a), d => d.y);
|
| 204 |
+
const bLast = byGroup.get(b).find(d => d.xKey === lastKey)?.y ?? d3.mean(byGroup.get(b), d => d.y);
|
| 205 |
+
return d3.descending(aLast, bLast) || d3.ascending(a, b);
|
| 206 |
+
});
|
| 207 |
+
const maxRows = Math.min(groups.length, 6);
|
| 208 |
+
const visibleGroups = groups.slice(0, maxRows);
|
| 209 |
+
|
| 210 |
+
const palette = ["#ffd22e", "#3c93ff", "#22d4d6", "#a56bff", "#ff6b6b", "#78c850"];
|
| 211 |
+
function colorFor(index) {
|
| 212 |
+
return palette[index % palette.length];
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
const titleText = titles.main_title || metadata.title || `${humanize(groupField)} over ${humanize(xField)}`;
|
| 216 |
+
const subtitleText = titles.sub_title || metadata.description || `Values by ${humanize(groupField)} over ${humanize(xField)}.`;
|
| 217 |
+
|
| 218 |
+
const headerX = 40;
|
| 219 |
+
const headerY = 56;
|
| 220 |
+
const header = svg.append("g")
|
| 221 |
+
.attr("class", "bump03-header")
|
| 222 |
+
.attr("data-reserved-slot", "header_title_art_slot")
|
| 223 |
+
.attr("data-slot-anchor", "top-left-header")
|
| 224 |
+
.attr("data-collision-rule", "avoid-header-illustration-year-rail-and-rows")
|
| 225 |
+
.attr("transform", `translate(${headerX},${headerY})`);
|
| 226 |
+
|
| 227 |
+
const kickerWords = humanize(titleText).split(/\s+/).slice(0, 3).join(" ").toUpperCase();
|
| 228 |
+
const kicker = header.append("g")
|
| 229 |
+
.attr("class", "header-kicker-pill");
|
| 230 |
+
kicker.append("rect")
|
| 231 |
+
.attr("x", 0)
|
| 232 |
+
.attr("y", 0)
|
| 233 |
+
.attr("width", 280)
|
| 234 |
+
.attr("height", 34)
|
| 235 |
+
.attr("rx", 17)
|
| 236 |
+
.attr("fill", "#273179")
|
| 237 |
+
.attr("stroke", "#6d61d7")
|
| 238 |
+
.attr("opacity", 0.95);
|
| 239 |
+
kicker.append("g")
|
| 240 |
+
.attr("data-asset-slot", "header_reaction_icon_slots")
|
| 241 |
+
.attr("data-slot-anchor", "inside-kicker-left")
|
| 242 |
+
.attr("data-collision-rule", "inside-kicker")
|
| 243 |
+
.attr("transform", "translate(22,17)")
|
| 244 |
+
.call(g => {
|
| 245 |
+
g.append("circle").attr("r", 9).attr("fill", "#ffffff").attr("opacity", 0.95);
|
| 246 |
+
g.append("path").attr("d", "M-5,0 H5 M0,-5 V5").attr("stroke", "#273179").attr("stroke-width", 2).attr("stroke-linecap", "round");
|
| 247 |
+
});
|
| 248 |
+
const kickerLabel = compactLabel(kickerWords, 28).toUpperCase();
|
| 249 |
+
kicker.append("text")
|
| 250 |
+
.attr("x", 48)
|
| 251 |
+
.attr("y", 22)
|
| 252 |
+
.attr("fill", "#f5f7ff")
|
| 253 |
+
.style("font-family", font)
|
| 254 |
+
.style("font-size", kickerLabel.length > 24 ? "10px" : "13px")
|
| 255 |
+
.style("font-weight", 800)
|
| 256 |
+
.style("letter-spacing", "0.6px")
|
| 257 |
+
.text(kickerLabel);
|
| 258 |
+
|
| 259 |
+
const titleMatch = humanize(titleText).match(/\bin\s+(.+)/i);
|
| 260 |
+
const headline = titleMatch ? `in ${titleMatch[1]}` : humanize(titleText);
|
| 261 |
+
let titleLines = wrapLines(headline, 22, 2);
|
| 262 |
+
if (/^in Educational Gaming Apps$/i.test(headline)) {
|
| 263 |
+
titleLines = ["in Educational", "Gaming Apps"];
|
| 264 |
+
}
|
| 265 |
+
const longHeadline = titleLines.some(line => line.length > 18);
|
| 266 |
+
const titleNode = header.append("text")
|
| 267 |
+
.attr("class", "main-title")
|
| 268 |
+
.attr("x", 0)
|
| 269 |
+
.attr("y", 98)
|
| 270 |
+
.style("font-family", titleFont)
|
| 271 |
+
.style("font-size", titleLines.length > 1 ? (longHeadline ? "34px" : "44px") : "46px")
|
| 272 |
+
.style("font-weight", 900);
|
| 273 |
+
titleLines.forEach((line, i) => {
|
| 274 |
+
const t = titleNode.append("tspan")
|
| 275 |
+
.attr("x", 0)
|
| 276 |
+
.attr("dy", i === 0 ? 0 : (longHeadline ? 39 : 49));
|
| 277 |
+
if (/gaming|apps|market|rates|ratios|volume|transport|climate/i.test(line) && !/^in educational$/i.test(line)) {
|
| 278 |
+
t.attr("fill", "#ffe051");
|
| 279 |
+
} else {
|
| 280 |
+
t.attr("fill", "#ffffff");
|
| 281 |
+
}
|
| 282 |
+
t.text(line);
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
const subY = 142 + Math.max(0, titleLines.length - 1) * 45;
|
| 286 |
+
const subtitle = header.append("text")
|
| 287 |
+
.attr("class", "subtitle")
|
| 288 |
+
.attr("x", 0)
|
| 289 |
+
.attr("y", subY)
|
| 290 |
+
.attr("fill", "#dce2ee")
|
| 291 |
+
.style("font-family", font)
|
| 292 |
+
.style("font-size", "16px");
|
| 293 |
+
appendTspans(subtitle, wrapLines(subtitleText.replace(/\*\*/g, ""), 46, 2), 0, 22);
|
| 294 |
+
|
| 295 |
+
const headerArt = svg.append("g")
|
| 296 |
+
.attr("class", "header-game-controller-slot")
|
| 297 |
+
.attr("data-reserved-slot", "header_game_controller_slot")
|
| 298 |
+
.attr("data-asset-slot", "header_game_controller_slot")
|
| 299 |
+
.attr("data-slot-anchor", "top-right-header")
|
| 300 |
+
.attr("data-collision-rule", "avoid-title-subtitle-and-year-rail")
|
| 301 |
+
.attr("transform", `translate(${longHeadline ? width - 82 : width - 132},${headerY + 112}) scale(${longHeadline ? 0.72 : 1})`);
|
| 302 |
+
headerArt.append("circle")
|
| 303 |
+
.attr("r", 70)
|
| 304 |
+
.attr("fill", "#244ed9")
|
| 305 |
+
.attr("stroke", "#4d71ff")
|
| 306 |
+
.attr("opacity", 0.82);
|
| 307 |
+
headerArt.append("path")
|
| 308 |
+
.attr("d", "M-42,18 C-40,-8 -24,-18 -2,-10 C16,-18 36,-10 42,18 C47,36 34,44 20,34 L10,25 H-10 L-20,34 C-34,44 -47,36 -42,18 Z")
|
| 309 |
+
.attr("fill", "#eef4ff")
|
| 310 |
+
.attr("opacity", 0.92);
|
| 311 |
+
headerArt.append("path")
|
| 312 |
+
.attr("d", "M-25,12 H-8 M-16,3 V21 M18,9 h1 M31,14 h1 M24,22 h1")
|
| 313 |
+
.attr("stroke", "#20335f")
|
| 314 |
+
.attr("stroke-width", 4)
|
| 315 |
+
.attr("stroke-linecap", "round");
|
| 316 |
+
[
|
| 317 |
+
[-62, -84, "#7e6dff"],
|
| 318 |
+
[-8, -92, "#ff5f7e"],
|
| 319 |
+
[58, -78, "#ffd34a"]
|
| 320 |
+
].forEach((d, i) => {
|
| 321 |
+
const icon = headerArt.append("g")
|
| 322 |
+
.attr("class", "header-reaction-icon-slot")
|
| 323 |
+
.attr("data-asset-slot", "header_reaction_icon_slots")
|
| 324 |
+
.attr("data-slot-anchor", "around-header-illustration")
|
| 325 |
+
.attr("data-collision-rule", "avoid-title-and-canvas-edge")
|
| 326 |
+
.attr("transform", `translate(${d[0]},${d[1]})`);
|
| 327 |
+
icon.append("circle").attr("r", 17).attr("fill", d[2]).attr("opacity", 0.9);
|
| 328 |
+
icon.append("text")
|
| 329 |
+
.attr("text-anchor", "middle")
|
| 330 |
+
.attr("y", 6)
|
| 331 |
+
.attr("fill", "#ffffff")
|
| 332 |
+
.style("font-family", font)
|
| 333 |
+
.style("font-size", "18px")
|
| 334 |
+
.style("font-weight", 900)
|
| 335 |
+
.text(i === 0 ? "+" : i === 1 ? "" : "1");
|
| 336 |
+
if (i === 1) {
|
| 337 |
+
icon.append("path").attr("d", "M-8,-2 C-8,-10 2,-12 0,-2 C4,-12 14,-10 12,-2 C10,6 0,12 0,12 C0,12 -10,6 -8,-2 Z").attr("fill", "#ffffff").attr("opacity", 0.9);
|
| 338 |
+
}
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
+
const chartX = 40;
|
| 342 |
+
const chartRight = width - 40;
|
| 343 |
+
const yearY = 322;
|
| 344 |
+
const rowTop = 382;
|
| 345 |
+
const rowGap = maxRows >= 6 ? 11 : 13;
|
| 346 |
+
const bottomH = 72;
|
| 347 |
+
const bottomGap = 24;
|
| 348 |
+
const availableH = height - rowTop - bottomH - bottomGap - 30;
|
| 349 |
+
const rowH = Math.max(62, Math.min(116, (availableH - rowGap * (maxRows - 1)) / maxRows));
|
| 350 |
+
const rowW = chartRight - chartX;
|
| 351 |
+
const leftBlockW = Math.min(88, Math.max(68, rowW * 0.16));
|
| 352 |
+
const rightSlotW = Math.min(72, Math.max(52, rowW * 0.12));
|
| 353 |
+
const plotX = leftBlockW + 82;
|
| 354 |
+
const plotW = rowW - plotX - rightSlotW - 14;
|
| 355 |
+
|
| 356 |
+
const xScale = d3.scalePoint()
|
| 357 |
+
.domain(xValues.map(d => d.key))
|
| 358 |
+
.range([plotX + 24, plotX + plotW - 18])
|
| 359 |
+
.padding(0.05);
|
| 360 |
+
|
| 361 |
+
const yearRail = svg.append("g")
|
| 362 |
+
.attr("class", "year-rail")
|
| 363 |
+
.attr("transform", `translate(${chartX},${yearY})`);
|
| 364 |
+
yearRail.append("rect")
|
| 365 |
+
.attr("x", 0)
|
| 366 |
+
.attr("y", 0)
|
| 367 |
+
.attr("width", rowW)
|
| 368 |
+
.attr("height", 50)
|
| 369 |
+
.attr("rx", 8)
|
| 370 |
+
.attr("fill", "rgba(13, 30, 55, 0.86)")
|
| 371 |
+
.attr("stroke", colors.stroke);
|
| 372 |
+
yearRail.append("text")
|
| 373 |
+
.attr("x", 20)
|
| 374 |
+
.attr("y", 31)
|
| 375 |
+
.attr("fill", "#ffffff")
|
| 376 |
+
.style("font-family", font)
|
| 377 |
+
.style("font-size", "14px")
|
| 378 |
+
.style("font-weight", 900)
|
| 379 |
+
.text("YEAR");
|
| 380 |
+
|
| 381 |
+
const allPoints = visibleGroups.flatMap(g => byGroup.get(g));
|
| 382 |
+
const peak = allPoints.reduce((best, d) => !best || d.y > best.y ? d : best, null);
|
| 383 |
+
const peakKey = peak?.xKey || xValues[Math.floor(xValues.length / 2)].key;
|
| 384 |
+
yearRail.selectAll(".year-label")
|
| 385 |
+
.data(xValues)
|
| 386 |
+
.enter()
|
| 387 |
+
.append("text")
|
| 388 |
+
.attr("class", "year-label")
|
| 389 |
+
.attr("x", d => xScale(d.key))
|
| 390 |
+
.attr("y", 32)
|
| 391 |
+
.attr("text-anchor", "middle")
|
| 392 |
+
.attr("fill", d => d.key === peakKey ? "#ffe051" : "#ffffff")
|
| 393 |
+
.style("font-family", font)
|
| 394 |
+
.style("font-size", "16px")
|
| 395 |
+
.style("font-weight", d => d.key === peakKey ? 900 : 500)
|
| 396 |
+
.text(d => d.raw);
|
| 397 |
+
|
| 398 |
+
const rowLayer = svg.append("g").attr("class", "bump03-row-layer");
|
| 399 |
+
visibleGroups.forEach((group, index) => {
|
| 400 |
+
const color = colorFor(index);
|
| 401 |
+
const rowY = rowTop + index * (rowH + rowGap);
|
| 402 |
+
const row = rowLayer.append("g")
|
| 403 |
+
.attr("class", "series-row")
|
| 404 |
+
.attr("data-group", group)
|
| 405 |
+
.attr("transform", `translate(${chartX},${rowY})`);
|
| 406 |
+
|
| 407 |
+
row.append("rect")
|
| 408 |
+
.attr("class", "series-card")
|
| 409 |
+
.attr("x", 0)
|
| 410 |
+
.attr("y", 0)
|
| 411 |
+
.attr("width", rowW)
|
| 412 |
+
.attr("height", rowH)
|
| 413 |
+
.attr("rx", 10)
|
| 414 |
+
.attr("fill", colors.panel)
|
| 415 |
+
.attr("stroke", colors.stroke)
|
| 416 |
+
.attr("filter", index === 0 ? "url(#bump-chart-03-glow)" : null);
|
| 417 |
+
row.append("rect")
|
| 418 |
+
.attr("x", 0)
|
| 419 |
+
.attr("y", 0)
|
| 420 |
+
.attr("width", rowW)
|
| 421 |
+
.attr("height", rowH)
|
| 422 |
+
.attr("rx", 10)
|
| 423 |
+
.attr("fill", color)
|
| 424 |
+
.attr("opacity", 0.06);
|
| 425 |
+
row.append("rect")
|
| 426 |
+
.attr("class", "left-rank-block")
|
| 427 |
+
.attr("x", 0)
|
| 428 |
+
.attr("y", 0)
|
| 429 |
+
.attr("width", leftBlockW)
|
| 430 |
+
.attr("height", rowH)
|
| 431 |
+
.attr("rx", 10)
|
| 432 |
+
.attr("fill", color)
|
| 433 |
+
.attr("opacity", 0.16)
|
| 434 |
+
.attr("stroke", color)
|
| 435 |
+
.attr("stroke-width", 1.2);
|
| 436 |
+
row.append("rect")
|
| 437 |
+
.attr("x", leftBlockW - 10)
|
| 438 |
+
.attr("y", 0)
|
| 439 |
+
.attr("width", 12)
|
| 440 |
+
.attr("height", rowH)
|
| 441 |
+
.attr("fill", colors.panel)
|
| 442 |
+
.attr("opacity", 0.86);
|
| 443 |
+
|
| 444 |
+
const leftIcon = row.append("g")
|
| 445 |
+
.attr("class", "row-left-icon-slot")
|
| 446 |
+
.attr("data-reserved-slot", `row_left_icon_slot_${index + 1}`)
|
| 447 |
+
.attr("data-asset-slot", "row_left_icon_slot_N")
|
| 448 |
+
.attr("data-slot-anchor", "inside-left-rank-block")
|
| 449 |
+
.attr("data-collision-rule", "avoid-rank-label")
|
| 450 |
+
.attr("transform", `translate(${leftBlockW / 2},${Math.max(28, rowH * 0.32)})`);
|
| 451 |
+
leftIcon.append("circle").attr("r", Math.min(18, rowH * 0.18)).attr("fill", color).attr("opacity", 0.85);
|
| 452 |
+
leftIcon.append("path")
|
| 453 |
+
.attr("d", "M-8,3 H8 M0,-8 V8")
|
| 454 |
+
.attr("stroke", "#ffffff")
|
| 455 |
+
.attr("stroke-width", 2)
|
| 456 |
+
.attr("stroke-linecap", "round")
|
| 457 |
+
.attr("opacity", 0.9);
|
| 458 |
+
|
| 459 |
+
row.append("text")
|
| 460 |
+
.attr("class", "rank-label")
|
| 461 |
+
.attr("x", leftBlockW / 2)
|
| 462 |
+
.attr("y", rowH - 28)
|
| 463 |
+
.attr("text-anchor", "middle")
|
| 464 |
+
.attr("fill", color)
|
| 465 |
+
.style("font-family", font)
|
| 466 |
+
.style("font-size", rowH < 80 ? "18px" : "24px")
|
| 467 |
+
.style("font-weight", 900)
|
| 468 |
+
.text(compactLabel(group, 10).replace(/\s.*$/, ""));
|
| 469 |
+
row.append("text")
|
| 470 |
+
.attr("x", leftBlockW / 2)
|
| 471 |
+
.attr("y", rowH - 10)
|
| 472 |
+
.attr("text-anchor", "middle")
|
| 473 |
+
.attr("fill", "#ffffff")
|
| 474 |
+
.style("font-family", font)
|
| 475 |
+
.style("font-size", rowH < 80 ? "12px" : "15px")
|
| 476 |
+
.style("font-weight", 700)
|
| 477 |
+
.text(humanize(group).split(/\s+/).slice(-1)[0] || "Group");
|
| 478 |
+
|
| 479 |
+
row.append("rect")
|
| 480 |
+
.attr("class", "rank-number-pill")
|
| 481 |
+
.attr("x", leftBlockW + 10)
|
| 482 |
+
.attr("y", rowH / 2 - 15)
|
| 483 |
+
.attr("width", 30)
|
| 484 |
+
.attr("height", 30)
|
| 485 |
+
.attr("rx", 8)
|
| 486 |
+
.attr("fill", "#0c172b")
|
| 487 |
+
.attr("stroke", colors.stroke);
|
| 488 |
+
row.append("text")
|
| 489 |
+
.attr("x", leftBlockW + 25)
|
| 490 |
+
.attr("y", rowH / 2 + 7)
|
| 491 |
+
.attr("text-anchor", "middle")
|
| 492 |
+
.attr("fill", "#ffffff")
|
| 493 |
+
.style("font-family", font)
|
| 494 |
+
.style("font-size", "18px")
|
| 495 |
+
.style("font-weight", 800)
|
| 496 |
+
.text(index + 1);
|
| 497 |
+
|
| 498 |
+
const series = xValues.map(x => {
|
| 499 |
+
const found = byGroup.get(group).find(d => d.xKey === x.key);
|
| 500 |
+
return found || { xKey: x.key, xRaw: x.raw, y: null, group };
|
| 501 |
+
}).filter(d => Number.isFinite(d.y));
|
| 502 |
+
const extent = d3.extent(series, d => d.y);
|
| 503 |
+
const pad = Math.max(1, (extent[1] - extent[0]) * 0.28);
|
| 504 |
+
const yScale = d3.scaleLinear()
|
| 505 |
+
.domain([extent[0] - pad, extent[1] + pad])
|
| 506 |
+
.range([rowH * 0.68, rowH * 0.30]);
|
| 507 |
+
const line = d3.line()
|
| 508 |
+
.x(d => xScale(d.xKey))
|
| 509 |
+
.y(d => yScale(d.y))
|
| 510 |
+
.curve(d3.curveMonotoneX);
|
| 511 |
+
|
| 512 |
+
row.selectAll(".v-grid")
|
| 513 |
+
.data(xValues)
|
| 514 |
+
.enter()
|
| 515 |
+
.append("line")
|
| 516 |
+
.attr("class", "v-grid")
|
| 517 |
+
.attr("x1", d => xScale(d.key))
|
| 518 |
+
.attr("x2", d => xScale(d.key))
|
| 519 |
+
.attr("y1", rowH * 0.30)
|
| 520 |
+
.attr("y2", rowH * 0.72)
|
| 521 |
+
.attr("stroke", "#27405f")
|
| 522 |
+
.attr("stroke-width", 1)
|
| 523 |
+
.attr("opacity", 0.32);
|
| 524 |
+
|
| 525 |
+
const peakPoint = series.reduce((best, d) => !best || d.y > best.y ? d : best, null);
|
| 526 |
+
const peakX = peakPoint ? xScale(peakPoint.xKey) : xScale(peakKey);
|
| 527 |
+
row.append("line")
|
| 528 |
+
.attr("class", "peak-leader")
|
| 529 |
+
.attr("x1", peakX)
|
| 530 |
+
.attr("x2", peakX)
|
| 531 |
+
.attr("y1", rowH * 0.34)
|
| 532 |
+
.attr("y2", rowH * 0.77)
|
| 533 |
+
.attr("stroke", color)
|
| 534 |
+
.attr("stroke-width", 1.5)
|
| 535 |
+
.attr("stroke-dasharray", "5 6")
|
| 536 |
+
.attr("opacity", 0.92);
|
| 537 |
+
|
| 538 |
+
row.append("path")
|
| 539 |
+
.datum(series)
|
| 540 |
+
.attr("class", "series-line")
|
| 541 |
+
.attr("fill", "none")
|
| 542 |
+
.attr("stroke", color)
|
| 543 |
+
.attr("stroke-width", rowH < 80 ? 4 : 5)
|
| 544 |
+
.attr("stroke-linecap", "round")
|
| 545 |
+
.attr("stroke-linejoin", "round")
|
| 546 |
+
.attr("d", line);
|
| 547 |
+
|
| 548 |
+
row.selectAll(".point")
|
| 549 |
+
.data(series)
|
| 550 |
+
.enter()
|
| 551 |
+
.append("circle")
|
| 552 |
+
.attr("class", "point")
|
| 553 |
+
.attr("cx", d => xScale(d.xKey))
|
| 554 |
+
.attr("cy", d => yScale(d.y))
|
| 555 |
+
.attr("r", d => d === peakPoint ? (rowH < 80 ? 9 : 11) : (rowH < 80 ? 6 : 7))
|
| 556 |
+
.attr("fill", color)
|
| 557 |
+
.attr("stroke", "#ffffff")
|
| 558 |
+
.attr("stroke-width", d => d === peakPoint ? 3 : 2);
|
| 559 |
+
|
| 560 |
+
row.selectAll(".value-label")
|
| 561 |
+
.data(series)
|
| 562 |
+
.enter()
|
| 563 |
+
.append("text")
|
| 564 |
+
.attr("class", "value-label")
|
| 565 |
+
.attr("x", d => xScale(d.xKey))
|
| 566 |
+
.attr("y", d => yScale(d.y) - (d === peakPoint ? 16 : 12))
|
| 567 |
+
.attr("text-anchor", "middle")
|
| 568 |
+
.attr("fill", d => d === peakPoint ? color : "#ffffff")
|
| 569 |
+
.style("font-family", font)
|
| 570 |
+
.style("font-size", d => d === peakPoint ? (rowH < 80 ? "14px" : "18px") : (rowH < 80 ? "11px" : "15px"))
|
| 571 |
+
.style("font-weight", 900)
|
| 572 |
+
.text(d => formatValue(d.y, yUnit));
|
| 573 |
+
|
| 574 |
+
const rightIcon = row.append("g")
|
| 575 |
+
.attr("class", "row-right-icon-slot")
|
| 576 |
+
.attr("data-reserved-slot", `row_right_icon_slot_${index + 1}`)
|
| 577 |
+
.attr("data-asset-slot", "row_right_icon_slot_N")
|
| 578 |
+
.attr("data-slot-anchor", "right-row-centerline")
|
| 579 |
+
.attr("data-collision-rule", "avoid-value-labels-and-row-card-edge")
|
| 580 |
+
.attr("transform", `translate(${rowW - rightSlotW / 2},${rowH / 2})`);
|
| 581 |
+
rightIcon.append("circle")
|
| 582 |
+
.attr("r", Math.min(34, rowH * 0.32))
|
| 583 |
+
.attr("fill", "#0d223e")
|
| 584 |
+
.attr("stroke", color)
|
| 585 |
+
.attr("opacity", 0.94);
|
| 586 |
+
rightIcon.append("path")
|
| 587 |
+
.attr("d", "M-12,10 L-12,2 L-4,2 L-4,-7 L4,-7 L4,10 M-18,10 L18,10")
|
| 588 |
+
.attr("fill", "none")
|
| 589 |
+
.attr("stroke", color)
|
| 590 |
+
.attr("stroke-width", 3)
|
| 591 |
+
.attr("stroke-linecap", "round");
|
| 592 |
+
});
|
| 593 |
+
|
| 594 |
+
const bottomY = rowTop + maxRows * rowH + (maxRows - 1) * rowGap + bottomGap;
|
| 595 |
+
const bottom = svg.append("g")
|
| 596 |
+
.attr("class", "bottom-peak-card")
|
| 597 |
+
.attr("transform", `translate(${chartX},${bottomY})`);
|
| 598 |
+
bottom.append("rect")
|
| 599 |
+
.attr("width", rowW)
|
| 600 |
+
.attr("height", bottomH)
|
| 601 |
+
.attr("rx", 10)
|
| 602 |
+
.attr("fill", "rgba(10, 26, 49, 0.9)")
|
| 603 |
+
.attr("stroke", colors.stroke);
|
| 604 |
+
const bottomIcon = bottom.append("g")
|
| 605 |
+
.attr("class", "bottom-peak-icon-slot")
|
| 606 |
+
.attr("data-reserved-slot", "bottom_peak_icon_slot")
|
| 607 |
+
.attr("data-asset-slot", "bottom_peak_icon_slot")
|
| 608 |
+
.attr("data-slot-anchor", "bottom-card-left")
|
| 609 |
+
.attr("data-collision-rule", "avoid-bottom-card-text")
|
| 610 |
+
.attr("transform", "translate(46,36)");
|
| 611 |
+
bottomIcon.append("circle")
|
| 612 |
+
.attr("r", 24)
|
| 613 |
+
.attr("fill", "#111f39")
|
| 614 |
+
.attr("stroke", "#ffd22e");
|
| 615 |
+
bottomIcon.append("path")
|
| 616 |
+
.attr("d", "M-7,8 H7 M-4,8 V0 C-10,-6 -5,-13 0,-13 C5,-13 10,-6 4,0 V8")
|
| 617 |
+
.attr("fill", "none")
|
| 618 |
+
.attr("stroke", "#ffd22e")
|
| 619 |
+
.attr("stroke-width", 2.2)
|
| 620 |
+
.attr("stroke-linecap", "round")
|
| 621 |
+
.attr("stroke-linejoin", "round");
|
| 622 |
+
|
| 623 |
+
const peakYear = peak?.xRaw || xValues[Math.floor(xValues.length / 2)].raw;
|
| 624 |
+
const subjectText = /student engagement/i.test(titleText)
|
| 625 |
+
? "student engagement"
|
| 626 |
+
: humanize(yField).toLowerCase();
|
| 627 |
+
const groupNoun = humanize(groupField).toLowerCase();
|
| 628 |
+
const pluralGroupNoun = /s$/.test(groupNoun) ? groupNoun : `${groupNoun}s`;
|
| 629 |
+
const bottomText = `${peakYear} was the peak year for ${subjectText} across ${pluralGroupNoun}.`;
|
| 630 |
+
const bottomTextNode = bottom.append("text")
|
| 631 |
+
.attr("x", 92)
|
| 632 |
+
.attr("y", 30)
|
| 633 |
+
.attr("fill", "#ffffff")
|
| 634 |
+
.style("font-family", font)
|
| 635 |
+
.style("font-size", "16px")
|
| 636 |
+
.style("font-weight", 500);
|
| 637 |
+
if (String(peakYear).length && bottomText.includes("peak year")) {
|
| 638 |
+
bottomTextNode.append("tspan").attr("x", 92).text(`${peakYear} was the `);
|
| 639 |
+
bottomTextNode.append("tspan").attr("fill", "#ffe051").attr("font-weight", 900).text("peak year");
|
| 640 |
+
bottomTextNode.append("tspan").text(` for ${subjectText}`);
|
| 641 |
+
bottomTextNode.append("tspan")
|
| 642 |
+
.attr("x", 92)
|
| 643 |
+
.attr("dy", 22)
|
| 644 |
+
.text(`across ${pluralGroupNoun}.`);
|
| 645 |
+
} else {
|
| 646 |
+
appendTspans(bottomTextNode, wrapLines(bottomText, 54, 2), 92, 22);
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
return svg.node();
|
| 650 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/bump_chart_04.js
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Bump Chart",
|
| 5 |
+
"chart_name": "bump_chart_04",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"chart_for": "comparison",
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [
|
| 11 |
+
[2, 12],
|
| 12 |
+
[0, "inf"],
|
| 13 |
+
[4, 10]
|
| 14 |
+
],
|
| 15 |
+
"required_fields_icons": [],
|
| 16 |
+
"required_other_icons": [],
|
| 17 |
+
"required_fields_colors": ["group"],
|
| 18 |
+
"required_other_colors": [],
|
| 19 |
+
"supported_effects": ["spacing"],
|
| 20 |
+
"min_height": 400,
|
| 21 |
+
"min_width": 400,
|
| 22 |
+
"background": "dark",
|
| 23 |
+
"icon_mark": "none",
|
| 24 |
+
"icon_label": "none",
|
| 25 |
+
"has_x_axis": "no",
|
| 26 |
+
"has_y_axis": "no"
|
| 27 |
+
}
|
| 28 |
+
REQUIREMENTS_END
|
| 29 |
+
*/
|
| 30 |
+
|
| 31 |
+
function makeChart(containerSelector, data) {
|
| 32 |
+
const jsonData = data || {};
|
| 33 |
+
const sourceRows = jsonData.data?.data || [];
|
| 34 |
+
const variables = jsonData.variables || {};
|
| 35 |
+
const typography = jsonData.typography || {};
|
| 36 |
+
const titles = jsonData.titles || {};
|
| 37 |
+
const metadata = jsonData.metadata || {};
|
| 38 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 39 |
+
|
| 40 |
+
d3.select(containerSelector).html("");
|
| 41 |
+
|
| 42 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 43 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 44 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 45 |
+
const yColumn = dataColumns[1] || {};
|
| 46 |
+
const yUnit = yColumn.unit === "none" ? "" : (yColumn.unit || "");
|
| 47 |
+
|
| 48 |
+
const width = Math.max(620, Number(variables.width) || 650);
|
| 49 |
+
const height = Math.max(900, Number(variables.height) || 975);
|
| 50 |
+
const font = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
|
| 51 |
+
const titleFont = typography.title?.font_family || font;
|
| 52 |
+
|
| 53 |
+
const colors = {
|
| 54 |
+
bg0: "#061022",
|
| 55 |
+
bg1: "#08203a",
|
| 56 |
+
bg2: "#020914",
|
| 57 |
+
panel: "#07192e",
|
| 58 |
+
panelSoft: "#0d2943",
|
| 59 |
+
stroke: "#2d4868",
|
| 60 |
+
text: "#f4f8ff",
|
| 61 |
+
muted: "#d4deeb",
|
| 62 |
+
rail: "#112640",
|
| 63 |
+
railStroke: "#517190",
|
| 64 |
+
teal: "#35dbc9",
|
| 65 |
+
green: "#7ee66e",
|
| 66 |
+
cyan: "#27c7e9"
|
| 67 |
+
};
|
| 68 |
+
const palette = ["#38d8c5", "#a65bed", "#ffbd2e", "#27ceb1", "#5aa7ff", "#f26d7d", "#86df6d"];
|
| 69 |
+
|
| 70 |
+
const svg = d3.select(containerSelector)
|
| 71 |
+
.append("svg")
|
| 72 |
+
.attr("width", "100%")
|
| 73 |
+
.attr("height", height)
|
| 74 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 75 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 76 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 77 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 78 |
+
.attr("class", "bump-chart-04-reference-layout");
|
| 79 |
+
|
| 80 |
+
const defs = svg.append("defs");
|
| 81 |
+
const bg = defs.append("radialGradient")
|
| 82 |
+
.attr("id", "bump04-bg")
|
| 83 |
+
.attr("cx", "50%")
|
| 84 |
+
.attr("cy", "31%")
|
| 85 |
+
.attr("r", "82%");
|
| 86 |
+
bg.append("stop").attr("offset", "0%").attr("stop-color", colors.bg1);
|
| 87 |
+
bg.append("stop").attr("offset", "58%").attr("stop-color", colors.bg0);
|
| 88 |
+
bg.append("stop").attr("offset", "100%").attr("stop-color", colors.bg2);
|
| 89 |
+
|
| 90 |
+
const shadow = defs.append("filter")
|
| 91 |
+
.attr("id", "bump04-card-shadow")
|
| 92 |
+
.attr("x", "-20%")
|
| 93 |
+
.attr("y", "-20%")
|
| 94 |
+
.attr("width", "140%")
|
| 95 |
+
.attr("height", "140%");
|
| 96 |
+
shadow.append("feDropShadow")
|
| 97 |
+
.attr("dx", 0)
|
| 98 |
+
.attr("dy", 8)
|
| 99 |
+
.attr("stdDeviation", 12)
|
| 100 |
+
.attr("flood-color", "#000713")
|
| 101 |
+
.attr("flood-opacity", 0.34);
|
| 102 |
+
|
| 103 |
+
const glow = defs.append("filter")
|
| 104 |
+
.attr("id", "bump04-soft-glow")
|
| 105 |
+
.attr("x", "-40%")
|
| 106 |
+
.attr("y", "-40%")
|
| 107 |
+
.attr("width", "180%")
|
| 108 |
+
.attr("height", "180%");
|
| 109 |
+
glow.append("feGaussianBlur").attr("stdDeviation", 3).attr("result", "blur");
|
| 110 |
+
const merge = glow.append("feMerge");
|
| 111 |
+
merge.append("feMergeNode").attr("in", "blur");
|
| 112 |
+
merge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 113 |
+
|
| 114 |
+
svg.append("rect")
|
| 115 |
+
.attr("width", width)
|
| 116 |
+
.attr("height", height)
|
| 117 |
+
.attr("fill", "url(#bump04-bg)");
|
| 118 |
+
|
| 119 |
+
function humanize(value) {
|
| 120 |
+
return String(value ?? "")
|
| 121 |
+
.replace(/_/g, " ")
|
| 122 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 123 |
+
.replace(/\s+/g, " ")
|
| 124 |
+
.trim();
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function compactLabel(value, maxChars) {
|
| 128 |
+
const clean = humanize(value);
|
| 129 |
+
if (clean.length <= maxChars) return clean;
|
| 130 |
+
const words = clean.split(/\s+/);
|
| 131 |
+
if (words.length > 1) {
|
| 132 |
+
const initials = words.map(w => w[0]).join("").toUpperCase();
|
| 133 |
+
if (initials.length >= 2 && initials.length <= maxChars) return initials;
|
| 134 |
+
const firstLast = `${words[0]} ${words[words.length - 1]}`;
|
| 135 |
+
if (firstLast.length <= maxChars) return firstLast;
|
| 136 |
+
}
|
| 137 |
+
return `${clean.slice(0, Math.max(3, maxChars - 1))}.`;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
function initials(value) {
|
| 141 |
+
const words = humanize(value).split(/\s+/).filter(Boolean);
|
| 142 |
+
return words.slice(0, 2).map(w => w[0]).join("").toUpperCase() || "?";
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
function wrapWords(value, maxChars, maxLines) {
|
| 146 |
+
const words = humanize(value).split(/\s+/).filter(Boolean);
|
| 147 |
+
const lines = [];
|
| 148 |
+
let cur = "";
|
| 149 |
+
words.forEach(word => {
|
| 150 |
+
const next = cur ? `${cur} ${word}` : word;
|
| 151 |
+
if (next.length <= maxChars || !cur) {
|
| 152 |
+
cur = next;
|
| 153 |
+
} else {
|
| 154 |
+
lines.push(cur);
|
| 155 |
+
cur = word;
|
| 156 |
+
}
|
| 157 |
+
});
|
| 158 |
+
if (cur) lines.push(cur);
|
| 159 |
+
if (lines.length <= maxLines) return lines;
|
| 160 |
+
const kept = lines.slice(0, maxLines - 1);
|
| 161 |
+
kept.push(compactLabel(lines.slice(maxLines - 1).join(" "), maxChars));
|
| 162 |
+
return kept;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function appendLines(node, lines, x, y, lineHeight) {
|
| 166 |
+
lines.forEach((line, i) => {
|
| 167 |
+
node.append("tspan")
|
| 168 |
+
.attr("x", x)
|
| 169 |
+
.attr("y", y + i * lineHeight)
|
| 170 |
+
.text(line);
|
| 171 |
+
});
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function parseX(value, fallback) {
|
| 175 |
+
if (value instanceof Date) return value.getTime();
|
| 176 |
+
const asNumber = Number(value);
|
| 177 |
+
if (Number.isFinite(asNumber)) return asNumber;
|
| 178 |
+
const parsed = Date.parse(value);
|
| 179 |
+
if (Number.isFinite(parsed)) return parsed;
|
| 180 |
+
return fallback;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function formatX(value) {
|
| 184 |
+
if (value instanceof Date) return String(value.getFullYear());
|
| 185 |
+
const clean = humanize(value);
|
| 186 |
+
return clean.replace(/\.0$/, "");
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
function formatValue(value, unit, options = {}) {
|
| 190 |
+
const n = Number(value);
|
| 191 |
+
if (!Number.isFinite(n)) return "";
|
| 192 |
+
const includeUnit = options.includeUnit === true;
|
| 193 |
+
let out;
|
| 194 |
+
const abs = Math.abs(n);
|
| 195 |
+
if (abs >= 1000) out = d3.format(".3~s")(n);
|
| 196 |
+
else if (abs >= 100) out = d3.format(".1f")(n).replace(/\.0$/, "");
|
| 197 |
+
else if (abs >= 10) out = d3.format(".1f")(n).replace(/\.0$/, "");
|
| 198 |
+
else out = d3.format(".2f")(n).replace(/\.?0+$/, "");
|
| 199 |
+
if (!includeUnit || unit === "%") return `${out}`;
|
| 200 |
+
if (/^[$€£¥]/.test(unit || "")) return `${unit}${out}`;
|
| 201 |
+
if ((unit || "").length <= 2) return `${out}${unit}`;
|
| 202 |
+
return `${out}`;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
const rows = sourceRows.map((d, i) => ({
|
| 206 |
+
raw: d,
|
| 207 |
+
sourceIndex: i,
|
| 208 |
+
xRaw: d[xField],
|
| 209 |
+
xKey: String(d[xField]),
|
| 210 |
+
xSort: parseX(d[xField], i),
|
| 211 |
+
y: Number(d[yField]),
|
| 212 |
+
group: String(d[groupField] ?? "")
|
| 213 |
+
})).filter(d => d.group && d.xKey && Number.isFinite(d.y));
|
| 214 |
+
|
| 215 |
+
if (!xField || !yField || !groupField || !rows.length) {
|
| 216 |
+
svg.append("text")
|
| 217 |
+
.attr("x", width / 2)
|
| 218 |
+
.attr("y", height / 2)
|
| 219 |
+
.attr("text-anchor", "middle")
|
| 220 |
+
.attr("fill", colors.text)
|
| 221 |
+
.style("font-family", font)
|
| 222 |
+
.style("font-size", "18px")
|
| 223 |
+
.text("Missing bump chart data");
|
| 224 |
+
return svg.node();
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
const xValues = Array.from(new Map(rows
|
| 228 |
+
.sort((a, b) => d3.ascending(a.xSort, b.xSort))
|
| 229 |
+
.map(d => [d.xKey, { key: d.xKey, raw: d.xRaw, sort: d.xSort }])).values());
|
| 230 |
+
const xIndex = new Map(xValues.map((d, i) => [d.key, i]));
|
| 231 |
+
const xSortByKey = new Map(xValues.map(d => [d.key, d.sort]));
|
| 232 |
+
const byGroup = d3.group(rows, d => d.group);
|
| 233 |
+
const lastKey = xValues[xValues.length - 1]?.key;
|
| 234 |
+
const groups = Array.from(byGroup.keys()).sort((a, b) => {
|
| 235 |
+
const aRows = byGroup.get(a) || [];
|
| 236 |
+
const bRows = byGroup.get(b) || [];
|
| 237 |
+
const aLast = aRows.find(d => d.xKey === lastKey)?.y ?? d3.mean(aRows, d => d.y);
|
| 238 |
+
const bLast = bRows.find(d => d.xKey === lastKey)?.y ?? d3.mean(bRows, d => d.y);
|
| 239 |
+
return d3.descending(aLast, bLast) || d3.ascending(a, b);
|
| 240 |
+
});
|
| 241 |
+
const visibleGroups = groups.slice(0, Math.min(6, groups.length));
|
| 242 |
+
|
| 243 |
+
function colorFor(index) {
|
| 244 |
+
return palette[index % palette.length];
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
function buildTitleParts(title) {
|
| 248 |
+
const raw = humanize(title).replace(/\*\*/g, "");
|
| 249 |
+
const acrossMatch = raw.match(/^(.*?)\s+Across\s+(.+)$/i);
|
| 250 |
+
if (acrossMatch) {
|
| 251 |
+
const before = acrossMatch[1].trim();
|
| 252 |
+
const words = before.split(/\s+/);
|
| 253 |
+
return {
|
| 254 |
+
line1: words.slice(0, 2).join(" ").toUpperCase(),
|
| 255 |
+
highlightLines: wrapWords(words.slice(2).join(" "), 24, 2).map(d => d.toUpperCase()),
|
| 256 |
+
pill: `ACROSS ${acrossMatch[2]}`.toUpperCase()
|
| 257 |
+
};
|
| 258 |
+
}
|
| 259 |
+
const words = raw.split(/\s+/);
|
| 260 |
+
return {
|
| 261 |
+
line1: words.slice(0, Math.min(3, words.length)).join(" ").toUpperCase(),
|
| 262 |
+
highlightLines: wrapWords(words.slice(Math.min(3, words.length)).join(" "), 22, 2).map(d => d.toUpperCase()).filter(Boolean),
|
| 263 |
+
pill: humanize(groupField).toUpperCase()
|
| 264 |
+
};
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
const titleText = titles.main_title || metadata.title || `${humanize(yField)} by ${humanize(groupField)}`;
|
| 268 |
+
const subtitleText = titles.sub_title || metadata.description || `${humanize(yField)} over ${humanize(xField)}.`;
|
| 269 |
+
const titleParts = buildTitleParts(titleText);
|
| 270 |
+
|
| 271 |
+
const headerArt = svg.append("g")
|
| 272 |
+
.attr("class", "header-topic-art-slot")
|
| 273 |
+
.attr("data-reserved-slot", "header_topic_art_slot")
|
| 274 |
+
.attr("data-asset-slot", "header_topic_art_slot")
|
| 275 |
+
.attr("data-slot-anchor", "left-header-safe-area")
|
| 276 |
+
.attr("data-asset-source-policy", "image2_asset")
|
| 277 |
+
.attr("data-collision-rule", "avoid-title-subtitle-year-rail-and-canvas-edge")
|
| 278 |
+
.attr("transform", "translate(36,46)");
|
| 279 |
+
headerArt.append("rect")
|
| 280 |
+
.attr("width", 150)
|
| 281 |
+
.attr("height", 156)
|
| 282 |
+
.attr("rx", 18)
|
| 283 |
+
.attr("fill", "#0e2a43")
|
| 284 |
+
.attr("opacity", 0.26)
|
| 285 |
+
.attr("stroke", "#2fd6c7")
|
| 286 |
+
.attr("stroke-dasharray", "7 8")
|
| 287 |
+
.attr("stroke-width", 1.4);
|
| 288 |
+
headerArt.selectAll(".slot-orbit")
|
| 289 |
+
.data([[38, 42, 24], [92, 68, 34], [55, 112, 18], [116, 118, 22]])
|
| 290 |
+
.enter()
|
| 291 |
+
.append("circle")
|
| 292 |
+
.attr("class", "slot-orbit")
|
| 293 |
+
.attr("cx", d => d[0])
|
| 294 |
+
.attr("cy", d => d[1])
|
| 295 |
+
.attr("r", d => d[2])
|
| 296 |
+
.attr("fill", "none")
|
| 297 |
+
.attr("stroke", "#35dbc9")
|
| 298 |
+
.attr("opacity", 0.42)
|
| 299 |
+
.attr("stroke-width", 3);
|
| 300 |
+
|
| 301 |
+
[[104, 24], [74, 32], [24, 76]].forEach(([x, y], i) => {
|
| 302 |
+
const star = svg.append("g")
|
| 303 |
+
.attr("class", "header-spark")
|
| 304 |
+
.attr("transform", `translate(${x},${y})`);
|
| 305 |
+
star.append("path")
|
| 306 |
+
.attr("d", "M0,-9 L2,-2 L9,0 L2,2 L0,9 L-2,2 L-9,0 L-2,-2 Z")
|
| 307 |
+
.attr("fill", i === 1 ? "#ffffff" : "#6cf2d0")
|
| 308 |
+
.attr("opacity", i === 1 ? 0.92 : 0.8);
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
const titleX = 205;
|
| 312 |
+
const titleY = 45;
|
| 313 |
+
const highlightCount = Math.max(1, titleParts.highlightLines.length);
|
| 314 |
+
const maxHighlightLen = d3.max(titleParts.highlightLines, d => d.length) || 0;
|
| 315 |
+
const highlightFitSize = maxHighlightLen
|
| 316 |
+
? Math.floor((width - titleX - 18) / Math.max(1, maxHighlightLen * 0.63))
|
| 317 |
+
: 44;
|
| 318 |
+
const highlightSize = Math.max(28, Math.min(maxHighlightLen > 23 ? 32 : maxHighlightLen > 18 ? 38 : 44, highlightFitSize));
|
| 319 |
+
const firstLineFitSize = Math.floor((width - titleX - 18) / Math.max(1, titleParts.line1.length * 0.62));
|
| 320 |
+
svg.append("text")
|
| 321 |
+
.attr("class", "title-line-one")
|
| 322 |
+
.attr("x", titleX)
|
| 323 |
+
.attr("y", titleY)
|
| 324 |
+
.attr("fill", colors.text)
|
| 325 |
+
.style("font-family", titleFont)
|
| 326 |
+
.style("font-size", `${Math.max(21, Math.min(titleParts.line1.length > 24 ? 23 : 29, firstLineFitSize))}px`)
|
| 327 |
+
.style("font-weight", 900)
|
| 328 |
+
.style("letter-spacing", "0px")
|
| 329 |
+
.text(titleParts.line1);
|
| 330 |
+
|
| 331 |
+
const highlight = svg.append("text")
|
| 332 |
+
.attr("class", "title-highlight")
|
| 333 |
+
.attr("x", titleX)
|
| 334 |
+
.attr("fill", colors.green)
|
| 335 |
+
.style("font-family", titleFont)
|
| 336 |
+
.style("font-size", `${highlightSize}px`)
|
| 337 |
+
.style("font-weight", 900)
|
| 338 |
+
.style("letter-spacing", "0px");
|
| 339 |
+
titleParts.highlightLines.forEach((line, i) => {
|
| 340 |
+
highlight.append("tspan")
|
| 341 |
+
.attr("x", titleX)
|
| 342 |
+
.attr("y", titleY + 48 + i * (highlightSize + 3))
|
| 343 |
+
.attr("fill", i % 2 ? colors.cyan : colors.green)
|
| 344 |
+
.text(line);
|
| 345 |
+
});
|
| 346 |
+
|
| 347 |
+
const pillY = titleY + 54 + highlightCount * (highlightSize + 3);
|
| 348 |
+
const pillText = compactLabel(titleParts.pill, 28).toUpperCase();
|
| 349 |
+
const pillW = Math.min(width - titleX - 24, Math.max(240, pillText.length * 14 + 34));
|
| 350 |
+
const pill = svg.append("g")
|
| 351 |
+
.attr("class", "title-scope-pill")
|
| 352 |
+
.attr("transform", `translate(${titleX},${pillY})`);
|
| 353 |
+
pill.append("rect")
|
| 354 |
+
.attr("width", pillW)
|
| 355 |
+
.attr("height", 34)
|
| 356 |
+
.attr("rx", 18)
|
| 357 |
+
.attr("fill", "#0a4f73")
|
| 358 |
+
.attr("opacity", 0.76);
|
| 359 |
+
pill.append("text")
|
| 360 |
+
.attr("x", pillW / 2)
|
| 361 |
+
.attr("y", 24)
|
| 362 |
+
.attr("text-anchor", "middle")
|
| 363 |
+
.attr("fill", "#ffffff")
|
| 364 |
+
.style("font-family", font)
|
| 365 |
+
.style("font-size", pillText.length > 23 ? "17px" : "20px")
|
| 366 |
+
.style("font-weight", 900)
|
| 367 |
+
.style("letter-spacing", "0px")
|
| 368 |
+
.text(pillText);
|
| 369 |
+
|
| 370 |
+
const subtitleY = pillY + 52;
|
| 371 |
+
const subtitle = svg.append("text")
|
| 372 |
+
.attr("class", "subtitle")
|
| 373 |
+
.attr("x", titleX)
|
| 374 |
+
.attr("fill", colors.muted)
|
| 375 |
+
.style("font-family", font)
|
| 376 |
+
.style("font-size", "15px")
|
| 377 |
+
.style("font-weight", 500);
|
| 378 |
+
appendLines(subtitle, wrapWords(subtitleText, 47, 2), titleX, subtitleY, 20);
|
| 379 |
+
|
| 380 |
+
const headerBottom = subtitleY + 42;
|
| 381 |
+
const chartX = 27;
|
| 382 |
+
const chartRight = width - 27;
|
| 383 |
+
const rowW = chartRight - chartX;
|
| 384 |
+
const yearY = Math.max(246, headerBottom + 16);
|
| 385 |
+
const railX = chartX + 112;
|
| 386 |
+
const railW = rowW - 118;
|
| 387 |
+
const rowTop = yearY + 46;
|
| 388 |
+
const rowGap = visibleGroups.length >= 6 ? 10 : 14;
|
| 389 |
+
const bottomMargin = 124;
|
| 390 |
+
const rowH = Math.max(76, Math.min(118, (height - rowTop - bottomMargin - rowGap * (visibleGroups.length - 1)) / Math.max(1, visibleGroups.length)));
|
| 391 |
+
const leftRankW = 62;
|
| 392 |
+
const iconW = 92;
|
| 393 |
+
const plotX = leftRankW + iconW + 25;
|
| 394 |
+
const endpointW = 92;
|
| 395 |
+
const plotW = rowW - plotX - endpointW - 16;
|
| 396 |
+
|
| 397 |
+
const xDomain = xValues.map(d => d.key);
|
| 398 |
+
const linearX = xValues.length > 1 && xValues.every(d => Number.isFinite(d.sort)) && new Set(xValues.map(d => d.sort)).size === xValues.length;
|
| 399 |
+
const xPointScale = d3.scalePoint()
|
| 400 |
+
.domain(xDomain)
|
| 401 |
+
.range([plotX + 6, plotX + plotW - 6])
|
| 402 |
+
.padding(0);
|
| 403 |
+
const railPointScale = d3.scalePoint()
|
| 404 |
+
.domain(xDomain)
|
| 405 |
+
.range([railX + 34 - chartX, railX + railW - 34 - chartX])
|
| 406 |
+
.padding(0);
|
| 407 |
+
const xLinearScale = d3.scaleLinear()
|
| 408 |
+
.domain(d3.extent(xValues, d => d.sort))
|
| 409 |
+
.range([plotX + 6, plotX + plotW - 6]);
|
| 410 |
+
const railLinearScale = d3.scaleLinear()
|
| 411 |
+
.domain(d3.extent(xValues, d => d.sort))
|
| 412 |
+
.range([railX + 34 - chartX, railX + railW - 34 - chartX]);
|
| 413 |
+
function xPosByKey(key) {
|
| 414 |
+
return linearX ? xLinearScale(xSortByKey.get(key)) : xPointScale(key);
|
| 415 |
+
}
|
| 416 |
+
function railPosByTick(tick) {
|
| 417 |
+
return linearX ? railLinearScale(tick.sort) : railPointScale(tick.key);
|
| 418 |
+
}
|
| 419 |
+
function buildDisplayTicks() {
|
| 420 |
+
if (!linearX || xValues.length > 7) return xValues;
|
| 421 |
+
const numericYears = xValues.every(d => Number.isInteger(Number(d.raw)) && Number(d.raw) >= 1000 && Number(d.raw) <= 9999);
|
| 422 |
+
if (!numericYears) return xValues;
|
| 423 |
+
const out = [xValues[0]];
|
| 424 |
+
for (let i = 1; i < xValues.length; i += 1) {
|
| 425 |
+
const prev = Number(xValues[i - 1].raw);
|
| 426 |
+
const cur = Number(xValues[i].raw);
|
| 427 |
+
if (i > 1 && i < xValues.length - 1 && cur - prev >= 4) {
|
| 428 |
+
const mid = prev + Math.round((cur - prev) / 2);
|
| 429 |
+
out.push({ key: `tick-${mid}`, raw: mid, sort: mid, generatedTick: true });
|
| 430 |
+
}
|
| 431 |
+
out.push(xValues[i]);
|
| 432 |
+
}
|
| 433 |
+
return out.length <= 8 ? out : xValues;
|
| 434 |
+
}
|
| 435 |
+
const displayTicks = buildDisplayTicks();
|
| 436 |
+
|
| 437 |
+
const yearRail = svg.append("g")
|
| 438 |
+
.attr("class", "year-rail")
|
| 439 |
+
.attr("transform", `translate(${chartX},${yearY})`);
|
| 440 |
+
yearRail.append("rect")
|
| 441 |
+
.attr("x", railX - chartX)
|
| 442 |
+
.attr("y", 0)
|
| 443 |
+
.attr("width", railW)
|
| 444 |
+
.attr("height", 34)
|
| 445 |
+
.attr("rx", 17)
|
| 446 |
+
.attr("fill", "rgba(22, 43, 65, 0.88)")
|
| 447 |
+
.attr("stroke", colors.railStroke)
|
| 448 |
+
.attr("stroke-width", 1.2);
|
| 449 |
+
yearRail.selectAll(".year-label")
|
| 450 |
+
.data(displayTicks)
|
| 451 |
+
.enter()
|
| 452 |
+
.append("text")
|
| 453 |
+
.attr("class", "year-label")
|
| 454 |
+
.attr("x", d => railPosByTick(d))
|
| 455 |
+
.attr("y", 23)
|
| 456 |
+
.attr("text-anchor", "middle")
|
| 457 |
+
.attr("fill", "#ffffff")
|
| 458 |
+
.style("font-family", font)
|
| 459 |
+
.style("font-size", displayTicks.length > 7 ? "11px" : displayTicks.length > 6 ? "13px" : "15px")
|
| 460 |
+
.style("font-weight", 800)
|
| 461 |
+
.text(d => formatX(d.raw));
|
| 462 |
+
|
| 463 |
+
const rowLayer = svg.append("g")
|
| 464 |
+
.attr("class", "bump04-row-layer")
|
| 465 |
+
.attr("data-overlap-avoid", "title subtitle header_topic_art_slot year_rail");
|
| 466 |
+
|
| 467 |
+
visibleGroups.forEach((group, index) => {
|
| 468 |
+
const color = colorFor(index);
|
| 469 |
+
const rowY = rowTop + index * (rowH + rowGap);
|
| 470 |
+
const safeGroup = group.replace(/[^a-zA-Z0-9_-]/g, "-");
|
| 471 |
+
const areaGradientId = `bump04-area-${index}`;
|
| 472 |
+
const areaGradient = defs.append("linearGradient")
|
| 473 |
+
.attr("id", areaGradientId)
|
| 474 |
+
.attr("x1", "0%")
|
| 475 |
+
.attr("x2", "0%")
|
| 476 |
+
.attr("y1", "0%")
|
| 477 |
+
.attr("y2", "100%");
|
| 478 |
+
areaGradient.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 0.44);
|
| 479 |
+
areaGradient.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0.05);
|
| 480 |
+
|
| 481 |
+
const row = rowLayer.append("g")
|
| 482 |
+
.attr("class", "series-row-card")
|
| 483 |
+
.attr("data-group", group)
|
| 484 |
+
.attr("data-row-index", index)
|
| 485 |
+
.attr("transform", `translate(${chartX},${rowY})`);
|
| 486 |
+
|
| 487 |
+
row.append("rect")
|
| 488 |
+
.attr("class", "row-card-bg")
|
| 489 |
+
.attr("x", 0)
|
| 490 |
+
.attr("y", 0)
|
| 491 |
+
.attr("width", rowW)
|
| 492 |
+
.attr("height", rowH)
|
| 493 |
+
.attr("rx", 9)
|
| 494 |
+
.attr("fill", "rgba(6, 24, 43, 0.84)")
|
| 495 |
+
.attr("stroke", colors.stroke)
|
| 496 |
+
.attr("filter", index === 0 ? "url(#bump04-card-shadow)" : null);
|
| 497 |
+
|
| 498 |
+
row.append("rect")
|
| 499 |
+
.attr("x", 0)
|
| 500 |
+
.attr("y", 0)
|
| 501 |
+
.attr("width", rowW)
|
| 502 |
+
.attr("height", rowH)
|
| 503 |
+
.attr("rx", 9)
|
| 504 |
+
.attr("fill", color)
|
| 505 |
+
.attr("opacity", 0.035);
|
| 506 |
+
|
| 507 |
+
const rank = row.append("g")
|
| 508 |
+
.attr("class", "rank-circle")
|
| 509 |
+
.attr("transform", `translate(${leftRankW / 2},${rowH / 2})`);
|
| 510 |
+
rank.append("circle")
|
| 511 |
+
.attr("r", Math.min(27, rowH * 0.31))
|
| 512 |
+
.attr("fill", color)
|
| 513 |
+
.attr("opacity", 0.48)
|
| 514 |
+
.attr("stroke", color)
|
| 515 |
+
.attr("stroke-width", 3);
|
| 516 |
+
rank.append("circle")
|
| 517 |
+
.attr("r", Math.min(21, rowH * 0.24))
|
| 518 |
+
.attr("fill", "#153654")
|
| 519 |
+
.attr("opacity", 0.76);
|
| 520 |
+
rank.append("text")
|
| 521 |
+
.attr("text-anchor", "middle")
|
| 522 |
+
.attr("y", 11)
|
| 523 |
+
.attr("fill", "#ffffff")
|
| 524 |
+
.style("font-family", font)
|
| 525 |
+
.style("font-size", rowH < 90 ? "24px" : "30px")
|
| 526 |
+
.style("font-weight", 900)
|
| 527 |
+
.text(index + 1);
|
| 528 |
+
|
| 529 |
+
const icon = row.append("g")
|
| 530 |
+
.attr("class", "row-group-icon-slot")
|
| 531 |
+
.attr("data-reserved-slot", `row_group_icon_slot_${index + 1}`)
|
| 532 |
+
.attr("data-asset-slot", "row_group_icon_slot_N")
|
| 533 |
+
.attr("data-group", group)
|
| 534 |
+
.attr("data-slot-anchor", "row-left-icon-column")
|
| 535 |
+
.attr("data-asset-source-policy", "retrieval_asset")
|
| 536 |
+
.attr("data-collision-rule", "avoid-rank-circle-group-label-plot")
|
| 537 |
+
.attr("transform", `translate(${leftRankW + 48},${rowH * 0.38})`);
|
| 538 |
+
icon.append("rect")
|
| 539 |
+
.attr("x", -24)
|
| 540 |
+
.attr("y", -23)
|
| 541 |
+
.attr("width", 48)
|
| 542 |
+
.attr("height", 46)
|
| 543 |
+
.attr("rx", 14)
|
| 544 |
+
.attr("fill", color)
|
| 545 |
+
.attr("opacity", 0.18)
|
| 546 |
+
.attr("stroke", color)
|
| 547 |
+
.attr("stroke-dasharray", "4 5");
|
| 548 |
+
icon.append("text")
|
| 549 |
+
.attr("text-anchor", "middle")
|
| 550 |
+
.attr("y", 8)
|
| 551 |
+
.attr("fill", color)
|
| 552 |
+
.style("font-family", font)
|
| 553 |
+
.style("font-size", "19px")
|
| 554 |
+
.style("font-weight", 900)
|
| 555 |
+
.text(initials(group));
|
| 556 |
+
|
| 557 |
+
row.append("text")
|
| 558 |
+
.attr("class", "group-label")
|
| 559 |
+
.attr("x", leftRankW + 48)
|
| 560 |
+
.attr("y", rowH - 20)
|
| 561 |
+
.attr("text-anchor", "middle")
|
| 562 |
+
.attr("fill", color)
|
| 563 |
+
.style("font-family", font)
|
| 564 |
+
.style("font-size", humanize(group).length > 12 ? "13px" : "16px")
|
| 565 |
+
.style("font-weight", 900)
|
| 566 |
+
.text(compactLabel(group, 13));
|
| 567 |
+
|
| 568 |
+
row.selectAll(".vertical-guide")
|
| 569 |
+
.data(displayTicks)
|
| 570 |
+
.enter()
|
| 571 |
+
.append("line")
|
| 572 |
+
.attr("class", "vertical-guide")
|
| 573 |
+
.attr("x1", d => linearX ? xLinearScale(d.sort) : xPointScale(d.key))
|
| 574 |
+
.attr("x2", d => linearX ? xLinearScale(d.sort) : xPointScale(d.key))
|
| 575 |
+
.attr("y1", 14)
|
| 576 |
+
.attr("y2", rowH - 16)
|
| 577 |
+
.attr("stroke", "#41607c")
|
| 578 |
+
.attr("stroke-width", 1)
|
| 579 |
+
.attr("stroke-dasharray", "3 4")
|
| 580 |
+
.attr("opacity", 0.28);
|
| 581 |
+
|
| 582 |
+
const series = xValues.map(x => {
|
| 583 |
+
const found = (byGroup.get(group) || []).find(d => d.xKey === x.key);
|
| 584 |
+
return found || { xKey: x.key, xRaw: x.raw, y: null, group };
|
| 585 |
+
}).filter(d => Number.isFinite(d.y));
|
| 586 |
+
const extent = d3.extent(series, d => d.y);
|
| 587 |
+
const delta = Math.max(1, (extent[1] - extent[0]) || Math.abs(extent[0]) * 0.05 || 1);
|
| 588 |
+
const yScale = d3.scaleLinear()
|
| 589 |
+
.domain([extent[0] - delta * 0.18, extent[1] + delta * 0.34])
|
| 590 |
+
.range([rowH - 38, 28]);
|
| 591 |
+
const line = d3.line()
|
| 592 |
+
.x(d => xPosByKey(d.xKey))
|
| 593 |
+
.y(d => yScale(d.y))
|
| 594 |
+
.curve(d3.curveMonotoneX);
|
| 595 |
+
const area = d3.area()
|
| 596 |
+
.x(d => xPosByKey(d.xKey))
|
| 597 |
+
.y0(rowH - 16)
|
| 598 |
+
.y1(d => yScale(d.y))
|
| 599 |
+
.curve(d3.curveMonotoneX);
|
| 600 |
+
|
| 601 |
+
row.append("path")
|
| 602 |
+
.datum(series)
|
| 603 |
+
.attr("class", "series-area")
|
| 604 |
+
.attr("fill", `url(#${areaGradientId})`)
|
| 605 |
+
.attr("d", area);
|
| 606 |
+
row.append("path")
|
| 607 |
+
.datum(series)
|
| 608 |
+
.attr("class", "series-line")
|
| 609 |
+
.attr("fill", "none")
|
| 610 |
+
.attr("stroke", color)
|
| 611 |
+
.attr("stroke-width", rowH < 90 ? 3.5 : 4.5)
|
| 612 |
+
.attr("stroke-linecap", "round")
|
| 613 |
+
.attr("stroke-linejoin", "round")
|
| 614 |
+
.attr("filter", "url(#bump04-soft-glow)")
|
| 615 |
+
.attr("d", line);
|
| 616 |
+
|
| 617 |
+
row.selectAll(".point")
|
| 618 |
+
.data(series)
|
| 619 |
+
.enter()
|
| 620 |
+
.append("circle")
|
| 621 |
+
.attr("class", "point")
|
| 622 |
+
.attr("cx", d => xPosByKey(d.xKey))
|
| 623 |
+
.attr("cy", d => yScale(d.y))
|
| 624 |
+
.attr("r", rowH < 90 ? 5.8 : 6.8)
|
| 625 |
+
.attr("fill", color)
|
| 626 |
+
.attr("stroke", "#ffffff")
|
| 627 |
+
.attr("stroke-width", 2.2);
|
| 628 |
+
|
| 629 |
+
const showEvery = xValues.length > 7 ? Math.ceil(xValues.length / 6) : 1;
|
| 630 |
+
row.selectAll(".point-label")
|
| 631 |
+
.data(series.filter(d => {
|
| 632 |
+
const idx = xIndex.get(d.xKey) || 0;
|
| 633 |
+
return idx === 0 || idx === xValues.length - 1 || idx % showEvery === 0;
|
| 634 |
+
}))
|
| 635 |
+
.enter()
|
| 636 |
+
.append("text")
|
| 637 |
+
.attr("class", "point-label")
|
| 638 |
+
.attr("x", d => xPosByKey(d.xKey))
|
| 639 |
+
.attr("y", d => Math.max(18, yScale(d.y) - 13))
|
| 640 |
+
.attr("text-anchor", "middle")
|
| 641 |
+
.attr("fill", "#ffffff")
|
| 642 |
+
.style("font-family", font)
|
| 643 |
+
.style("font-size", rowH < 90 ? "12px" : "14px")
|
| 644 |
+
.style("font-weight", 800)
|
| 645 |
+
.text(d => formatValue(d.y, yUnit));
|
| 646 |
+
|
| 647 |
+
const lastPoint = series[series.length - 1];
|
| 648 |
+
const endpointText = formatValue(lastPoint?.y, yUnit, { includeUnit: true });
|
| 649 |
+
row.append("text")
|
| 650 |
+
.attr("class", "endpoint-value")
|
| 651 |
+
.attr("x", rowW - 16)
|
| 652 |
+
.attr("y", rowH / 2 + 14)
|
| 653 |
+
.attr("text-anchor", "end")
|
| 654 |
+
.attr("fill", color)
|
| 655 |
+
.style("font-family", font)
|
| 656 |
+
.style("font-size", endpointText.length > 5 ? "26px" : "31px")
|
| 657 |
+
.style("font-weight", 900)
|
| 658 |
+
.text(endpointText);
|
| 659 |
+
});
|
| 660 |
+
|
| 661 |
+
return svg.node();
|
| 662 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/bump_chart_05.js
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Bump Chart",
|
| 5 |
+
"chart_name": "bump_plain_chart_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"chart_for": "comparison",
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [
|
| 11 |
+
[2, 12],
|
| 12 |
+
[0, "inf"],
|
| 13 |
+
[4, 10]
|
| 14 |
+
],
|
| 15 |
+
"required_fields_icons": [],
|
| 16 |
+
"required_other_icons": [],
|
| 17 |
+
"required_fields_colors": ["group"],
|
| 18 |
+
"required_other_colors": [],
|
| 19 |
+
"supported_effects": ["spacing"],
|
| 20 |
+
"min_height": 400,
|
| 21 |
+
"min_width": 400,
|
| 22 |
+
"background": "no",
|
| 23 |
+
"icon_mark": "none",
|
| 24 |
+
"icon_label": "none",
|
| 25 |
+
"has_x_axis": "no",
|
| 26 |
+
"has_y_axis": "no"
|
| 27 |
+
}
|
| 28 |
+
REQUIREMENTS_END
|
| 29 |
+
*/
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
function makeChart(containerSelector, data) {
|
| 34 |
+
// 提取数据
|
| 35 |
+
const jsonData = data;
|
| 36 |
+
const chartData = jsonData.data.data;
|
| 37 |
+
const variables = jsonData.variables;
|
| 38 |
+
const typography = jsonData.typography;
|
| 39 |
+
const colors = jsonData.colors || {};
|
| 40 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 41 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 42 |
+
const images = jsonData.images || {};
|
| 43 |
+
// 获取颜色
|
| 44 |
+
const getColor = (group) => {
|
| 45 |
+
return colorResolver.field(group, 0, { fallbackKey: "primary", fallback: "#888" }).value;
|
| 46 |
+
};
|
| 47 |
+
const withOpacity = (color, opacity) => {
|
| 48 |
+
const parsed = d3.color(color);
|
| 49 |
+
if (!parsed) return color;
|
| 50 |
+
parsed.opacity = opacity;
|
| 51 |
+
return parsed.formatRgb();
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
// 清空容器
|
| 55 |
+
d3.select(containerSelector).html("");
|
| 56 |
+
|
| 57 |
+
// 设置尺寸和边距 - 左侧为参考图中的排行卡片和标签列留空间
|
| 58 |
+
const width = variables.width;
|
| 59 |
+
const height = variables.height;
|
| 60 |
+
const margin = {
|
| 61 |
+
top: Math.max(76, height * 0.15),
|
| 62 |
+
right: Math.max(28, width * 0.06),
|
| 63 |
+
bottom: Math.max(34, height * 0.06),
|
| 64 |
+
left: Math.max(176, width * 0.31)
|
| 65 |
+
};
|
| 66 |
+
const innerWidth = width - margin.left - margin.right;
|
| 67 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 68 |
+
|
| 69 |
+
// 获取字段名
|
| 70 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 71 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 72 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 73 |
+
|
| 74 |
+
// 获取唯一的组值和X值
|
| 75 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 76 |
+
const xValues = [...new Set(chartData.map(d => d[xField]))]
|
| 77 |
+
.sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
|
| 78 |
+
|
| 79 |
+
// 创建SVG
|
| 80 |
+
const svg = d3.select(containerSelector)
|
| 81 |
+
.append("svg")
|
| 82 |
+
.attr("width", "100%")
|
| 83 |
+
.attr("height", height)
|
| 84 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 85 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 86 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 87 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 88 |
+
|
| 89 |
+
const defs = svg.append("defs");
|
| 90 |
+
const bgGradient = defs.append("linearGradient")
|
| 91 |
+
.attr("id", "bump-plain-01-bg")
|
| 92 |
+
.attr("x1", "0%")
|
| 93 |
+
.attr("y1", "0%")
|
| 94 |
+
.attr("x2", "0%")
|
| 95 |
+
.attr("y2", "100%");
|
| 96 |
+
bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#F5FAFE");
|
| 97 |
+
bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#FFFFFF");
|
| 98 |
+
svg.append("rect")
|
| 99 |
+
.attr("class", "bump-plain-01-chart-surface")
|
| 100 |
+
.attr("x", 0)
|
| 101 |
+
.attr("y", 0)
|
| 102 |
+
.attr("width", width)
|
| 103 |
+
.attr("height", height)
|
| 104 |
+
.attr("fill", "url(#bump-plain-01-bg)");
|
| 105 |
+
|
| 106 |
+
const rowShadow = defs.append("filter")
|
| 107 |
+
.attr("id", "bump-plain-01-row-shadow")
|
| 108 |
+
.attr("x", "-6%")
|
| 109 |
+
.attr("y", "-12%")
|
| 110 |
+
.attr("width", "112%")
|
| 111 |
+
.attr("height", "130%");
|
| 112 |
+
rowShadow.append("feDropShadow")
|
| 113 |
+
.attr("dx", 0)
|
| 114 |
+
.attr("dy", 2)
|
| 115 |
+
.attr("stdDeviation", 3)
|
| 116 |
+
.attr("flood-color", "#1E4B76")
|
| 117 |
+
.attr("flood-opacity", 0.08);
|
| 118 |
+
|
| 119 |
+
svg.append("rect")
|
| 120 |
+
.attr("class", "bump-plain-header-art-slot")
|
| 121 |
+
.attr("data-asset-slot", "header_water_illustration_slot")
|
| 122 |
+
.attr("data-reserved-slot", "header_water_illustration_slot")
|
| 123 |
+
.attr("data-anchor", "top_right_header")
|
| 124 |
+
.attr("data-collision-rule", "avoid title subtitle year chips and chart rows")
|
| 125 |
+
.attr("data-asset-source-policy", "image2_or_retrieval_reserved_slot_no_raster")
|
| 126 |
+
.attr("data-bbox", `${width * 0.65},10,${width * 0.26},${Math.max(48, margin.top - 24)}`)
|
| 127 |
+
.attr("data-no-embedded-raster", "true")
|
| 128 |
+
.attr("data-no-copied-reference-art", "true")
|
| 129 |
+
.attr("x", width * 0.65)
|
| 130 |
+
.attr("y", 10)
|
| 131 |
+
.attr("width", width * 0.26)
|
| 132 |
+
.attr("height", Math.max(48, margin.top - 24))
|
| 133 |
+
.attr("rx", 12)
|
| 134 |
+
.attr("fill", "none")
|
| 135 |
+
.attr("stroke", "none");
|
| 136 |
+
|
| 137 |
+
// 创建图表组
|
| 138 |
+
const g = svg.append("g")
|
| 139 |
+
.attr("transform", `translate(${margin.left},${margin.top})`);
|
| 140 |
+
|
| 141 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
|
| 142 |
+
|
| 143 |
+
// 为每个组分配固定位置 (1到groups.length)
|
| 144 |
+
const fixedGroupPositions = {};
|
| 145 |
+
groups.forEach((group, i) => {
|
| 146 |
+
fixedGroupPositions[group] = i + 1;
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
// 1. 计算每个x下各group的排名
|
| 150 |
+
// 构建排名数据结构:{ group1: [{x, rank}], group2: ... }
|
| 151 |
+
const rankData = {};
|
| 152 |
+
|
| 153 |
+
// 初始化每个group的数据结构
|
| 154 |
+
groups.forEach(group => {
|
| 155 |
+
rankData[group] = [];
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
// 为每个时间点和每个组创建排名数据
|
| 159 |
+
xValues.forEach(x => {
|
| 160 |
+
// 取出该x下所有group的数据
|
| 161 |
+
const items = chartData.filter(d => d[xField] === x);
|
| 162 |
+
|
| 163 |
+
// 记录该时间点存在的组
|
| 164 |
+
const existingGroups = items.map(d => d[groupField]);
|
| 165 |
+
|
| 166 |
+
// 按yField降序排序(y越大排名越高)
|
| 167 |
+
items.sort((a, b) => b[yField] - a[yField]);
|
| 168 |
+
|
| 169 |
+
// 为有数据的组计算排名
|
| 170 |
+
const rankByGroup = {};
|
| 171 |
+
items.forEach((d, i) => {
|
| 172 |
+
rankByGroup[d[groupField]] = i + 1; // 排名从1开始
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
// 为每个组添加排名数据,无论是否存在
|
| 176 |
+
groups.forEach(group => {
|
| 177 |
+
if (existingGroups.includes(group)) {
|
| 178 |
+
// 该组在该时间点有数据
|
| 179 |
+
const dataItem = items.find(d => d[groupField] === group);
|
| 180 |
+
rankData[group].push({
|
| 181 |
+
x: x,
|
| 182 |
+
rank: rankByGroup[group],
|
| 183 |
+
value: dataItem[yField],
|
| 184 |
+
missing: false
|
| 185 |
+
});
|
| 186 |
+
} else {
|
| 187 |
+
// 该组在该时间点没有数据
|
| 188 |
+
|
| 189 |
+
// 查找该组最近的有效排名
|
| 190 |
+
let nearestRank = null;
|
| 191 |
+
// 先尝试找前一个时间点的排名
|
| 192 |
+
const groupData = rankData[group];
|
| 193 |
+
if (groupData && groupData.length > 0) {
|
| 194 |
+
const lastValidPoint = [...groupData].reverse().find(d => !d.missing);
|
| 195 |
+
if (lastValidPoint) {
|
| 196 |
+
nearestRank = lastValidPoint.rank;
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
// 如果找不到有效的历史排名,使用固定位置
|
| 201 |
+
if (nearestRank === null) {
|
| 202 |
+
nearestRank = fixedGroupPositions[group];
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
rankData[group].push({
|
| 206 |
+
x: x,
|
| 207 |
+
rank: nearestRank, // 使用最近的有效排名或固定位置
|
| 208 |
+
value: null,
|
| 209 |
+
missing: true
|
| 210 |
+
});
|
| 211 |
+
}
|
| 212 |
+
});
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
// 对每个组的数据按x值排序
|
| 216 |
+
groups.forEach(group => {
|
| 217 |
+
rankData[group].sort((a, b) => {
|
| 218 |
+
const dateA = chartUtils.format.parseDate(a.x);
|
| 219 |
+
const dateB = chartUtils.format.parseDate(b.x);
|
| 220 |
+
return dateA - dateB;
|
| 221 |
+
});
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
// 2. y轴比例尺:排名(1在顶部)
|
| 225 |
+
const yScale = d3.scaleLinear()
|
| 226 |
+
.domain([1, groups.length])
|
| 227 |
+
.range([0, innerHeight]);
|
| 228 |
+
|
| 229 |
+
const yValues = chartData.map(d => +d[yField]).filter(d => d !== null && d !== undefined && !Number.isNaN(d));
|
| 230 |
+
const yMin = Math.min(...yValues);
|
| 231 |
+
const yMax = Math.max(...yValues);
|
| 232 |
+
const rowStep = groups.length > 1 ? innerHeight / (groups.length - 1) : innerHeight;
|
| 233 |
+
const rowBandHeight = Math.max(42, Math.min(72, rowStep * 0.70));
|
| 234 |
+
const rowX = -margin.left + Math.max(22, width * 0.035);
|
| 235 |
+
const rowW = width - Math.max(44, width * 0.07);
|
| 236 |
+
const tabW = Math.max(38, Math.min(52, rowBandHeight * 0.72));
|
| 237 |
+
const cardGap = 7;
|
| 238 |
+
const cardW = Math.max(108, Math.min(132, margin.left * 0.62));
|
| 239 |
+
const labelCardX = rowX + tabW + cardGap;
|
| 240 |
+
const plotStartGap = Math.max(70, labelCardX + cardW + 54);
|
| 241 |
+
const plotEnd = innerWidth - Math.max(12, width * 0.02);
|
| 242 |
+
const shiftedXScale = xScale.copy().range([plotStartGap, plotEnd]);
|
| 243 |
+
const nodeR = Math.max(9, Math.min(17, rowBandHeight * 0.22, (plotEnd - plotStartGap) / Math.max(18, xValues.length * 5.2)));
|
| 244 |
+
const labelFont = Math.max(8, Math.min(11, nodeR * 0.72));
|
| 245 |
+
|
| 246 |
+
const truncateToWidth = (text, maxWidth, fontSize, fontWeight = "700") => {
|
| 247 |
+
const value = String(text);
|
| 248 |
+
if (chartUtils.text.measure(g, value, { fontSize, fontWeight }).width <= maxWidth) {
|
| 249 |
+
return value;
|
| 250 |
+
}
|
| 251 |
+
let trimmed = value;
|
| 252 |
+
while (trimmed.length > 3 && chartUtils.text.measure(g, `${trimmed}...`, { fontSize, fontWeight }).width > maxWidth) {
|
| 253 |
+
trimmed = trimmed.slice(0, -1);
|
| 254 |
+
}
|
| 255 |
+
return `${trimmed}...`;
|
| 256 |
+
};
|
| 257 |
+
|
| 258 |
+
groups.forEach((group, i) => {
|
| 259 |
+
const rowY = yScale(fixedGroupPositions[group]);
|
| 260 |
+
const color = getColor(group);
|
| 261 |
+
g.append("rect")
|
| 262 |
+
.attr("class", "bump-plain-row-panel")
|
| 263 |
+
.attr("data-group", group)
|
| 264 |
+
.attr("x", rowX)
|
| 265 |
+
.attr("y", rowY - rowBandHeight / 2)
|
| 266 |
+
.attr("width", rowW)
|
| 267 |
+
.attr("height", rowBandHeight)
|
| 268 |
+
.attr("rx", 11)
|
| 269 |
+
.attr("fill", i % 2 === 0 ? "#FFFFFF" : "#F3F8FD")
|
| 270 |
+
.attr("stroke", "#D8EAF6")
|
| 271 |
+
.attr("stroke-width", 1)
|
| 272 |
+
.attr("filter", "url(#bump-plain-01-row-shadow)");
|
| 273 |
+
|
| 274 |
+
g.append("rect")
|
| 275 |
+
.attr("class", "bump-plain-rank-tab")
|
| 276 |
+
.attr("data-group", group)
|
| 277 |
+
.attr("x", rowX)
|
| 278 |
+
.attr("y", rowY - rowBandHeight / 2)
|
| 279 |
+
.attr("width", tabW)
|
| 280 |
+
.attr("height", rowBandHeight)
|
| 281 |
+
.attr("rx", 9)
|
| 282 |
+
.attr("fill", color);
|
| 283 |
+
g.append("rect")
|
| 284 |
+
.attr("x", rowX + tabW - 10)
|
| 285 |
+
.attr("y", rowY - rowBandHeight / 2)
|
| 286 |
+
.attr("width", 10)
|
| 287 |
+
.attr("height", rowBandHeight)
|
| 288 |
+
.attr("fill", color);
|
| 289 |
+
g.append("text")
|
| 290 |
+
.attr("class", "bump-plain-rank-label")
|
| 291 |
+
.attr("x", rowX + tabW / 2)
|
| 292 |
+
.attr("y", rowY + 6)
|
| 293 |
+
.attr("text-anchor", "middle")
|
| 294 |
+
.attr("font-size", Math.max(18, rowBandHeight * 0.36))
|
| 295 |
+
.attr("font-weight", 800)
|
| 296 |
+
.attr("fill", "#FFFFFF")
|
| 297 |
+
.text(i + 1);
|
| 298 |
+
|
| 299 |
+
g.append("rect")
|
| 300 |
+
.attr("class", "bump-plain-label-card")
|
| 301 |
+
.attr("data-group", group)
|
| 302 |
+
.attr("x", labelCardX)
|
| 303 |
+
.attr("y", rowY - rowBandHeight / 2 + 6)
|
| 304 |
+
.attr("width", cardW)
|
| 305 |
+
.attr("height", rowBandHeight - 12)
|
| 306 |
+
.attr("rx", 8)
|
| 307 |
+
.attr("fill", withOpacity(color, 0.10))
|
| 308 |
+
.attr("stroke", withOpacity(color, 0.28))
|
| 309 |
+
.attr("stroke-width", 1);
|
| 310 |
+
const slotR = Math.max(13, Math.min(19, rowBandHeight * 0.24));
|
| 311 |
+
const slotCx = labelCardX + 17 + slotR;
|
| 312 |
+
const slotId = `group_icon_slot_${i + 1}`;
|
| 313 |
+
g.append("circle")
|
| 314 |
+
.attr("class", "bump-plain-group-icon-slot")
|
| 315 |
+
.attr("data-asset-slot", slotId)
|
| 316 |
+
.attr("data-reserved-slot", "group_icon_slot")
|
| 317 |
+
.attr("data-group", group)
|
| 318 |
+
.attr("data-anchor", "row_label_card_centerline")
|
| 319 |
+
.attr("data-collision-rule", "stay inside label card and avoid group label and latest value text")
|
| 320 |
+
.attr("data-asset-source-policy", "image2_or_retrieval_reserved_slot_no_raster")
|
| 321 |
+
.attr("data-bbox", `${slotCx - slotR},${rowY - slotR},${slotR * 2},${slotR * 2}`)
|
| 322 |
+
.attr("data-no-embedded-raster", "true")
|
| 323 |
+
.attr("data-no-copied-reference-art", "true")
|
| 324 |
+
.attr("cx", slotCx)
|
| 325 |
+
.attr("cy", rowY)
|
| 326 |
+
.attr("r", slotR)
|
| 327 |
+
.attr("fill", withOpacity(color, 0.08))
|
| 328 |
+
.attr("stroke", withOpacity(color, 0.34))
|
| 329 |
+
.attr("stroke-width", 1.2)
|
| 330 |
+
.attr("stroke-dasharray", "3 3");
|
| 331 |
+
g.append("text")
|
| 332 |
+
.attr("class", "bump-plain-group-label")
|
| 333 |
+
.attr("x", slotCx + slotR + 9)
|
| 334 |
+
.attr("y", rowY - 4)
|
| 335 |
+
.attr("font-size", Math.max(11, Math.min(13, rowBandHeight * 0.19)))
|
| 336 |
+
.attr("font-weight", 800)
|
| 337 |
+
.attr("fill", "#173A5E")
|
| 338 |
+
.text(truncateToWidth(group, cardW - (slotCx - labelCardX) - slotR - 18, 13, "800"));
|
| 339 |
+
const latest = [...rankData[group]].reverse().find(d => !d.missing);
|
| 340 |
+
if (latest) {
|
| 341 |
+
g.append("text")
|
| 342 |
+
.attr("class", "bump-plain-card-value")
|
| 343 |
+
.attr("x", slotCx + slotR + 9)
|
| 344 |
+
.attr("y", rowY + 15)
|
| 345 |
+
.attr("font-size", Math.max(9, Math.min(11, rowBandHeight * 0.16)))
|
| 346 |
+
.attr("font-weight", 700)
|
| 347 |
+
.attr("fill", withOpacity("#173A5E", 0.72))
|
| 348 |
+
.text(`${xFormat(chartUtils.format.parseDate(latest.x))}: ${chartUtils.format.number(latest.value).text}`);
|
| 349 |
+
}
|
| 350 |
+
});
|
| 351 |
+
|
| 352 |
+
// 3. 绘制线条
|
| 353 |
+
groups.forEach(group => {
|
| 354 |
+
const groupRanks = rankData[group];
|
| 355 |
+
|
| 356 |
+
// 使用定义的线条生成器来创建中断的线条
|
| 357 |
+
const lineGenerator = d3.line()
|
| 358 |
+
.x(d => shiftedXScale(chartUtils.format.parseDate(d.x)))
|
| 359 |
+
.y(d => yScale(d.rank))
|
| 360 |
+
.curve(d3.curveMonotoneX)
|
| 361 |
+
.defined(d => !d.missing); // 只在有效数据点之间绘制线条
|
| 362 |
+
|
| 363 |
+
g.append("path")
|
| 364 |
+
.datum(groupRanks)
|
| 365 |
+
.attr("class", "bump-plain-connector")
|
| 366 |
+
.attr("data-group", group)
|
| 367 |
+
.attr("fill", "none")
|
| 368 |
+
.attr("stroke", getColor(group))
|
| 369 |
+
.attr("stroke-width", 2.6)
|
| 370 |
+
.attr("stroke-linecap", "round")
|
| 371 |
+
.attr("stroke-linejoin", "round")
|
| 372 |
+
.attr("opacity", 0.82)
|
| 373 |
+
.attr("d", lineGenerator);
|
| 374 |
+
|
| 375 |
+
const radiusScale = d3.scaleLinear()
|
| 376 |
+
.domain([yMin, yMax])
|
| 377 |
+
.range([nodeR * 0.78, nodeR * 1.08]);
|
| 378 |
+
|
| 379 |
+
// 为每个组生成一个安全的CSS类名
|
| 380 |
+
const safeGroupId = `group-${groups.indexOf(group)}`;
|
| 381 |
+
|
| 382 |
+
// 绘制每个点的圆点 - 只绘制非缺失的点
|
| 383 |
+
g.selectAll(`.dot-${safeGroupId}`)
|
| 384 |
+
.data(groupRanks.filter(d => !d.missing))
|
| 385 |
+
.enter()
|
| 386 |
+
.append("circle")
|
| 387 |
+
.attr("class", `bump-plain-node dot-${safeGroupId}`)
|
| 388 |
+
.attr("data-group", group)
|
| 389 |
+
.attr("data-x", d => d.x)
|
| 390 |
+
.attr("data-value", d => d.value)
|
| 391 |
+
.attr("cx", d => shiftedXScale(chartUtils.format.parseDate(d.x)))
|
| 392 |
+
.attr("cy", d => yScale(d.rank))
|
| 393 |
+
.attr("r", d => radiusScale(d.value))
|
| 394 |
+
.attr("fill", getColor(group))
|
| 395 |
+
.attr("stroke", "#fff")
|
| 396 |
+
.attr("stroke-width", 2.4);
|
| 397 |
+
|
| 398 |
+
// 添加数值标签 - 只为非缺失的点添加
|
| 399 |
+
g.selectAll(`.label-${safeGroupId}`)
|
| 400 |
+
.data(groupRanks.filter(d => !d.missing))
|
| 401 |
+
.enter()
|
| 402 |
+
.append("text")
|
| 403 |
+
.attr("class", `bump-plain-value-label label-${safeGroupId}`)
|
| 404 |
+
.attr("x", d => shiftedXScale(chartUtils.format.parseDate(d.x)))
|
| 405 |
+
.attr("y", d => yScale(d.rank))
|
| 406 |
+
.attr("text-anchor", "middle")
|
| 407 |
+
.attr("dy", "0.36em")
|
| 408 |
+
.attr("font-size", labelFont)
|
| 409 |
+
.attr("fill", () => chartUtils.color.brightness(getColor(group)) > 0.62 ? "#173A5E" : "#FFFFFF")
|
| 410 |
+
.attr("font-weight", 800)
|
| 411 |
+
.text(d => chartUtils.format.number(d.value, { maximumFractionDigits: 0 }).text);
|
| 412 |
+
});
|
| 413 |
+
|
| 414 |
+
// 优化时间标签避免重叠
|
| 415 |
+
const optimizeTimeLabels = (ticks, format, availableWidth) => {
|
| 416 |
+
if (ticks.length <= 1) return ticks;
|
| 417 |
+
|
| 418 |
+
// 计算所有标签的宽度
|
| 419 |
+
const calculateLabelWidth = (tick) => {
|
| 420 |
+
return chartUtils.text.measure(g, format(tick), { fontSize: 14 }).width + 20; // 添加一些边距
|
| 421 |
+
};
|
| 422 |
+
|
| 423 |
+
const tickWidths = ticks.map(tick => calculateLabelWidth(tick));
|
| 424 |
+
|
| 425 |
+
// 检查是否会重叠
|
| 426 |
+
let willOverlap = false;
|
| 427 |
+
let positions = ticks.map(t => xScale(t));
|
| 428 |
+
|
| 429 |
+
for (let i = 0; i < positions.length - 1; i++) {
|
| 430 |
+
const gap = positions[i+1] - positions[i];
|
| 431 |
+
if (gap < (tickWidths[i] + tickWidths[i+1]) / 2) {
|
| 432 |
+
willOverlap = true;
|
| 433 |
+
break;
|
| 434 |
+
}
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
// 如果不会重叠,显示所有标签
|
| 438 |
+
if (!willOverlap) {
|
| 439 |
+
return ticks;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
// 计算可以显示的标签数量
|
| 443 |
+
const averageWidth = tickWidths.reduce((a, b) => a + b, 0) / ticks.length;
|
| 444 |
+
let maxLabels = Math.floor(availableWidth / averageWidth);
|
| 445 |
+
|
| 446 |
+
// 确保至少显示首尾标签
|
| 447 |
+
if (maxLabels <= 2) {
|
| 448 |
+
return [ticks[0], ticks[ticks.length - 1]];
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
// 均匀选择标签
|
| 452 |
+
const step = Math.ceil(ticks.length / maxLabels);
|
| 453 |
+
const optimizedTicks = [];
|
| 454 |
+
|
| 455 |
+
for (let i = 0; i < ticks.length; i += step) {
|
| 456 |
+
optimizedTicks.push(ticks[i]);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
// 确保包含最后一个标签
|
| 460 |
+
if (optimizedTicks[optimizedTicks.length - 1] !== ticks[ticks.length - 1]) {
|
| 461 |
+
optimizedTicks.push(ticks[ticks.length - 1]);
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
return optimizedTicks;
|
| 465 |
+
};
|
| 466 |
+
|
| 467 |
+
// 优化时间标签
|
| 468 |
+
const optimizedXTicks = optimizeTimeLabels(xTicks, xFormat, innerWidth);
|
| 469 |
+
|
| 470 |
+
// 添加顶部时间标签
|
| 471 |
+
optimizedXTicks.forEach(tick => {
|
| 472 |
+
g.append("text")
|
| 473 |
+
.attr("class", "bump-plain-year-chip")
|
| 474 |
+
.attr("x", shiftedXScale(tick))
|
| 475 |
+
.attr("y", -36)
|
| 476 |
+
.attr("text-anchor", "middle")
|
| 477 |
+
.attr("font-size", 11)
|
| 478 |
+
.attr("font-weight", 800)
|
| 479 |
+
.attr("fill", "#FFFFFF")
|
| 480 |
+
.text(xFormat(tick));
|
| 481 |
+
});
|
| 482 |
+
|
| 483 |
+
optimizedXTicks.forEach(tick => {
|
| 484 |
+
const label = xFormat(tick);
|
| 485 |
+
const chipW = Math.max(32, chartUtils.text.measure(g, label, { fontSize: 11, fontWeight: "800" }).width + 14);
|
| 486 |
+
g.insert("rect", ".bump-plain-year-chip")
|
| 487 |
+
.attr("class", "bump-plain-year-chip-bg")
|
| 488 |
+
.attr("x", shiftedXScale(tick) - chipW / 2)
|
| 489 |
+
.attr("y", -51)
|
| 490 |
+
.attr("width", chipW)
|
| 491 |
+
.attr("height", 22)
|
| 492 |
+
.attr("rx", 7)
|
| 493 |
+
.attr("fill", "#143E70");
|
| 494 |
+
});
|
| 495 |
+
|
| 496 |
+
return svg.node();
|
| 497 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/bump_chart_06.js
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Bump Chart",
|
| 5 |
+
"chart_name": "bump_chart_06",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"chart_for": "comparison",
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [
|
| 11 |
+
[4, 12],
|
| 12 |
+
[0, "inf"],
|
| 13 |
+
[3, 10]
|
| 14 |
+
],
|
| 15 |
+
"required_fields_icons": ["group"],
|
| 16 |
+
"required_other_icons": [],
|
| 17 |
+
"required_fields_colors": ["group"],
|
| 18 |
+
"required_other_colors": [],
|
| 19 |
+
"supported_effects": ["spacing"],
|
| 20 |
+
"min_height": 400,
|
| 21 |
+
"min_width": 400,
|
| 22 |
+
"background": "no",
|
| 23 |
+
"icon_mark": "none",
|
| 24 |
+
"icon_label": "none",
|
| 25 |
+
"has_x_axis": "no",
|
| 26 |
+
"has_y_axis": "no"
|
| 27 |
+
}
|
| 28 |
+
REQUIREMENTS_END
|
| 29 |
+
*/
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
function makeChart(containerSelector, data) {
|
| 34 |
+
// Reference-targeted rank-card bump chart for bump_chart_06.
|
| 35 |
+
const jsonData = data;
|
| 36 |
+
const chartData = jsonData.data.data;
|
| 37 |
+
const variables = jsonData.variables;
|
| 38 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 39 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 40 |
+
let colorByGroup = {};
|
| 41 |
+
const resolveInputColor = (group) => {
|
| 42 |
+
return colorResolver.field(group, 0, { fallbackKey: "primary", fallback: "#888" }).value || "#356FAE";
|
| 43 |
+
};
|
| 44 |
+
const getColor = (group) => {
|
| 45 |
+
return colorByGroup[group] || resolveInputColor(group);
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
d3.select(containerSelector).html("");
|
| 49 |
+
|
| 50 |
+
const width = variables.width;
|
| 51 |
+
const height = variables.height;
|
| 52 |
+
const margin = {
|
| 53 |
+
top: Math.max(70, Math.min(92, height * 0.16)),
|
| 54 |
+
right: Math.max(24, Math.min(42, width * 0.06)),
|
| 55 |
+
bottom: Math.max(22, Math.min(40, height * 0.07)),
|
| 56 |
+
left: Math.max(210, Math.min(265, width * 0.43))
|
| 57 |
+
};
|
| 58 |
+
const innerWidth = width - margin.left - margin.right;
|
| 59 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 60 |
+
|
| 61 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 62 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 63 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 64 |
+
|
| 65 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 66 |
+
const xValues = [...new Set(chartData.map(d => d[xField]))]
|
| 67 |
+
.sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
|
| 68 |
+
|
| 69 |
+
const svg = d3.select(containerSelector)
|
| 70 |
+
.append("svg")
|
| 71 |
+
.attr("width", "100%")
|
| 72 |
+
.attr("height", height)
|
| 73 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 74 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 75 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 76 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 77 |
+
|
| 78 |
+
svg.append("rect")
|
| 79 |
+
.attr("class", "bump06-chart-bg")
|
| 80 |
+
.attr("width", width)
|
| 81 |
+
.attr("height", height)
|
| 82 |
+
.attr("fill", "#EAF6FB");
|
| 83 |
+
|
| 84 |
+
const defs = svg.append("defs");
|
| 85 |
+
const rowShadow = defs.append("filter")
|
| 86 |
+
.attr("id", "bump06-row-shadow")
|
| 87 |
+
.attr("x", "-8%")
|
| 88 |
+
.attr("y", "-24%")
|
| 89 |
+
.attr("width", "116%")
|
| 90 |
+
.attr("height", "160%");
|
| 91 |
+
rowShadow.append("feDropShadow")
|
| 92 |
+
.attr("dx", 0)
|
| 93 |
+
.attr("dy", 5)
|
| 94 |
+
.attr("stdDeviation", 7)
|
| 95 |
+
.attr("flood-color", "#0A3152")
|
| 96 |
+
.attr("flood-opacity", 0.10);
|
| 97 |
+
|
| 98 |
+
defs.append("clipPath")
|
| 99 |
+
.attr("id", "bump06-card-clip")
|
| 100 |
+
.append("rect")
|
| 101 |
+
.attr("x", 12)
|
| 102 |
+
.attr("y", margin.top - 48)
|
| 103 |
+
.attr("width", Math.max(1, width - 24))
|
| 104 |
+
.attr("height", Math.max(1, height - margin.top - margin.bottom + 96))
|
| 105 |
+
.attr("rx", 16);
|
| 106 |
+
|
| 107 |
+
const g = svg.append("g")
|
| 108 |
+
.attr("transform", `translate(${margin.left},${margin.top})`);
|
| 109 |
+
|
| 110 |
+
const axisInfo = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
|
| 111 |
+
const xScale = d3.scalePoint()
|
| 112 |
+
.domain(xValues)
|
| 113 |
+
.range([0, innerWidth])
|
| 114 |
+
.padding(0.08);
|
| 115 |
+
const xTicks = xValues;
|
| 116 |
+
const xFormat = (tick) => axisInfo.xFormat(chartUtils.format.parseDate(tick));
|
| 117 |
+
|
| 118 |
+
const rankData = {};
|
| 119 |
+
groups.forEach(group => {
|
| 120 |
+
rankData[group] = [];
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
xValues.forEach(x => {
|
| 124 |
+
const items = chartData.filter(d => d[xField] === x);
|
| 125 |
+
items.sort((a, b) => b[yField] - a[yField]);
|
| 126 |
+
|
| 127 |
+
const groupsWithData = new Set(items.map(d => d[groupField]));
|
| 128 |
+
items.forEach((d, i) => {
|
| 129 |
+
rankData[d[groupField]].push({
|
| 130 |
+
x: d[xField],
|
| 131 |
+
rank: i + 1,
|
| 132 |
+
value: d[yField],
|
| 133 |
+
hasData: true
|
| 134 |
+
});
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
groups.forEach(group => {
|
| 138 |
+
if (!groupsWithData.has(group)) {
|
| 139 |
+
const existingEntries = rankData[group];
|
| 140 |
+
const lastRank = existingEntries.length > 0 ? existingEntries[existingEntries.length - 1].rank : groups.length;
|
| 141 |
+
rankData[group].push({
|
| 142 |
+
x: x,
|
| 143 |
+
rank: lastRank,
|
| 144 |
+
value: null,
|
| 145 |
+
hasData: false
|
| 146 |
+
});
|
| 147 |
+
}
|
| 148 |
+
});
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
groups.forEach(group => {
|
| 152 |
+
rankData[group].sort((a, b) => {
|
| 153 |
+
return chartUtils.format.parseDate(a.x) - chartUtils.format.parseDate(b.x);
|
| 154 |
+
});
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
const yScale = d3.scaleLinear()
|
| 158 |
+
.domain([1, groups.length])
|
| 159 |
+
.range([0, innerHeight]);
|
| 160 |
+
|
| 161 |
+
const initialRankByGroup = {};
|
| 162 |
+
groups.forEach(group => {
|
| 163 |
+
const first = rankData[group].find(d => d.hasData) || rankData[group][0];
|
| 164 |
+
initialRankByGroup[group] = first ? first.rank : groups.indexOf(group) + 1;
|
| 165 |
+
});
|
| 166 |
+
const labelOrder = [...groups].sort((a, b) => initialRankByGroup[a] - initialRankByGroup[b]);
|
| 167 |
+
const fallbackPalette = ["#159447", "#D21D1D", "#FF7A18", "#164A91", "#F4C400", "#49BBD2", "#8B64B4", "#2B8F73", "#D94F94", "#6A7A24"];
|
| 168 |
+
const resolvedColors = groups.map(group => resolveInputColor(group).toLowerCase());
|
| 169 |
+
const hasDuplicateColors = new Set(resolvedColors).size < resolvedColors.length;
|
| 170 |
+
labelOrder.forEach((group, i) => {
|
| 171 |
+
colorByGroup[group] = hasDuplicateColors ? fallbackPalette[i % fallbackPalette.length] : resolveInputColor(group);
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
const rowStep = groups.length > 1 ? innerHeight / (groups.length - 1) : innerHeight;
|
| 175 |
+
const rowBandHeight = Math.max(54, Math.min(88, rowStep * 0.76));
|
| 176 |
+
const rowBandX = -margin.left + Math.max(12, width * 0.018);
|
| 177 |
+
const rowBandW = width - Math.max(24, width * 0.036);
|
| 178 |
+
const tabW = Math.max(28, Math.min(44, rowBandHeight * 0.48));
|
| 179 |
+
const iconR = Math.max(16, Math.min(29, rowBandHeight * 0.34));
|
| 180 |
+
const iconX = rowBandX + tabW + iconR + Math.max(18, width * 0.025);
|
| 181 |
+
const labelX = iconX + iconR + Math.max(22, width * 0.034);
|
| 182 |
+
const labelMaxWidth = Math.max(78, margin.left - (labelX + margin.left) - 16);
|
| 183 |
+
|
| 184 |
+
const measureText = (text, fontSize, fontWeight = "700") => {
|
| 185 |
+
return chartUtils.text.measure(g, String(text), { fontSize, fontWeight }).width;
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
const truncateToWidth = (text, maxWidth, fontSize, fontWeight = "800") => {
|
| 189 |
+
const value = String(text);
|
| 190 |
+
if (measureText(value, fontSize, fontWeight) <= maxWidth) return value;
|
| 191 |
+
let trimmed = value;
|
| 192 |
+
while (trimmed.length > 3 && measureText(`${trimmed}...`, fontSize, fontWeight) > maxWidth) {
|
| 193 |
+
trimmed = trimmed.slice(0, -1);
|
| 194 |
+
}
|
| 195 |
+
return `${trimmed}...`;
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
const wrapToLines = (text, maxWidth, fontSize, fontWeight = "800", maxLines = 2) => {
|
| 199 |
+
const words = String(text).split(/\s+/).filter(Boolean);
|
| 200 |
+
const lines = [];
|
| 201 |
+
let current = "";
|
| 202 |
+
words.forEach(word => {
|
| 203 |
+
const candidate = current ? `${current} ${word}` : word;
|
| 204 |
+
if (measureText(candidate, fontSize, fontWeight) <= maxWidth || !current) {
|
| 205 |
+
current = candidate;
|
| 206 |
+
} else {
|
| 207 |
+
lines.push(current);
|
| 208 |
+
current = word;
|
| 209 |
+
}
|
| 210 |
+
});
|
| 211 |
+
if (current) lines.push(current);
|
| 212 |
+
if (lines.length <= maxLines) return lines;
|
| 213 |
+
const clipped = lines.slice(0, maxLines);
|
| 214 |
+
clipped[maxLines - 1] = truncateToWidth(clipped[maxLines - 1], maxWidth, fontSize, fontWeight);
|
| 215 |
+
return clipped;
|
| 216 |
+
};
|
| 217 |
+
|
| 218 |
+
const optimizeTimeLabels = (ticks, format, availableWidth) => {
|
| 219 |
+
if (ticks.length <= 1) return ticks;
|
| 220 |
+
const calculateLabelWidth = (tick) => {
|
| 221 |
+
return chartUtils.text.measure(g, format(tick), { fontSize: 13, fontWeight: "800" }).width + 20;
|
| 222 |
+
};
|
| 223 |
+
const tickWidths = ticks.map(tick => calculateLabelWidth(tick));
|
| 224 |
+
let willOverlap = false;
|
| 225 |
+
let positions = ticks.map(t => xScale(t));
|
| 226 |
+
for (let i = 0; i < positions.length - 1; i++) {
|
| 227 |
+
const gap = positions[i+1] - positions[i];
|
| 228 |
+
if (gap < (tickWidths[i] + tickWidths[i+1]) / 2) {
|
| 229 |
+
willOverlap = true;
|
| 230 |
+
break;
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
if (!willOverlap) return ticks;
|
| 234 |
+
const averageWidth = tickWidths.reduce((a, b) => a + b, 0) / ticks.length;
|
| 235 |
+
let maxLabels = Math.floor(availableWidth / averageWidth);
|
| 236 |
+
if (maxLabels <= 2) return [ticks[0], ticks[ticks.length - 1]];
|
| 237 |
+
const step = Math.ceil(ticks.length / maxLabels);
|
| 238 |
+
const optimizedTicks = [];
|
| 239 |
+
for (let i = 0; i < ticks.length; i += step) {
|
| 240 |
+
optimizedTicks.push(ticks[i]);
|
| 241 |
+
}
|
| 242 |
+
if (optimizedTicks[optimizedTicks.length - 1] !== ticks[ticks.length - 1]) {
|
| 243 |
+
optimizedTicks.push(ticks[ticks.length - 1]);
|
| 244 |
+
}
|
| 245 |
+
return optimizedTicks;
|
| 246 |
+
};
|
| 247 |
+
|
| 248 |
+
const enforceTickGap = (ticks, minGap) => {
|
| 249 |
+
const kept = [];
|
| 250 |
+
ticks.forEach((tick, index) => {
|
| 251 |
+
const currentX = xScale(tick);
|
| 252 |
+
if (!kept.length) {
|
| 253 |
+
kept.push(tick);
|
| 254 |
+
return;
|
| 255 |
+
}
|
| 256 |
+
const prevX = xScale(kept[kept.length - 1]);
|
| 257 |
+
if (currentX - prevX >= minGap) {
|
| 258 |
+
kept.push(tick);
|
| 259 |
+
} else if (index === ticks.length - 1) {
|
| 260 |
+
kept[kept.length - 1] = tick;
|
| 261 |
+
}
|
| 262 |
+
});
|
| 263 |
+
return kept;
|
| 264 |
+
};
|
| 265 |
+
|
| 266 |
+
const optimizedXTicks = optimizeTimeLabels(xTicks, xFormat, innerWidth);
|
| 267 |
+
const visibleXTicks = enforceTickGap(optimizedXTicks, Math.max(34, width * 0.075));
|
| 268 |
+
|
| 269 |
+
labelOrder.forEach((group, i) => {
|
| 270 |
+
const rowY = yScale(initialRankByGroup[group]);
|
| 271 |
+
const color = getColor(group);
|
| 272 |
+
g.append("rect")
|
| 273 |
+
.attr("class", "bump06-row-card")
|
| 274 |
+
.attr("x", rowBandX)
|
| 275 |
+
.attr("y", rowY - rowBandHeight / 2)
|
| 276 |
+
.attr("width", rowBandW)
|
| 277 |
+
.attr("height", rowBandHeight)
|
| 278 |
+
.attr("rx", Math.min(18, rowBandHeight * 0.24))
|
| 279 |
+
.attr("fill", i % 2 === 0 ? "#FFFFFF" : "#F9FCFF")
|
| 280 |
+
.attr("filter", "url(#bump06-row-shadow)");
|
| 281 |
+
|
| 282 |
+
g.append("rect")
|
| 283 |
+
.attr("class", "bump06-rank-tab")
|
| 284 |
+
.attr("x", rowBandX)
|
| 285 |
+
.attr("y", rowY - rowBandHeight / 2 + 3)
|
| 286 |
+
.attr("width", tabW)
|
| 287 |
+
.attr("height", rowBandHeight - 6)
|
| 288 |
+
.attr("rx", Math.min(13, (rowBandHeight - 6) / 2))
|
| 289 |
+
.attr("fill", color);
|
| 290 |
+
|
| 291 |
+
g.append("text")
|
| 292 |
+
.attr("class", "bump06-rank-label")
|
| 293 |
+
.attr("x", rowBandX + tabW / 2)
|
| 294 |
+
.attr("y", rowY + 7)
|
| 295 |
+
.attr("text-anchor", "middle")
|
| 296 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 297 |
+
.attr("font-weight", "900")
|
| 298 |
+
.attr("font-size", Math.max(18, Math.min(29, rowBandHeight * 0.38)))
|
| 299 |
+
.attr("fill", "#FFFFFF")
|
| 300 |
+
.text(i + 1);
|
| 301 |
+
|
| 302 |
+
const slot = g.append("g")
|
| 303 |
+
.attr("class", "reserved-asset-slot bump-group-icon-slot")
|
| 304 |
+
.attr("data-asset-slot", "group-icon")
|
| 305 |
+
.attr("data-asset-source-policy", jsonData.images && jsonData.images.field && jsonData.images.field[group] ? "input_provided" : "neutral_placeholder")
|
| 306 |
+
.attr("data-anchor", `row-${i + 1}-center`)
|
| 307 |
+
.attr("data-collision-rule", "stay inside row card and left of plotted bump lines")
|
| 308 |
+
.attr("transform", `translate(${iconX},${rowY})`);
|
| 309 |
+
slot.append("circle")
|
| 310 |
+
.attr("r", iconR)
|
| 311 |
+
.attr("fill", "#EEF6FF")
|
| 312 |
+
.attr("stroke", "#FFFFFF")
|
| 313 |
+
.attr("stroke-width", 3);
|
| 314 |
+
if (jsonData.images && jsonData.images.field && jsonData.images.field[group]) {
|
| 315 |
+
slot.append("image")
|
| 316 |
+
.attr("class", "input-provided-group-icon")
|
| 317 |
+
.attr("data-asset-slot", "group-icon-image")
|
| 318 |
+
.attr("data-asset-source-policy", "input_provided")
|
| 319 |
+
.attr("xlink:href", jsonData.images.field[group])
|
| 320 |
+
.attr("x", -iconR * 0.78)
|
| 321 |
+
.attr("y", -iconR * 0.78)
|
| 322 |
+
.attr("width", iconR * 1.56)
|
| 323 |
+
.attr("height", iconR * 1.56)
|
| 324 |
+
.attr("preserveAspectRatio", "xMidYMid meet");
|
| 325 |
+
} else {
|
| 326 |
+
slot.append("circle")
|
| 327 |
+
.attr("r", iconR * 0.58)
|
| 328 |
+
.attr("fill", "none")
|
| 329 |
+
.attr("stroke", color)
|
| 330 |
+
.attr("stroke-width", 2)
|
| 331 |
+
.attr("opacity", 0.30);
|
| 332 |
+
slot.append("text")
|
| 333 |
+
.attr("text-anchor", "middle")
|
| 334 |
+
.attr("dy", "0.35em")
|
| 335 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 336 |
+
.attr("font-weight", "900")
|
| 337 |
+
.attr("font-size", Math.max(9, iconR * 0.52))
|
| 338 |
+
.attr("fill", color)
|
| 339 |
+
.text(String(group).trim().slice(0, 1).toUpperCase());
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
const labelFontSize = Math.max(12, Math.min(18, rowBandHeight * 0.23));
|
| 343 |
+
const labelLines = wrapToLines(group, labelMaxWidth, labelFontSize, "900", rowBandHeight > 64 ? 2 : 1);
|
| 344 |
+
const label = g.append("text")
|
| 345 |
+
.attr("class", "bump06-group-label")
|
| 346 |
+
.attr("x", labelX)
|
| 347 |
+
.attr("y", rowY - ((labelLines.length - 1) * labelFontSize * 0.56))
|
| 348 |
+
.attr("text-anchor", "start")
|
| 349 |
+
.attr("dominant-baseline", "middle")
|
| 350 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 351 |
+
.attr("font-weight", "900")
|
| 352 |
+
.attr("font-size", labelFontSize)
|
| 353 |
+
.attr("fill", "#0C2B55");
|
| 354 |
+
labelLines.forEach((line, lineIndex) => {
|
| 355 |
+
label.append("tspan")
|
| 356 |
+
.attr("x", labelX)
|
| 357 |
+
.attr("dy", lineIndex === 0 ? 0 : labelFontSize * 1.08)
|
| 358 |
+
.text(line);
|
| 359 |
+
});
|
| 360 |
+
|
| 361 |
+
});
|
| 362 |
+
|
| 363 |
+
const axisY = -62;
|
| 364 |
+
const railX = xScale(visibleXTicks[0]) - Math.max(34, width * 0.07);
|
| 365 |
+
const railW = xScale(visibleXTicks[visibleXTicks.length - 1]) - xScale(visibleXTicks[0]) + Math.max(68, width * 0.14);
|
| 366 |
+
g.append("rect")
|
| 367 |
+
.attr("class", "bump06-time-rail")
|
| 368 |
+
.attr("x", railX)
|
| 369 |
+
.attr("y", axisY - 18)
|
| 370 |
+
.attr("width", railW)
|
| 371 |
+
.attr("height", 36)
|
| 372 |
+
.attr("rx", 18)
|
| 373 |
+
.attr("fill", "#102C55");
|
| 374 |
+
|
| 375 |
+
visibleXTicks.forEach(tick => {
|
| 376 |
+
const tickX = xScale(tick);
|
| 377 |
+
g.append("line")
|
| 378 |
+
.attr("class", "bump06-vertical-guide")
|
| 379 |
+
.attr("x1", tickX)
|
| 380 |
+
.attr("x2", tickX)
|
| 381 |
+
.attr("y1", axisY + 20)
|
| 382 |
+
.attr("y2", innerHeight + rowBandHeight / 2)
|
| 383 |
+
.attr("stroke", "#C8D7E4")
|
| 384 |
+
.attr("stroke-width", 1.2)
|
| 385 |
+
.attr("stroke-dasharray", "4 5")
|
| 386 |
+
.attr("opacity", 0.95);
|
| 387 |
+
|
| 388 |
+
g.append("text")
|
| 389 |
+
.attr("x", tickX)
|
| 390 |
+
.attr("y", axisY + 6)
|
| 391 |
+
.attr("text-anchor", "middle")
|
| 392 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 393 |
+
.attr("font-weight", "900")
|
| 394 |
+
.attr("font-size", Math.max(12, Math.min(18, width * 0.027)))
|
| 395 |
+
.attr("fill", "#FFFFFF")
|
| 396 |
+
.text(xFormat(tick));
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
+
const allValues = chartData
|
| 400 |
+
.filter(d => d[yField] !== null && d[yField] !== undefined)
|
| 401 |
+
.map(d => +d[yField]);
|
| 402 |
+
const yMin = Math.min(...allValues);
|
| 403 |
+
const yMax = Math.max(...allValues);
|
| 404 |
+
const radiusScale = d3.scaleLinear()
|
| 405 |
+
.domain([yMin, yMax])
|
| 406 |
+
.range([
|
| 407 |
+
Math.max(5, Math.min(8, rowBandHeight * 0.10)),
|
| 408 |
+
Math.max(7, Math.min(11, rowBandHeight * 0.14))
|
| 409 |
+
]);
|
| 410 |
+
|
| 411 |
+
groups.forEach(group => {
|
| 412 |
+
const groupRanks = rankData[group];
|
| 413 |
+
const validSegments = [];
|
| 414 |
+
let currentSegment = [];
|
| 415 |
+
groupRanks.forEach((d, i) => {
|
| 416 |
+
if (d.hasData) {
|
| 417 |
+
currentSegment.push(d);
|
| 418 |
+
} else if (currentSegment.length > 0) {
|
| 419 |
+
validSegments.push(currentSegment);
|
| 420 |
+
currentSegment = [];
|
| 421 |
+
}
|
| 422 |
+
});
|
| 423 |
+
if (currentSegment.length > 0) {
|
| 424 |
+
validSegments.push(currentSegment);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
validSegments.forEach(segment => {
|
| 428 |
+
if (segment.length > 1) {
|
| 429 |
+
g.append("path")
|
| 430 |
+
.datum(segment)
|
| 431 |
+
.attr("fill", "none")
|
| 432 |
+
.attr("stroke", getColor(group))
|
| 433 |
+
.attr("stroke-width", Math.max(3, Math.min(4.5, rowBandHeight * 0.055)))
|
| 434 |
+
.attr("stroke-linecap", "round")
|
| 435 |
+
.attr("stroke-linejoin", "round")
|
| 436 |
+
.attr("opacity", 0.98)
|
| 437 |
+
.attr("d", d3.line()
|
| 438 |
+
.x(d => xScale(d.x))
|
| 439 |
+
.y(d => yScale(d.rank))
|
| 440 |
+
);
|
| 441 |
+
}
|
| 442 |
+
});
|
| 443 |
+
|
| 444 |
+
groupRanks.forEach(d => {
|
| 445 |
+
if (d.hasData) {
|
| 446 |
+
const x = xScale(d.x);
|
| 447 |
+
const y = yScale(d.rank);
|
| 448 |
+
const r = radiusScale(+d.value);
|
| 449 |
+
g.append("circle")
|
| 450 |
+
.attr("class", "bump06-value-node")
|
| 451 |
+
.attr("data-group", group)
|
| 452 |
+
.attr("data-x", d.x)
|
| 453 |
+
.attr("cx", x)
|
| 454 |
+
.attr("cy", y)
|
| 455 |
+
.attr("r", r)
|
| 456 |
+
.attr("fill", getColor(group))
|
| 457 |
+
.attr("stroke", "#fff")
|
| 458 |
+
.attr("stroke-width", 2.4);
|
| 459 |
+
|
| 460 |
+
const labelY = y - r - Math.max(5, rowBandHeight * 0.055);
|
| 461 |
+
g.append("text")
|
| 462 |
+
.attr("class", "bump06-value-label")
|
| 463 |
+
.attr("x", x)
|
| 464 |
+
.attr("y", labelY)
|
| 465 |
+
.attr("text-anchor", "middle")
|
| 466 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 467 |
+
.attr("font-weight", "900")
|
| 468 |
+
.attr("font-size", Math.max(10, Math.min(16, rowBandHeight * 0.19)))
|
| 469 |
+
.attr("fill", getColor(group))
|
| 470 |
+
.text(chartUtils.format.autoText(+d.value));
|
| 471 |
+
}
|
| 472 |
+
});
|
| 473 |
+
});
|
| 474 |
+
|
| 475 |
+
return svg.node();
|
| 476 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/bump_chart_07.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Bump Chart",
|
| 5 |
+
"chart_name": "bump_chart_07",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"chart_for": "comparison",
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [
|
| 11 |
+
[4, 12],
|
| 12 |
+
[0, "inf"],
|
| 13 |
+
[3, 10]
|
| 14 |
+
],
|
| 15 |
+
"required_fields_icons": [],
|
| 16 |
+
"required_other_icons": [],
|
| 17 |
+
"required_fields_colors": ["group"],
|
| 18 |
+
"required_other_colors": [],
|
| 19 |
+
"supported_effects": ["spacing"],
|
| 20 |
+
"min_height": 400,
|
| 21 |
+
"min_width": 400,
|
| 22 |
+
"background": "no",
|
| 23 |
+
"icon_mark": "none",
|
| 24 |
+
"icon_label": "none",
|
| 25 |
+
"has_x_axis": "no",
|
| 26 |
+
"has_y_axis": "no"
|
| 27 |
+
}
|
| 28 |
+
REQUIREMENTS_END
|
| 29 |
+
*/
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
function makeChart(containerSelector, data) {
|
| 33 |
+
const jsonData = data;
|
| 34 |
+
const chartData = jsonData.data.data;
|
| 35 |
+
const variables = jsonData.variables;
|
| 36 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 37 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 38 |
+
const colorByGroup = {};
|
| 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 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 45 |
+
const xValues = [...new Set(chartData.map(d => d[xField]))]
|
| 46 |
+
.sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
|
| 47 |
+
|
| 48 |
+
const resolveInputColor = (group) => {
|
| 49 |
+
return colorResolver.field(group, 0, { fallbackKey: "primary", fallback: "#2E7DD7" }).value || "#2E7DD7";
|
| 50 |
+
};
|
| 51 |
+
const getColor = (group) => colorByGroup[group] || resolveInputColor(group);
|
| 52 |
+
|
| 53 |
+
d3.select(containerSelector).html("");
|
| 54 |
+
|
| 55 |
+
const width = variables.width;
|
| 56 |
+
const height = variables.height;
|
| 57 |
+
const margin = {
|
| 58 |
+
top: Math.max(76, Math.min(96, height * 0.15)),
|
| 59 |
+
right: Math.max(24, Math.min(44, width * 0.06)),
|
| 60 |
+
bottom: Math.max(26, Math.min(44, height * 0.07)),
|
| 61 |
+
left: Math.max(220, Math.min(260, width * 0.42))
|
| 62 |
+
};
|
| 63 |
+
const innerWidth = width - margin.left - margin.right;
|
| 64 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 65 |
+
|
| 66 |
+
const svg = d3.select(containerSelector)
|
| 67 |
+
.append("svg")
|
| 68 |
+
.attr("width", "100%")
|
| 69 |
+
.attr("height", height)
|
| 70 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 71 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 72 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 73 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 74 |
+
|
| 75 |
+
svg.append("rect")
|
| 76 |
+
.attr("class", "bump07-chart-bg")
|
| 77 |
+
.attr("width", width)
|
| 78 |
+
.attr("height", height)
|
| 79 |
+
.attr("fill", "none");
|
| 80 |
+
|
| 81 |
+
const defs = svg.append("defs");
|
| 82 |
+
const rowShadow = defs.append("filter")
|
| 83 |
+
.attr("id", "bump07-row-shadow")
|
| 84 |
+
.attr("x", "-8%")
|
| 85 |
+
.attr("y", "-20%")
|
| 86 |
+
.attr("width", "116%")
|
| 87 |
+
.attr("height", "150%");
|
| 88 |
+
rowShadow.append("feDropShadow")
|
| 89 |
+
.attr("dx", 0)
|
| 90 |
+
.attr("dy", 4)
|
| 91 |
+
.attr("stdDeviation", 5)
|
| 92 |
+
.attr("flood-color", "#173C5A")
|
| 93 |
+
.attr("flood-opacity", 0.11);
|
| 94 |
+
|
| 95 |
+
const g = svg.append("g")
|
| 96 |
+
.attr("transform", `translate(${margin.left},${margin.top})`);
|
| 97 |
+
|
| 98 |
+
const axisInfo = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
|
| 99 |
+
const xScale = d3.scalePoint()
|
| 100 |
+
.domain(xValues)
|
| 101 |
+
.range([0, innerWidth])
|
| 102 |
+
.padding(0.08);
|
| 103 |
+
const xFormat = (tick) => axisInfo.xFormat(chartUtils.format.parseDate(tick));
|
| 104 |
+
|
| 105 |
+
const rankData = {};
|
| 106 |
+
groups.forEach(group => {
|
| 107 |
+
rankData[group] = [];
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
xValues.forEach(x => {
|
| 111 |
+
const items = chartData
|
| 112 |
+
.filter(d => d[xField] === x)
|
| 113 |
+
.sort((a, b) => b[yField] - a[yField]);
|
| 114 |
+
const seen = new Set(items.map(d => d[groupField]));
|
| 115 |
+
|
| 116 |
+
items.forEach((d, i) => {
|
| 117 |
+
rankData[d[groupField]].push({
|
| 118 |
+
x: d[xField],
|
| 119 |
+
rank: i + 1,
|
| 120 |
+
value: +d[yField],
|
| 121 |
+
hasData: true
|
| 122 |
+
});
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
groups.forEach(group => {
|
| 126 |
+
if (!seen.has(group)) {
|
| 127 |
+
const existing = rankData[group];
|
| 128 |
+
const lastRank = existing.length ? existing[existing.length - 1].rank : groups.length;
|
| 129 |
+
rankData[group].push({ x, rank: lastRank, value: null, hasData: false });
|
| 130 |
+
}
|
| 131 |
+
});
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
groups.forEach(group => {
|
| 135 |
+
rankData[group].sort((a, b) => chartUtils.format.parseDate(a.x) - chartUtils.format.parseDate(b.x));
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
const baseRowStep = groups.length > 1 ? innerHeight / (groups.length - 1) : innerHeight;
|
| 139 |
+
const topRowClearance = Math.max(34, Math.min(48, baseRowStep * 0.24));
|
| 140 |
+
const bottomRowInset = Math.max(0, Math.min(16, baseRowStep * 0.06));
|
| 141 |
+
const effectiveRowHeight = Math.max(1, innerHeight - topRowClearance - bottomRowInset);
|
| 142 |
+
const rowStep = groups.length > 1 ? effectiveRowHeight / (groups.length - 1) : effectiveRowHeight;
|
| 143 |
+
const yScale = d3.scaleLinear()
|
| 144 |
+
.domain([1, Math.max(1, groups.length)])
|
| 145 |
+
.range([topRowClearance, innerHeight - bottomRowInset]);
|
| 146 |
+
|
| 147 |
+
const initialRankByGroup = {};
|
| 148 |
+
groups.forEach(group => {
|
| 149 |
+
const first = rankData[group].find(d => d.hasData) || rankData[group][0];
|
| 150 |
+
initialRankByGroup[group] = first ? first.rank : groups.indexOf(group) + 1;
|
| 151 |
+
});
|
| 152 |
+
const labelOrder = [...groups].sort((a, b) => initialRankByGroup[a] - initialRankByGroup[b]);
|
| 153 |
+
|
| 154 |
+
const fallbackPalette = ["#43A333", "#6E47C8", "#2376D9", "#D94D7D", "#FF8618", "#12A89E", "#F4B400", "#4867C8", "#E95AA8", "#3E9B7A"];
|
| 155 |
+
labelOrder.forEach((group, i) => {
|
| 156 |
+
colorByGroup[group] = resolveInputColor(group) || fallbackPalette[i % fallbackPalette.length];
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
const rowBandHeight = Math.max(62, Math.min(96, rowStep * 0.78));
|
| 160 |
+
const rowBandX = -margin.left + Math.max(18, width * 0.035);
|
| 161 |
+
const rowBandW = width - Math.max(34, width * 0.058);
|
| 162 |
+
const badgeR = Math.max(15, Math.min(24, rowBandHeight * 0.26));
|
| 163 |
+
const iconR = Math.max(16, Math.min(26, rowBandHeight * 0.29));
|
| 164 |
+
const badgeX = rowBandX + Math.max(24, rowBandHeight * 0.34);
|
| 165 |
+
const iconX = badgeX + badgeR + iconR + Math.max(15, width * 0.026);
|
| 166 |
+
const dividerX = -Math.max(4, width * 0.012);
|
| 167 |
+
const labelX = iconX + iconR + Math.max(10, width * 0.018);
|
| 168 |
+
const labelMaxWidth = Math.max(86, dividerX - labelX - 10);
|
| 169 |
+
|
| 170 |
+
const measureText = (text, fontSize, fontWeight = "800") => {
|
| 171 |
+
return chartUtils.text.measure(g, String(text), { fontSize, fontWeight }).width;
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
const truncateToWidth = (text, maxWidth, fontSize, fontWeight = "800") => {
|
| 175 |
+
const value = String(text);
|
| 176 |
+
if (measureText(value, fontSize, fontWeight) <= maxWidth) return value;
|
| 177 |
+
let trimmed = value;
|
| 178 |
+
while (trimmed.length > 3 && measureText(`${trimmed}...`, fontSize, fontWeight) > maxWidth) {
|
| 179 |
+
trimmed = trimmed.slice(0, -1);
|
| 180 |
+
}
|
| 181 |
+
return `${trimmed}...`;
|
| 182 |
+
};
|
| 183 |
+
|
| 184 |
+
const splitLabelWords = (text) => String(text)
|
| 185 |
+
.replace(/\//g, "/ ")
|
| 186 |
+
.split(/\s+/)
|
| 187 |
+
.filter(Boolean);
|
| 188 |
+
|
| 189 |
+
const wrapToLines = (text, maxWidth, fontSize, fontWeight = "800", maxLines = 2, options = {}) => {
|
| 190 |
+
const words = options.splitSlash ? splitLabelWords(text) : String(text).split(/\s+/).filter(Boolean);
|
| 191 |
+
const lines = [];
|
| 192 |
+
let current = "";
|
| 193 |
+
words.forEach(word => {
|
| 194 |
+
const candidate = current ? `${current} ${word}` : word;
|
| 195 |
+
if (measureText(candidate, fontSize, fontWeight) <= maxWidth || !current) {
|
| 196 |
+
current = candidate;
|
| 197 |
+
} else {
|
| 198 |
+
lines.push(current);
|
| 199 |
+
current = word;
|
| 200 |
+
}
|
| 201 |
+
});
|
| 202 |
+
if (current) lines.push(current);
|
| 203 |
+
if (lines.length <= maxLines) {
|
| 204 |
+
return options.allowOverflowWords
|
| 205 |
+
? lines
|
| 206 |
+
: lines.map(line => truncateToWidth(line, maxWidth, fontSize, fontWeight));
|
| 207 |
+
}
|
| 208 |
+
const clipped = lines.slice(0, maxLines);
|
| 209 |
+
if (!options.allowOverflowWords) {
|
| 210 |
+
clipped[maxLines - 1] = truncateToWidth(clipped[maxLines - 1], maxWidth, fontSize, fontWeight);
|
| 211 |
+
return clipped.map(line => truncateToWidth(line, maxWidth, fontSize, fontWeight));
|
| 212 |
+
}
|
| 213 |
+
return clipped;
|
| 214 |
+
};
|
| 215 |
+
|
| 216 |
+
const wrapGroupLabel = (text) => {
|
| 217 |
+
let fontSize = Math.max(10, Math.min(13, rowBandHeight * 0.15));
|
| 218 |
+
let maxLines = 3;
|
| 219 |
+
let lines = wrapToLines(text, labelMaxWidth, fontSize, "900", maxLines, {
|
| 220 |
+
splitSlash: true,
|
| 221 |
+
allowOverflowWords: true
|
| 222 |
+
});
|
| 223 |
+
while ((lines.length > maxLines || lines.some(line => measureText(line, fontSize, "900") > labelMaxWidth)) && fontSize > 9) {
|
| 224 |
+
fontSize -= 0.5;
|
| 225 |
+
lines = wrapToLines(text, labelMaxWidth, fontSize, "900", maxLines, {
|
| 226 |
+
splitSlash: true,
|
| 227 |
+
allowOverflowWords: true
|
| 228 |
+
});
|
| 229 |
+
}
|
| 230 |
+
if (lines.length > maxLines) {
|
| 231 |
+
maxLines = 4;
|
| 232 |
+
lines = wrapToLines(text, labelMaxWidth, fontSize, "900", maxLines, {
|
| 233 |
+
splitSlash: true,
|
| 234 |
+
allowOverflowWords: true
|
| 235 |
+
});
|
| 236 |
+
}
|
| 237 |
+
return { fontSize, lines: lines.slice(0, maxLines) };
|
| 238 |
+
};
|
| 239 |
+
|
| 240 |
+
const visibleXTicks = (() => {
|
| 241 |
+
if (xValues.length <= 6) return xValues;
|
| 242 |
+
const maxLabels = Math.max(2, Math.floor(innerWidth / Math.max(42, width * 0.08)));
|
| 243 |
+
const step = Math.ceil(xValues.length / maxLabels);
|
| 244 |
+
const ticks = xValues.filter((_, i) => i % step === 0);
|
| 245 |
+
if (ticks[ticks.length - 1] !== xValues[xValues.length - 1]) ticks.push(xValues[xValues.length - 1]);
|
| 246 |
+
return ticks;
|
| 247 |
+
})();
|
| 248 |
+
|
| 249 |
+
labelOrder.forEach((group, rowIndex) => {
|
| 250 |
+
const rowY = yScale(initialRankByGroup[group]);
|
| 251 |
+
const color = getColor(group);
|
| 252 |
+
const safeRowTop = rowY - rowBandHeight / 2;
|
| 253 |
+
|
| 254 |
+
g.append("rect")
|
| 255 |
+
.attr("class", "bump07-row-card")
|
| 256 |
+
.attr("x", rowBandX)
|
| 257 |
+
.attr("y", safeRowTop)
|
| 258 |
+
.attr("width", rowBandW)
|
| 259 |
+
.attr("height", rowBandHeight)
|
| 260 |
+
.attr("rx", Math.min(17, rowBandHeight * 0.20))
|
| 261 |
+
.attr("fill", rowIndex % 2 === 0 ? "#FFFFFF" : "#FBFDFF")
|
| 262 |
+
.attr("stroke", "#E5F0F7")
|
| 263 |
+
.attr("stroke-width", 1)
|
| 264 |
+
.attr("filter", "url(#bump07-row-shadow)");
|
| 265 |
+
|
| 266 |
+
g.append("circle")
|
| 267 |
+
.attr("class", "bump07-rank-badge")
|
| 268 |
+
.attr("cx", badgeX)
|
| 269 |
+
.attr("cy", rowY)
|
| 270 |
+
.attr("r", badgeR)
|
| 271 |
+
.attr("fill", color);
|
| 272 |
+
|
| 273 |
+
g.append("text")
|
| 274 |
+
.attr("class", "bump07-rank-label")
|
| 275 |
+
.attr("x", badgeX)
|
| 276 |
+
.attr("y", rowY + Math.max(5, badgeR * 0.32))
|
| 277 |
+
.attr("text-anchor", "middle")
|
| 278 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 279 |
+
.attr("font-weight", "900")
|
| 280 |
+
.attr("font-size", Math.max(16, Math.min(24, badgeR * 1.22)))
|
| 281 |
+
.attr("fill", "#FFFFFF")
|
| 282 |
+
.text(groups.indexOf(group) + 1);
|
| 283 |
+
|
| 284 |
+
const slot = g.append("g")
|
| 285 |
+
.attr("class", "reserved-asset-slot bump07-group-icon-slot")
|
| 286 |
+
.attr("data-asset-slot", "group-icon")
|
| 287 |
+
.attr("data-asset-source-policy", jsonData.images && jsonData.images.field && jsonData.images.field[group] ? "input_provided" : "neutral_placeholder")
|
| 288 |
+
.attr("data-anchor", `row-${rowIndex + 1}-center`)
|
| 289 |
+
.attr("data-collision-rule", "stay inside row card, right of rank badge, left of group label and plot")
|
| 290 |
+
.attr("transform", `translate(${iconX},${rowY})`);
|
| 291 |
+
slot.append("circle")
|
| 292 |
+
.attr("r", iconR)
|
| 293 |
+
.attr("fill", "#F2FAFF")
|
| 294 |
+
.attr("stroke", color)
|
| 295 |
+
.attr("stroke-width", 2)
|
| 296 |
+
.attr("opacity", 0.95);
|
| 297 |
+
if (jsonData.images && jsonData.images.field && jsonData.images.field[group]) {
|
| 298 |
+
slot.append("image")
|
| 299 |
+
.attr("class", "input-provided-group-icon")
|
| 300 |
+
.attr("data-asset-slot", "group-icon-image")
|
| 301 |
+
.attr("data-asset-source-policy", "input_provided")
|
| 302 |
+
.attr("xlink:href", jsonData.images.field[group])
|
| 303 |
+
.attr("x", -iconR * 0.72)
|
| 304 |
+
.attr("y", -iconR * 0.72)
|
| 305 |
+
.attr("width", iconR * 1.44)
|
| 306 |
+
.attr("height", iconR * 1.44)
|
| 307 |
+
.attr("preserveAspectRatio", "xMidYMid meet");
|
| 308 |
+
} else {
|
| 309 |
+
slot.append("text")
|
| 310 |
+
.attr("text-anchor", "middle")
|
| 311 |
+
.attr("dy", "0.36em")
|
| 312 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 313 |
+
.attr("font-weight", "900")
|
| 314 |
+
.attr("font-size", Math.max(10, iconR * 0.58))
|
| 315 |
+
.attr("fill", color)
|
| 316 |
+
.text(String(group).trim().slice(0, 1).toUpperCase());
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
const labelWrap = wrapGroupLabel(group);
|
| 320 |
+
const labelFontSize = labelWrap.fontSize;
|
| 321 |
+
const labelLines = labelWrap.lines;
|
| 322 |
+
const label = g.append("text")
|
| 323 |
+
.attr("class", "bump07-group-label")
|
| 324 |
+
.attr("x", labelX)
|
| 325 |
+
.attr("y", rowY - ((labelLines.length - 1) * labelFontSize * 0.54))
|
| 326 |
+
.attr("text-anchor", "start")
|
| 327 |
+
.attr("dominant-baseline", "middle")
|
| 328 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 329 |
+
.attr("font-weight", "900")
|
| 330 |
+
.attr("font-size", labelFontSize)
|
| 331 |
+
.attr("fill", color);
|
| 332 |
+
labelLines.forEach((line, lineIndex) => {
|
| 333 |
+
label.append("tspan")
|
| 334 |
+
.attr("x", labelX)
|
| 335 |
+
.attr("dy", lineIndex === 0 ? 0 : labelFontSize * 1.08)
|
| 336 |
+
.text(line);
|
| 337 |
+
});
|
| 338 |
+
|
| 339 |
+
g.append("line")
|
| 340 |
+
.attr("class", "bump07-label-divider")
|
| 341 |
+
.attr("x1", dividerX)
|
| 342 |
+
.attr("x2", dividerX)
|
| 343 |
+
.attr("y1", safeRowTop + 8)
|
| 344 |
+
.attr("y2", safeRowTop + rowBandHeight - 8)
|
| 345 |
+
.attr("stroke", color)
|
| 346 |
+
.attr("stroke-width", 3)
|
| 347 |
+
.attr("stroke-linecap", "round");
|
| 348 |
+
});
|
| 349 |
+
|
| 350 |
+
const axisY = -52;
|
| 351 |
+
visibleXTicks.forEach(tick => {
|
| 352 |
+
const tickX = xScale(tick);
|
| 353 |
+
g.append("line")
|
| 354 |
+
.attr("class", "bump07-vertical-guide")
|
| 355 |
+
.attr("x1", tickX)
|
| 356 |
+
.attr("x2", tickX)
|
| 357 |
+
.attr("y1", axisY + 20)
|
| 358 |
+
.attr("y2", innerHeight + rowBandHeight / 2)
|
| 359 |
+
.attr("stroke", "#BFD5E6")
|
| 360 |
+
.attr("stroke-width", 1.2)
|
| 361 |
+
.attr("stroke-dasharray", "3 5")
|
| 362 |
+
.attr("opacity", 0.95);
|
| 363 |
+
|
| 364 |
+
const label = xFormat(tick);
|
| 365 |
+
const tabW = Math.max(44, Math.min(64, measureText(label, 14, "900") + 22));
|
| 366 |
+
g.append("rect")
|
| 367 |
+
.attr("class", "bump07-time-tab")
|
| 368 |
+
.attr("x", tickX - tabW / 2)
|
| 369 |
+
.attr("y", axisY - 18)
|
| 370 |
+
.attr("width", tabW)
|
| 371 |
+
.attr("height", 32)
|
| 372 |
+
.attr("rx", 7)
|
| 373 |
+
.attr("fill", "#082C55");
|
| 374 |
+
|
| 375 |
+
g.append("text")
|
| 376 |
+
.attr("class", "bump07-time-label")
|
| 377 |
+
.attr("x", tickX)
|
| 378 |
+
.attr("y", axisY + 3)
|
| 379 |
+
.attr("text-anchor", "middle")
|
| 380 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 381 |
+
.attr("font-weight", "900")
|
| 382 |
+
.attr("font-size", Math.max(12, Math.min(16, width * 0.025)))
|
| 383 |
+
.attr("fill", "#FFFFFF")
|
| 384 |
+
.text(label);
|
| 385 |
+
});
|
| 386 |
+
|
| 387 |
+
const values = chartData
|
| 388 |
+
.filter(d => d[yField] !== null && d[yField] !== undefined)
|
| 389 |
+
.map(d => +d[yField]);
|
| 390 |
+
const valueMin = Math.min(...values);
|
| 391 |
+
const valueMax = Math.max(...values);
|
| 392 |
+
const radiusScale = d3.scaleLinear()
|
| 393 |
+
.domain([valueMin, valueMax])
|
| 394 |
+
.range([Math.max(4.5, rowBandHeight * 0.055), Math.max(6.5, rowBandHeight * 0.078)]);
|
| 395 |
+
|
| 396 |
+
const groupValueExtent = {};
|
| 397 |
+
groups.forEach(group => {
|
| 398 |
+
const groupValues = rankData[group]
|
| 399 |
+
.filter(d => d.hasData && Number.isFinite(+d.value))
|
| 400 |
+
.map(d => +d.value);
|
| 401 |
+
groupValueExtent[group] = d3.extent(groupValues);
|
| 402 |
+
});
|
| 403 |
+
const localSlopeAmplitude = Math.max(10, Math.min(rowBandHeight * 0.24, rowStep * 0.20));
|
| 404 |
+
const pointY = (group, d) => {
|
| 405 |
+
const baseY = yScale(d.rank);
|
| 406 |
+
const extent = groupValueExtent[group] || [0, 0];
|
| 407 |
+
const span = extent[1] - extent[0];
|
| 408 |
+
if (!Number.isFinite(span) || span <= 0) return baseY;
|
| 409 |
+
const normalized = (+d.value - extent[0]) / span;
|
| 410 |
+
return baseY + (0.5 - normalized) * localSlopeAmplitude * 2;
|
| 411 |
+
};
|
| 412 |
+
|
| 413 |
+
groups.forEach(group => {
|
| 414 |
+
const groupRanks = rankData[group].filter(d => d.hasData);
|
| 415 |
+
if (groupRanks.length > 1) {
|
| 416 |
+
g.append("path")
|
| 417 |
+
.datum(groupRanks)
|
| 418 |
+
.attr("class", "bump07-rank-line")
|
| 419 |
+
.attr("fill", "none")
|
| 420 |
+
.attr("stroke", getColor(group))
|
| 421 |
+
.attr("stroke-width", Math.max(3, Math.min(4.2, rowBandHeight * 0.05)))
|
| 422 |
+
.attr("stroke-linecap", "round")
|
| 423 |
+
.attr("stroke-linejoin", "round")
|
| 424 |
+
.attr("opacity", 0.96)
|
| 425 |
+
.attr("d", d3.line()
|
| 426 |
+
.x(d => xScale(d.x))
|
| 427 |
+
.y(d => pointY(group, d))
|
| 428 |
+
);
|
| 429 |
+
}
|
| 430 |
+
});
|
| 431 |
+
|
| 432 |
+
groups.forEach(group => {
|
| 433 |
+
rankData[group].forEach(d => {
|
| 434 |
+
if (!d.hasData) return;
|
| 435 |
+
const x = xScale(d.x);
|
| 436 |
+
const y = pointY(group, d);
|
| 437 |
+
const r = radiusScale(+d.value);
|
| 438 |
+
g.append("circle")
|
| 439 |
+
.attr("class", "bump07-value-node")
|
| 440 |
+
.attr("data-group", group)
|
| 441 |
+
.attr("data-x", d.x)
|
| 442 |
+
.attr("cx", x)
|
| 443 |
+
.attr("cy", y)
|
| 444 |
+
.attr("r", r)
|
| 445 |
+
.attr("fill", getColor(group))
|
| 446 |
+
.attr("stroke", "#FFFFFF")
|
| 447 |
+
.attr("stroke-width", 2);
|
| 448 |
+
});
|
| 449 |
+
});
|
| 450 |
+
|
| 451 |
+
groups.forEach(group => {
|
| 452 |
+
rankData[group].forEach(d => {
|
| 453 |
+
if (!d.hasData) return;
|
| 454 |
+
const x = xScale(d.x);
|
| 455 |
+
const y = pointY(group, d);
|
| 456 |
+
const rowCenterY = yScale(d.rank);
|
| 457 |
+
const r = radiusScale(+d.value);
|
| 458 |
+
const text = chartUtils.format.autoText(+d.value);
|
| 459 |
+
const labelFont = Math.max(10, Math.min(14, rowBandHeight * 0.16));
|
| 460 |
+
const chipW = Math.max(28, measureText(text, labelFont, "900") + 12);
|
| 461 |
+
const chipH = Math.max(20, labelFont + 8);
|
| 462 |
+
let labelY = y - r - chipH / 2 - Math.max(6, rowBandHeight * 0.045);
|
| 463 |
+
const rowTop = rowCenterY - rowBandHeight / 2 + chipH / 2 + 5;
|
| 464 |
+
const rowBottom = rowCenterY + rowBandHeight / 2 - chipH / 2 - 5;
|
| 465 |
+
labelY = Math.max(rowTop, Math.min(rowBottom, labelY));
|
| 466 |
+
let labelX = Math.max(chipW / 2 + 2, Math.min(innerWidth - chipW / 2 - 2, x));
|
| 467 |
+
|
| 468 |
+
g.append("rect")
|
| 469 |
+
.attr("class", "bump07-value-chip")
|
| 470 |
+
.attr("x", labelX - chipW / 2)
|
| 471 |
+
.attr("y", labelY - chipH / 2)
|
| 472 |
+
.attr("width", chipW)
|
| 473 |
+
.attr("height", chipH)
|
| 474 |
+
.attr("rx", 5)
|
| 475 |
+
.attr("fill", getColor(group));
|
| 476 |
+
|
| 477 |
+
g.append("text")
|
| 478 |
+
.attr("class", "bump07-value-label")
|
| 479 |
+
.attr("x", labelX)
|
| 480 |
+
.attr("y", labelY + labelFont * 0.34)
|
| 481 |
+
.attr("text-anchor", "middle")
|
| 482 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 483 |
+
.attr("font-weight", "900")
|
| 484 |
+
.attr("font-size", labelFont)
|
| 485 |
+
.attr("fill", "#FFFFFF")
|
| 486 |
+
.text(text);
|
| 487 |
+
});
|
| 488 |
+
});
|
| 489 |
+
|
| 490 |
+
return svg.node();
|
| 491 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/bump_chart_09.js
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Sorted Lines Chart",
|
| 5 |
+
"chart_name": "bump_chart_09",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"chart_for": "comparison",
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [
|
| 11 |
+
[2, 12],
|
| 12 |
+
[0, "inf"],
|
| 13 |
+
[4, 10]
|
| 14 |
+
],
|
| 15 |
+
"required_fields_icons": ["group"],
|
| 16 |
+
"required_other_icons": [],
|
| 17 |
+
"required_fields_colors": ["group"],
|
| 18 |
+
"required_other_colors": [],
|
| 19 |
+
"supported_effects": ["spacing"],
|
| 20 |
+
"min_height": 400,
|
| 21 |
+
"min_width": 400,
|
| 22 |
+
"background": "no",
|
| 23 |
+
"icon_mark": "none",
|
| 24 |
+
"icon_label": "side",
|
| 25 |
+
"has_x_axis": "no",
|
| 26 |
+
"has_y_axis": "no"
|
| 27 |
+
}
|
| 28 |
+
REQUIREMENTS_END
|
| 29 |
+
*/
|
| 30 |
+
|
| 31 |
+
function makeChart(containerSelector, data) {
|
| 32 |
+
const jsonData = data;
|
| 33 |
+
const chartData = jsonData.data.data;
|
| 34 |
+
const variables = jsonData.variables || {};
|
| 35 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 36 |
+
const images = jsonData.images || { field: {}, other: {} };
|
| 37 |
+
|
| 38 |
+
d3.select(containerSelector).html("");
|
| 39 |
+
|
| 40 |
+
const width = +variables.width || 800;
|
| 41 |
+
const height = +variables.height || 600;
|
| 42 |
+
const timeField = chartUtils.schema.channel(jsonData, "x").key;
|
| 43 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key;
|
| 44 |
+
const groupField = chartUtils.schema.channel(jsonData, "group").key;
|
| 45 |
+
|
| 46 |
+
const timePoints = [...new Set(chartData.map(d => d[timeField]))]
|
| 47 |
+
.sort((a, b) => {
|
| 48 |
+
const da = chartUtils.format.parseDate(a);
|
| 49 |
+
const db = chartUtils.format.parseDate(b);
|
| 50 |
+
const ta = da instanceof Date ? da.getTime() : +da;
|
| 51 |
+
const tb = db instanceof Date ? db.getTime() : +db;
|
| 52 |
+
if (Number.isFinite(ta) && Number.isFinite(tb) && ta !== tb) return ta - tb;
|
| 53 |
+
return String(a).localeCompare(String(b), undefined, { numeric: true });
|
| 54 |
+
});
|
| 55 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 56 |
+
const valueUnit = chartUtils.schema.channel(jsonData, "y").unit !== "none"
|
| 57 |
+
? chartUtils.schema.channel(jsonData, "y").unit
|
| 58 |
+
: "";
|
| 59 |
+
|
| 60 |
+
const svg = d3.select(containerSelector)
|
| 61 |
+
.append("svg")
|
| 62 |
+
.attr("width", "100%")
|
| 63 |
+
.attr("height", height)
|
| 64 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 65 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 66 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 67 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 68 |
+
|
| 69 |
+
const defs = svg.append("defs");
|
| 70 |
+
defs.append("filter")
|
| 71 |
+
.attr("id", "bump09-row-shadow")
|
| 72 |
+
.attr("x", "-8%")
|
| 73 |
+
.attr("y", "-20%")
|
| 74 |
+
.attr("width", "116%")
|
| 75 |
+
.attr("height", "150%")
|
| 76 |
+
.append("feDropShadow")
|
| 77 |
+
.attr("dx", 0)
|
| 78 |
+
.attr("dy", 4)
|
| 79 |
+
.attr("stdDeviation", 6)
|
| 80 |
+
.attr("flood-color", "#0A3152")
|
| 81 |
+
.attr("flood-opacity", 0.11);
|
| 82 |
+
|
| 83 |
+
const headerGradient = defs.append("linearGradient")
|
| 84 |
+
.attr("id", "bump09-header-gradient")
|
| 85 |
+
.attr("x1", "0%")
|
| 86 |
+
.attr("x2", "100%")
|
| 87 |
+
.attr("y1", "0%")
|
| 88 |
+
.attr("y2", "0%");
|
| 89 |
+
headerGradient.append("stop").attr("offset", "0%").attr("stop-color", "#073D78");
|
| 90 |
+
headerGradient.append("stop").attr("offset", "100%").attr("stop-color", "#082A61");
|
| 91 |
+
|
| 92 |
+
const markerGradientIds = {};
|
| 93 |
+
groups.forEach((group, index) => {
|
| 94 |
+
const color = getColor(group, index);
|
| 95 |
+
const c = d3.color(color) || d3.color("#6B45D8");
|
| 96 |
+
const gradient = defs.append("radialGradient")
|
| 97 |
+
.attr("id", `bump09-dot-gradient-${index}`)
|
| 98 |
+
.attr("cx", "34%")
|
| 99 |
+
.attr("cy", "28%")
|
| 100 |
+
.attr("r", "78%");
|
| 101 |
+
gradient.append("stop").attr("offset", "0%").attr("stop-color", c.brighter(0.9).formatHex());
|
| 102 |
+
gradient.append("stop").attr("offset", "62%").attr("stop-color", c.formatHex());
|
| 103 |
+
gradient.append("stop").attr("offset", "100%").attr("stop-color", c.darker(0.35).formatHex());
|
| 104 |
+
markerGradientIds[group] = `bump09-dot-gradient-${index}`;
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
const measureText = (text, fontSize, fontWeight = "700") => {
|
| 108 |
+
return chartUtils.text.measure(svg, String(text), {
|
| 109 |
+
fontFamily: "Arial, Helvetica, sans-serif",
|
| 110 |
+
fontSize,
|
| 111 |
+
fontWeight
|
| 112 |
+
}).width;
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const fitText = (text, maxWidth, fontSize, fontWeight = "800", minFontSize = 8) => {
|
| 116 |
+
let size = fontSize;
|
| 117 |
+
while (size > minFontSize && measureText(text, size, fontWeight) > maxWidth) {
|
| 118 |
+
size -= 1;
|
| 119 |
+
}
|
| 120 |
+
return size;
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
const ellipsize = (text, maxWidth, fontSize, fontWeight = "800") => {
|
| 124 |
+
const value = String(text);
|
| 125 |
+
if (measureText(value, fontSize, fontWeight) <= maxWidth) return value;
|
| 126 |
+
let clipped = value;
|
| 127 |
+
while (clipped.length > 2 && measureText(`${clipped}...`, fontSize, fontWeight) > maxWidth) {
|
| 128 |
+
clipped = clipped.slice(0, -1);
|
| 129 |
+
}
|
| 130 |
+
return `${clipped}...`;
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
const contrastText = (color) => {
|
| 134 |
+
const c = d3.color(color);
|
| 135 |
+
if (!c) return "#FFFFFF";
|
| 136 |
+
const luminance = (0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b) / 255;
|
| 137 |
+
return luminance > 0.62 ? "#082A61" : "#FFFFFF";
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
function getColor(group, index = 0) {
|
| 141 |
+
const scale = colorResolver.scale(groups, { palette: "category10" });
|
| 142 |
+
return scale(group) || colorResolver.field(group, index, { fallback: "#6B45D8" }).value || "#6B45D8";
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
const rowOuterX = Math.max(6, width * 0.015);
|
| 146 |
+
const rowOuterW = width - rowOuterX * 2;
|
| 147 |
+
const headerTop = Math.max(8, height * 0.02);
|
| 148 |
+
const headerH = Math.max(30, Math.min(42, height * 0.09));
|
| 149 |
+
const headerGap = Math.max(8, Math.min(13, height * 0.025));
|
| 150 |
+
let rowGap = Math.max(6, Math.min(10, height * 0.018));
|
| 151 |
+
const hasUnitNote = Boolean(valueUnit) && height >= 330;
|
| 152 |
+
let unitH = hasUnitNote ? Math.max(26, Math.min(34, height * 0.075)) : 0;
|
| 153 |
+
const bottomPad = Math.max(7, height * 0.018);
|
| 154 |
+
let rowH = (height - headerTop - headerH - headerGap - bottomPad - unitH - rowGap * Math.max(0, groups.length - 1)) / Math.max(1, groups.length);
|
| 155 |
+
if (rowH < 36 && hasUnitNote) {
|
| 156 |
+
unitH = 0;
|
| 157 |
+
rowGap = Math.max(4, rowGap - 2);
|
| 158 |
+
rowH = (height - headerTop - headerH - headerGap - bottomPad - rowGap * Math.max(0, groups.length - 1)) / Math.max(1, groups.length);
|
| 159 |
+
}
|
| 160 |
+
rowH = Math.max(30, Math.min(68, rowH));
|
| 161 |
+
|
| 162 |
+
const maxLabelMeasure = d3.max(groups, group => measureText(group, 14, "900")) || 80;
|
| 163 |
+
const labelColumnW = Math.max(
|
| 164 |
+
Math.min(width * 0.38, maxLabelMeasure + 64),
|
| 165 |
+
Math.min(width * 0.31, 122)
|
| 166 |
+
);
|
| 167 |
+
const plotX0 = rowOuterX + labelColumnW;
|
| 168 |
+
const plotX1 = width - Math.max(22, width * 0.045);
|
| 169 |
+
const xScale = d3.scalePoint()
|
| 170 |
+
.domain(timePoints)
|
| 171 |
+
.range([plotX0, plotX1])
|
| 172 |
+
.padding(0.08);
|
| 173 |
+
|
| 174 |
+
svg.append("g")
|
| 175 |
+
.attr("class", "reserved-asset-slot bump09-header-illustration-policy")
|
| 176 |
+
.attr("data-asset-slot", "header-illustration")
|
| 177 |
+
.attr("data-asset-source-policy", "input_asset_or_image2_asset")
|
| 178 |
+
.attr("data-anchor", "page-header-image-bbox")
|
| 179 |
+
.attr("data-collision-rule", "owned by wrapper; must avoid title/subtitle and chart bbox")
|
| 180 |
+
.attr("data-no-copied-reference-art", "true")
|
| 181 |
+
.attr("opacity", 0);
|
| 182 |
+
|
| 183 |
+
svg.append("rect")
|
| 184 |
+
.attr("class", "bump09-header-band")
|
| 185 |
+
.attr("x", rowOuterX)
|
| 186 |
+
.attr("y", headerTop)
|
| 187 |
+
.attr("width", rowOuterW)
|
| 188 |
+
.attr("height", headerH)
|
| 189 |
+
.attr("rx", Math.min(14, headerH * 0.35))
|
| 190 |
+
.attr("fill", "url(#bump09-header-gradient)");
|
| 191 |
+
|
| 192 |
+
svg.append("text")
|
| 193 |
+
.attr("class", "bump09-country-header")
|
| 194 |
+
.attr("x", rowOuterX + 18)
|
| 195 |
+
.attr("y", headerTop + headerH / 2)
|
| 196 |
+
.attr("dominant-baseline", "middle")
|
| 197 |
+
.attr("text-anchor", "start")
|
| 198 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 199 |
+
.attr("font-size", Math.max(11, Math.min(16, headerH * 0.34)))
|
| 200 |
+
.attr("font-weight", "900")
|
| 201 |
+
.attr("fill", "#FFFFFF")
|
| 202 |
+
.text(String(groupField).toUpperCase());
|
| 203 |
+
|
| 204 |
+
timePoints.forEach(timePoint => {
|
| 205 |
+
const label = String(timePoint);
|
| 206 |
+
const labelSize = fitText(label, Math.max(24, (plotX1 - plotX0) / Math.max(1, timePoints.length) - 6), Math.max(11, Math.min(16, headerH * 0.36)), "900", 8);
|
| 207 |
+
svg.append("text")
|
| 208 |
+
.attr("class", "bump09-year-header")
|
| 209 |
+
.attr("x", xScale(timePoint))
|
| 210 |
+
.attr("y", headerTop + headerH / 2)
|
| 211 |
+
.attr("dominant-baseline", "middle")
|
| 212 |
+
.attr("text-anchor", "middle")
|
| 213 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 214 |
+
.attr("font-size", labelSize)
|
| 215 |
+
.attr("font-weight", "900")
|
| 216 |
+
.attr("fill", "#FFFFFF")
|
| 217 |
+
.text(label);
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
const values = chartData
|
| 221 |
+
.map(d => +d[valueField])
|
| 222 |
+
.filter(v => Number.isFinite(v));
|
| 223 |
+
const minValue = d3.min(values) || 0;
|
| 224 |
+
const maxValue = d3.max(values) || 1;
|
| 225 |
+
const minColumnGap = timePoints.length > 1
|
| 226 |
+
? d3.min(timePoints.slice(1).map((d, i) => xScale(d) - xScale(timePoints[i])))
|
| 227 |
+
: plotX1 - plotX0;
|
| 228 |
+
const maxRadius = Math.max(7, Math.min(rowH * 0.32, minColumnGap * 0.29, 24));
|
| 229 |
+
const minRadius = Math.max(5, Math.min(maxRadius * 0.62, rowH * 0.20));
|
| 230 |
+
const radiusScale = d3.scaleSqrt()
|
| 231 |
+
.domain([minValue, maxValue])
|
| 232 |
+
.range([minRadius, maxRadius])
|
| 233 |
+
.clamp(true);
|
| 234 |
+
const rowTop0 = headerTop + headerH + headerGap;
|
| 235 |
+
|
| 236 |
+
groups.forEach((group, index) => {
|
| 237 |
+
const color = getColor(group, index);
|
| 238 |
+
const rowY = rowTop0 + index * (rowH + rowGap);
|
| 239 |
+
const rowCenterY = rowY + rowH / 2;
|
| 240 |
+
|
| 241 |
+
svg.append("rect")
|
| 242 |
+
.attr("class", "bump09-row-card")
|
| 243 |
+
.attr("x", rowOuterX)
|
| 244 |
+
.attr("y", rowY)
|
| 245 |
+
.attr("width", rowOuterW)
|
| 246 |
+
.attr("height", rowH)
|
| 247 |
+
.attr("rx", Math.min(14, rowH * 0.18))
|
| 248 |
+
.attr("fill", index % 2 === 0 ? "#FFFFFF" : "#FBFDFF")
|
| 249 |
+
.attr("filter", "url(#bump09-row-shadow)");
|
| 250 |
+
|
| 251 |
+
svg.append("rect")
|
| 252 |
+
.attr("class", "bump09-row-rail")
|
| 253 |
+
.attr("x", rowOuterX)
|
| 254 |
+
.attr("y", rowY + 2)
|
| 255 |
+
.attr("width", Math.max(5, Math.min(10, width * 0.016)))
|
| 256 |
+
.attr("height", Math.max(1, rowH - 4))
|
| 257 |
+
.attr("rx", Math.min(8, rowH * 0.14))
|
| 258 |
+
.attr("fill", color);
|
| 259 |
+
|
| 260 |
+
const iconR = Math.max(9, Math.min(17, rowH * 0.24));
|
| 261 |
+
const iconX = rowOuterX + Math.max(24, iconR + 17);
|
| 262 |
+
const labelX = iconX + iconR + Math.max(8, width * 0.016);
|
| 263 |
+
const labelMaxW = Math.max(42, plotX0 - labelX - 14);
|
| 264 |
+
const slot = svg.append("g")
|
| 265 |
+
.attr("class", "reserved-asset-slot bump09-group-icon-slot")
|
| 266 |
+
.attr("data-asset-slot", "group-icon")
|
| 267 |
+
.attr("data-asset-source-policy", images.field && images.field[group] ? "input_provided" : "neutral_placeholder")
|
| 268 |
+
.attr("data-anchor", `row-${index + 1}-centerline`)
|
| 269 |
+
.attr("data-collision-rule", "stay inside row card and left of group label/year columns")
|
| 270 |
+
.attr("transform", `translate(${iconX},${rowCenterY})`);
|
| 271 |
+
slot.append("circle")
|
| 272 |
+
.attr("r", iconR)
|
| 273 |
+
.attr("fill", "#F2F7FD")
|
| 274 |
+
.attr("stroke", "#FFFFFF")
|
| 275 |
+
.attr("stroke-width", 2.2)
|
| 276 |
+
.attr("filter", "url(#bump09-row-shadow)");
|
| 277 |
+
if (images.field && images.field[group]) {
|
| 278 |
+
slot.append("image")
|
| 279 |
+
.attr("class", "input-provided-group-icon")
|
| 280 |
+
.attr("data-asset-slot", "group-icon-image")
|
| 281 |
+
.attr("data-asset-source-policy", "input_provided")
|
| 282 |
+
.attr("xlink:href", images.field[group])
|
| 283 |
+
.attr("x", -iconR * 0.82)
|
| 284 |
+
.attr("y", -iconR * 0.82)
|
| 285 |
+
.attr("width", iconR * 1.64)
|
| 286 |
+
.attr("height", iconR * 1.64)
|
| 287 |
+
.attr("preserveAspectRatio", "xMidYMid meet");
|
| 288 |
+
} else {
|
| 289 |
+
slot.append("text")
|
| 290 |
+
.attr("text-anchor", "middle")
|
| 291 |
+
.attr("dominant-baseline", "middle")
|
| 292 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 293 |
+
.attr("font-weight", "900")
|
| 294 |
+
.attr("font-size", Math.max(9, iconR * 0.72))
|
| 295 |
+
.attr("fill", color)
|
| 296 |
+
.text(String(group).trim().slice(0, 1).toUpperCase());
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
const baseLabelFont = Math.max(10, Math.min(17, rowH * 0.25));
|
| 300 |
+
const labelFont = fitText(group, labelMaxW, baseLabelFont, "900", 8);
|
| 301 |
+
const labelText = labelFont > 8.5 ? String(group) : ellipsize(group, labelMaxW, labelFont, "900");
|
| 302 |
+
svg.append("text")
|
| 303 |
+
.attr("class", "bump09-group-label")
|
| 304 |
+
.attr("x", labelX)
|
| 305 |
+
.attr("y", rowCenterY)
|
| 306 |
+
.attr("dominant-baseline", "middle")
|
| 307 |
+
.attr("text-anchor", "start")
|
| 308 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 309 |
+
.attr("font-size", labelFont)
|
| 310 |
+
.attr("font-weight", "900")
|
| 311 |
+
.attr("fill", "#082A61")
|
| 312 |
+
.text(labelText);
|
| 313 |
+
|
| 314 |
+
const groupData = chartData
|
| 315 |
+
.filter(d => d[groupField] === group)
|
| 316 |
+
.sort((a, b) => timePoints.indexOf(a[timeField]) - timePoints.indexOf(b[timeField]));
|
| 317 |
+
const points = groupData
|
| 318 |
+
.filter(d => timePoints.includes(d[timeField]) && Number.isFinite(+d[valueField]))
|
| 319 |
+
.map(d => ({
|
| 320 |
+
x: xScale(d[timeField]),
|
| 321 |
+
y: rowCenterY,
|
| 322 |
+
value: +d[valueField],
|
| 323 |
+
time: d[timeField]
|
| 324 |
+
}));
|
| 325 |
+
|
| 326 |
+
if (points.length >= 2) {
|
| 327 |
+
svg.append("path")
|
| 328 |
+
.datum(points)
|
| 329 |
+
.attr("class", "bump09-connector-line")
|
| 330 |
+
.attr("fill", "none")
|
| 331 |
+
.attr("stroke", color)
|
| 332 |
+
.attr("stroke-width", Math.max(2, Math.min(3.2, rowH * 0.045)))
|
| 333 |
+
.attr("stroke-linecap", "round")
|
| 334 |
+
.attr("stroke-linejoin", "round")
|
| 335 |
+
.attr("opacity", 0.96)
|
| 336 |
+
.attr("d", d3.line().x(d => d.x).y(d => d.y));
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
points.forEach(point => {
|
| 340 |
+
const r = radiusScale(point.value);
|
| 341 |
+
svg.append("circle")
|
| 342 |
+
.attr("class", "bump09-value-bubble")
|
| 343 |
+
.attr("data-group", group)
|
| 344 |
+
.attr("data-x", point.time)
|
| 345 |
+
.attr("data-value", point.value)
|
| 346 |
+
.attr("cx", point.x)
|
| 347 |
+
.attr("cy", point.y)
|
| 348 |
+
.attr("r", r)
|
| 349 |
+
.attr("fill", `url(#${markerGradientIds[group]})`)
|
| 350 |
+
.attr("stroke", "rgba(255,255,255,0.82)")
|
| 351 |
+
.attr("stroke-width", Math.max(1.2, r * 0.10));
|
| 352 |
+
|
| 353 |
+
const formattedValue = chartUtils.format.compactFixedText(point.value);
|
| 354 |
+
const valueFont = fitText(formattedValue, r * 1.55, Math.max(9, Math.min(18, r * 0.72)), "900", 7);
|
| 355 |
+
svg.append("text")
|
| 356 |
+
.attr("class", "bump09-value-label")
|
| 357 |
+
.attr("x", point.x)
|
| 358 |
+
.attr("y", point.y)
|
| 359 |
+
.attr("dominant-baseline", "middle")
|
| 360 |
+
.attr("text-anchor", "middle")
|
| 361 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 362 |
+
.attr("font-size", valueFont)
|
| 363 |
+
.attr("font-weight", "900")
|
| 364 |
+
.attr("fill", contrastText(color))
|
| 365 |
+
.text(formattedValue);
|
| 366 |
+
});
|
| 367 |
+
});
|
| 368 |
+
|
| 369 |
+
if (unitH > 0) {
|
| 370 |
+
const unitLabel = valueUnit === "$m" ? "million USD" : valueUnit;
|
| 371 |
+
const unitText = `All values are in ${unitLabel}.`;
|
| 372 |
+
const maxPillW = Math.min(width * 0.64, width - rowOuterX * 2);
|
| 373 |
+
const pillW = Math.min(maxPillW, Math.max(150, measureText(unitText, 11, "600") + 58));
|
| 374 |
+
const pillX = (width - pillW) / 2;
|
| 375 |
+
const pillY = Math.min(height - unitH - bottomPad / 2, rowTop0 + groups.length * rowH + Math.max(5, rowGap * groups.length));
|
| 376 |
+
const note = svg.append("g")
|
| 377 |
+
.attr("class", "bump09-unit-note")
|
| 378 |
+
.attr("data-chart-owned-region", "unit-note")
|
| 379 |
+
.attr("data-anchor", "bottom-center-below-row-cards")
|
| 380 |
+
.attr("data-collision-rule", "hide or move if it intersects bottom row card");
|
| 381 |
+
note.append("rect")
|
| 382 |
+
.attr("x", pillX)
|
| 383 |
+
.attr("y", pillY)
|
| 384 |
+
.attr("width", pillW)
|
| 385 |
+
.attr("height", unitH)
|
| 386 |
+
.attr("rx", unitH / 2)
|
| 387 |
+
.attr("fill", "#E7F2FB")
|
| 388 |
+
.attr("stroke", "#C9DFF2")
|
| 389 |
+
.attr("stroke-width", 1);
|
| 390 |
+
const iconX = pillX + unitH * 0.55;
|
| 391 |
+
const barBaseY = pillY + unitH * 0.66;
|
| 392 |
+
[0.34, 0.52, 0.76].forEach((scale, i) => {
|
| 393 |
+
note.append("rect")
|
| 394 |
+
.attr("x", iconX + i * 5)
|
| 395 |
+
.attr("y", barBaseY - unitH * scale)
|
| 396 |
+
.attr("width", 3.4)
|
| 397 |
+
.attr("height", unitH * scale)
|
| 398 |
+
.attr("rx", 1)
|
| 399 |
+
.attr("fill", i === 2 ? "#08336F" : "#4B82BC");
|
| 400 |
+
});
|
| 401 |
+
const unitFont = fitText(unitText, pillW - unitH * 1.62, Math.max(9, Math.min(12, unitH * 0.36)), "600", 7);
|
| 402 |
+
note.append("text")
|
| 403 |
+
.attr("x", pillX + unitH * 1.35)
|
| 404 |
+
.attr("y", pillY + unitH / 2)
|
| 405 |
+
.attr("dominant-baseline", "middle")
|
| 406 |
+
.attr("text-anchor", "start")
|
| 407 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 408 |
+
.attr("font-size", unitFont)
|
| 409 |
+
.attr("font-weight", "600")
|
| 410 |
+
.attr("fill", "#082A61")
|
| 411 |
+
.text(unitText);
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
return svg.node();
|
| 415 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/bump_chart_10.js
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Sorted Lines Chart",
|
| 5 |
+
"chart_name": "bump_chart_10",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"chart_for": "comparison",
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [
|
| 11 |
+
[2, 12],
|
| 12 |
+
[0, "inf"],
|
| 13 |
+
[4, 10]
|
| 14 |
+
],
|
| 15 |
+
"required_fields_icons": ["group"],
|
| 16 |
+
"required_other_icons": [],
|
| 17 |
+
"required_fields_colors": ["group"],
|
| 18 |
+
"required_other_colors": [],
|
| 19 |
+
"supported_effects": ["spacing"],
|
| 20 |
+
"min_height": 400,
|
| 21 |
+
"min_width": 400,
|
| 22 |
+
"background": "no",
|
| 23 |
+
"icon_mark": "none",
|
| 24 |
+
"icon_label": "side",
|
| 25 |
+
"has_x_axis": "no",
|
| 26 |
+
"has_y_axis": "no"
|
| 27 |
+
}
|
| 28 |
+
REQUIREMENTS_END
|
| 29 |
+
*/
|
| 30 |
+
|
| 31 |
+
function makeChart(containerSelector, data) {
|
| 32 |
+
const jsonData = data;
|
| 33 |
+
const chartData = (jsonData.data && jsonData.data.data) || [];
|
| 34 |
+
const variables = jsonData.variables || {};
|
| 35 |
+
const typography = jsonData.typography || {};
|
| 36 |
+
const images = jsonData.images || { field: {}, other: {} };
|
| 37 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 38 |
+
|
| 39 |
+
d3.select(containerSelector).html("");
|
| 40 |
+
|
| 41 |
+
const width = variables.width || 650;
|
| 42 |
+
const height = variables.height || 975;
|
| 43 |
+
const timeField = chartUtils.schema.channel(jsonData, "x").key;
|
| 44 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key;
|
| 45 |
+
const groupField = chartUtils.schema.channel(jsonData, "group").key;
|
| 46 |
+
|
| 47 |
+
function safeText(value) {
|
| 48 |
+
return value == null ? "" : String(value);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function measureText(text, fontSize, fontFamily, fontWeight) {
|
| 52 |
+
return chartUtils.text.measure(null, safeText(text), {
|
| 53 |
+
fontFamily: fontFamily,
|
| 54 |
+
fontSize: fontSize,
|
| 55 |
+
fontWeight: fontWeight
|
| 56 |
+
}).width;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function fitFont(text, maxWidth, start, family, weight, minSize) {
|
| 60 |
+
let size = start;
|
| 61 |
+
while (size > minSize && measureText(text, size, family, weight) > maxWidth) {
|
| 62 |
+
size -= 1;
|
| 63 |
+
}
|
| 64 |
+
return size;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function wrapText(selection, text, maxWidth, maxLines, lineHeight, fontSize, family, weight) {
|
| 68 |
+
const lines = computeWrappedLines(text, maxWidth, fontSize, family, weight);
|
| 69 |
+
const visible = lines.slice(0, maxLines);
|
| 70 |
+
if (lines.length > maxLines && visible.length) {
|
| 71 |
+
let last = visible[visible.length - 1];
|
| 72 |
+
while (last.length > 1 && measureText(`${last}...`, fontSize, family, weight) > maxWidth) {
|
| 73 |
+
last = last.slice(0, -1);
|
| 74 |
+
}
|
| 75 |
+
visible[visible.length - 1] = `${last}...`;
|
| 76 |
+
}
|
| 77 |
+
selection.selectAll("tspan")
|
| 78 |
+
.data(visible)
|
| 79 |
+
.enter()
|
| 80 |
+
.append("tspan")
|
| 81 |
+
.attr("x", selection.attr("x"))
|
| 82 |
+
.attr("dy", (d, i) => i === 0 ? 0 : lineHeight)
|
| 83 |
+
.text(d => d);
|
| 84 |
+
return visible.length;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function computeWrappedLines(text, maxWidth, fontSize, family, weight) {
|
| 88 |
+
const words = safeText(text).split(/\s+/).filter(Boolean);
|
| 89 |
+
const lines = [];
|
| 90 |
+
let line = "";
|
| 91 |
+
words.forEach(word => {
|
| 92 |
+
const candidate = line ? `${line} ${word}` : word;
|
| 93 |
+
if (measureText(candidate, fontSize, family, weight) <= maxWidth || !line) {
|
| 94 |
+
line = candidate;
|
| 95 |
+
} else {
|
| 96 |
+
lines.push(line);
|
| 97 |
+
line = word;
|
| 98 |
+
}
|
| 99 |
+
});
|
| 100 |
+
if (line) lines.push(line);
|
| 101 |
+
return lines;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function parseTimeValue(value) {
|
| 105 |
+
const parsed = chartUtils.format.parseDate(value);
|
| 106 |
+
if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed.getTime();
|
| 107 |
+
const numeric = +value;
|
| 108 |
+
return Number.isFinite(numeric) ? numeric : safeText(value);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const timePoints = [...new Set(chartData.map(d => d[timeField]))]
|
| 112 |
+
.sort((a, b) => {
|
| 113 |
+
const pa = parseTimeValue(a);
|
| 114 |
+
const pb = parseTimeValue(b);
|
| 115 |
+
if (typeof pa === "number" && typeof pb === "number") return pa - pb;
|
| 116 |
+
return safeText(a).localeCompare(safeText(b));
|
| 117 |
+
});
|
| 118 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 119 |
+
const grouped = new Map(groups.map(group => [
|
| 120 |
+
group,
|
| 121 |
+
chartData.filter(d => d[groupField] === group)
|
| 122 |
+
.sort((a, b) => timePoints.indexOf(a[timeField]) - timePoints.indexOf(b[timeField]))
|
| 123 |
+
]));
|
| 124 |
+
|
| 125 |
+
const titleFamily = (typography.title && typography.title.font_family) || "Arial, Helvetica, sans-serif";
|
| 126 |
+
const labelFamily = (typography.label && typography.label.font_family) || "Arial, Helvetica, sans-serif";
|
| 127 |
+
const annotationFamily = (typography.annotation && typography.annotation.font_family) || "Arial, Helvetica, sans-serif";
|
| 128 |
+
let titleText = safeText((jsonData.titles && jsonData.titles.main_title) || (jsonData.metadata && jsonData.metadata.title) || "Positive Trends");
|
| 129 |
+
titleText = titleText
|
| 130 |
+
.replace(/^Positive\s+Trends?\s+in\s+/i, "")
|
| 131 |
+
.replace(/^Positive\s+Trends?\s+Across\s+/i, "")
|
| 132 |
+
.trim() || titleText;
|
| 133 |
+
const subtitleText = safeText((jsonData.titles && jsonData.titles.sub_title) || (jsonData.metadata && jsonData.metadata.description) || jsonData.description || "");
|
| 134 |
+
|
| 135 |
+
const rowRamps = [
|
| 136 |
+
["#f7d9e6", "#f06fa2", "#e94a72", "#842842"],
|
| 137 |
+
["#98e1e8", "#44c4e3", "#247ed1", "#06477c"],
|
| 138 |
+
["#ffc39e", "#ff7246", "#e64a2b", "#7d2319"],
|
| 139 |
+
["#8adfe0", "#35bad5", "#207eb9", "#054a78"],
|
| 140 |
+
["#ffe39a", "#f7c232", "#c39b20", "#8a6a16"],
|
| 141 |
+
["#c9d7ff", "#7da0ff", "#4b68d9", "#24317e"],
|
| 142 |
+
["#e5c8ff", "#bb7ef0", "#8d54cf", "#4e2d80"],
|
| 143 |
+
["#b7ead0", "#67c88f", "#31935b", "#176238"]
|
| 144 |
+
];
|
| 145 |
+
|
| 146 |
+
const svg = d3.select(containerSelector)
|
| 147 |
+
.append("svg")
|
| 148 |
+
.attr("width", "100%")
|
| 149 |
+
.attr("height", height)
|
| 150 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 151 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 152 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 153 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 154 |
+
|
| 155 |
+
const defs = svg.append("defs");
|
| 156 |
+
defs.append("filter")
|
| 157 |
+
.attr("id", "bump10-soft-shadow")
|
| 158 |
+
.attr("x", "-12%")
|
| 159 |
+
.attr("y", "-12%")
|
| 160 |
+
.attr("width", "124%")
|
| 161 |
+
.attr("height", "130%")
|
| 162 |
+
.html('<feDropShadow dx="0" dy="4" stdDeviation="5" flood-color="#1b3327" flood-opacity="0.12"/>');
|
| 163 |
+
|
| 164 |
+
const bg = defs.append("radialGradient")
|
| 165 |
+
.attr("id", "bump10-bg")
|
| 166 |
+
.attr("cx", "72%")
|
| 167 |
+
.attr("cy", "8%")
|
| 168 |
+
.attr("r", "86%");
|
| 169 |
+
bg.append("stop").attr("offset", "0%").attr("stop-color", "#fff8e4");
|
| 170 |
+
bg.append("stop").attr("offset", "54%").attr("stop-color", "#fbfaf6");
|
| 171 |
+
bg.append("stop").attr("offset", "100%").attr("stop-color", "#f6fbfd");
|
| 172 |
+
|
| 173 |
+
svg.append("rect")
|
| 174 |
+
.attr("width", width)
|
| 175 |
+
.attr("height", height)
|
| 176 |
+
.attr("fill", "url(#bump10-bg)");
|
| 177 |
+
|
| 178 |
+
const badge = svg.append("g")
|
| 179 |
+
.attr("class", "bump10-positive-badge")
|
| 180 |
+
.attr("transform", "translate(38,42)");
|
| 181 |
+
badge.append("rect")
|
| 182 |
+
.attr("width", 250)
|
| 183 |
+
.attr("height", 38)
|
| 184 |
+
.attr("rx", 14)
|
| 185 |
+
.attr("fill", "#2f6a37");
|
| 186 |
+
badge.append("text")
|
| 187 |
+
.attr("x", 18)
|
| 188 |
+
.attr("y", 25)
|
| 189 |
+
.attr("font-family", labelFamily)
|
| 190 |
+
.attr("font-size", 18)
|
| 191 |
+
.attr("font-weight", 800)
|
| 192 |
+
.attr("letter-spacing", 2)
|
| 193 |
+
.attr("fill", "#ffffff")
|
| 194 |
+
.text("POSITIVE TRENDS");
|
| 195 |
+
badge.append("rect")
|
| 196 |
+
.attr("class", "reserved-asset-slot bump10-badge-trend-slot")
|
| 197 |
+
.attr("data-asset-slot", "badge-trend-mark")
|
| 198 |
+
.attr("data-slot-policy", "neutral_placeholder_or_image2_asset")
|
| 199 |
+
.attr("data-anchor", "badge-right-center")
|
| 200 |
+
.attr("data-collision-rule", "stay inside badge padding and clear of badge text")
|
| 201 |
+
.attr("x", 216)
|
| 202 |
+
.attr("y", 9)
|
| 203 |
+
.attr("width", 22)
|
| 204 |
+
.attr("height", 20)
|
| 205 |
+
.attr("rx", 5)
|
| 206 |
+
.attr("fill", "none")
|
| 207 |
+
.attr("stroke", "#e8f4de")
|
| 208 |
+
.attr("stroke-width", 2)
|
| 209 |
+
.attr("opacity", 0.95);
|
| 210 |
+
|
| 211 |
+
const titleMaxWidth = 365;
|
| 212 |
+
let titleFont = Math.min(
|
| 213 |
+
fitFont(titleText, 720, 37, titleFamily, 900, 26),
|
| 214 |
+
fitFont(titleText.split(/\s+/).reduce((a, b) => a.length > b.length ? a : b, ""), titleMaxWidth, 37, titleFamily, 900, 26)
|
| 215 |
+
);
|
| 216 |
+
while (titleFont > 24 && computeWrappedLines(titleText, titleMaxWidth, titleFont, titleFamily, 900).length > 2) {
|
| 217 |
+
titleFont -= 1;
|
| 218 |
+
}
|
| 219 |
+
const titleLineHeight = Math.round(titleFont * 1.15);
|
| 220 |
+
const title = svg.append("text")
|
| 221 |
+
.attr("class", "bump10-title")
|
| 222 |
+
.attr("x", 38)
|
| 223 |
+
.attr("y", 132)
|
| 224 |
+
.attr("font-family", titleFamily)
|
| 225 |
+
.attr("font-size", titleFont)
|
| 226 |
+
.attr("font-weight", 900)
|
| 227 |
+
.attr("fill", "#07331d");
|
| 228 |
+
wrapText(title, titleText, titleMaxWidth, 2, titleLineHeight, titleFont, titleFamily, 900);
|
| 229 |
+
|
| 230 |
+
const subtitle = svg.append("text")
|
| 231 |
+
.attr("class", "bump10-subtitle")
|
| 232 |
+
.attr("x", 44)
|
| 233 |
+
.attr("y", 244)
|
| 234 |
+
.attr("font-family", labelFamily)
|
| 235 |
+
.attr("font-size", 17)
|
| 236 |
+
.attr("font-weight", 400)
|
| 237 |
+
.attr("fill", "#5d6267");
|
| 238 |
+
wrapText(subtitle, subtitleText, 350, 3, 25, 17, labelFamily, 400);
|
| 239 |
+
|
| 240 |
+
const headerSlot = svg.append("g")
|
| 241 |
+
.attr("class", "reserved-asset-slot bump10-header-art-slot")
|
| 242 |
+
.attr("data-asset-slot", "header-farm-illustration")
|
| 243 |
+
.attr("data-slot-policy", "input_provided_or_image2_asset")
|
| 244 |
+
.attr("data-asset-source-policy", "input_provided_or_image2_asset")
|
| 245 |
+
.attr("data-anchor", "top-right-header-safe-area")
|
| 246 |
+
.attr("data-bbox", "430,58,220,220")
|
| 247 |
+
.attr("data-mask-policy", "rounded clipped bbox")
|
| 248 |
+
.attr("data-collision-rule", "stay right of title/subtitle and above year rail")
|
| 249 |
+
.attr("transform", "translate(430,58)");
|
| 250 |
+
const headerClipId = `bump10-header-clip-${Math.floor(Math.random() * 1000000)}`;
|
| 251 |
+
defs.append("clipPath")
|
| 252 |
+
.attr("id", headerClipId)
|
| 253 |
+
.append("rect")
|
| 254 |
+
.attr("x", 0)
|
| 255 |
+
.attr("y", 0)
|
| 256 |
+
.attr("width", 220)
|
| 257 |
+
.attr("height", 220)
|
| 258 |
+
.attr("rx", 28);
|
| 259 |
+
headerSlot.append("rect")
|
| 260 |
+
.attr("width", 220)
|
| 261 |
+
.attr("height", 220)
|
| 262 |
+
.attr("rx", 28)
|
| 263 |
+
.attr("fill", "#e8f2da");
|
| 264 |
+
headerSlot.append("path")
|
| 265 |
+
.attr("d", "M0,174 C45,130 84,158 128,122 C164,94 190,106 220,78 L220,220 L0,220 Z")
|
| 266 |
+
.attr("fill", "#7fa843")
|
| 267 |
+
.attr("opacity", 0.62);
|
| 268 |
+
headerSlot.append("path")
|
| 269 |
+
.attr("d", "M0,206 C52,160 94,184 136,148 C168,122 196,132 220,112 L220,220 L0,220 Z")
|
| 270 |
+
.attr("fill", "#d3c95c")
|
| 271 |
+
.attr("opacity", 0.55);
|
| 272 |
+
headerSlot.append("circle").attr("cx", 72).attr("cy", 76).attr("r", 18).attr("fill", "#ecbd5c").attr("opacity", 0.55);
|
| 273 |
+
headerSlot.append("circle").attr("cx", 176).attr("cy", 104).attr("r", 26).attr("fill", "#2f6a37").attr("opacity", 0.75);
|
| 274 |
+
headerSlot.append("circle").attr("cx", 176).attr("cy", 104).attr("r", 12).attr("fill", "#fbfaf6").attr("opacity", 0.88);
|
| 275 |
+
|
| 276 |
+
const chart = svg.append("g").attr("class", "bump10-chart");
|
| 277 |
+
const left = 28;
|
| 278 |
+
const right = 624;
|
| 279 |
+
const rowTop = 366;
|
| 280 |
+
const rowBottom = 886;
|
| 281 |
+
const rowGap = groups.length > 6 ? 10 : 14;
|
| 282 |
+
const rowHeight = Math.max(58, Math.min(92, (rowBottom - rowTop - rowGap * (groups.length - 1)) / Math.max(groups.length, 1)));
|
| 283 |
+
const iconX = left + 36;
|
| 284 |
+
const labelX = left + 82;
|
| 285 |
+
const markerLeft = left + 218;
|
| 286 |
+
const markerRight = right - 50;
|
| 287 |
+
const markerRadius = Math.max(18, Math.min(31, rowHeight * 0.32, (markerRight - markerLeft) / Math.max(timePoints.length * 2.6, 1)));
|
| 288 |
+
const markerCenters = timePoints.map((t, i) => {
|
| 289 |
+
if (timePoints.length <= 1) return (markerLeft + markerRight) / 2;
|
| 290 |
+
return markerLeft + i * ((markerRight - markerLeft) / (timePoints.length - 1));
|
| 291 |
+
});
|
| 292 |
+
const rowCenters = groups.map((g, i) => rowTop + rowHeight / 2 + i * (rowHeight + rowGap));
|
| 293 |
+
|
| 294 |
+
const railY = 318;
|
| 295 |
+
chart.append("line")
|
| 296 |
+
.attr("class", "bump10-year-rail")
|
| 297 |
+
.attr("x1", markerCenters[0] - 50)
|
| 298 |
+
.attr("x2", markerCenters[markerCenters.length - 1] + 50)
|
| 299 |
+
.attr("y1", railY)
|
| 300 |
+
.attr("y2", railY)
|
| 301 |
+
.attr("stroke", "#2c6034")
|
| 302 |
+
.attr("stroke-width", 2)
|
| 303 |
+
.attr("stroke-linecap", "round");
|
| 304 |
+
const yearStep = timePoints.length <= 1 ? 92 : Math.abs(markerCenters[1] - markerCenters[0]);
|
| 305 |
+
const yearPillWidth = Math.max(32, Math.min(68, yearStep - 8));
|
| 306 |
+
const yearFontSize = Math.max(10, Math.min(18, fitFont(safeText(timePoints.reduce((a, b) => safeText(a).length > safeText(b).length ? a : b, "")), yearPillWidth - 8, 18, labelFamily, 800, 10)));
|
| 307 |
+
timePoints.forEach((timePoint, i) => {
|
| 308 |
+
const x = markerCenters[i];
|
| 309 |
+
chart.append("rect")
|
| 310 |
+
.attr("class", "bump10-year-pill")
|
| 311 |
+
.attr("x", x - yearPillWidth / 2)
|
| 312 |
+
.attr("y", railY - 15)
|
| 313 |
+
.attr("width", yearPillWidth)
|
| 314 |
+
.attr("height", 30)
|
| 315 |
+
.attr("rx", Math.min(10, yearPillWidth / 4))
|
| 316 |
+
.attr("fill", "#24562d");
|
| 317 |
+
chart.append("text")
|
| 318 |
+
.attr("class", "bump10-year-label")
|
| 319 |
+
.attr("x", x)
|
| 320 |
+
.attr("y", railY + yearFontSize * 0.36)
|
| 321 |
+
.attr("text-anchor", "middle")
|
| 322 |
+
.attr("font-family", labelFamily)
|
| 323 |
+
.attr("font-size", yearFontSize)
|
| 324 |
+
.attr("font-weight", 800)
|
| 325 |
+
.attr("fill", "#ffffff")
|
| 326 |
+
.text(safeText(timePoint));
|
| 327 |
+
});
|
| 328 |
+
|
| 329 |
+
const finalValues = groups.map(group => {
|
| 330 |
+
const pts = grouped.get(group) || [];
|
| 331 |
+
const last = pts.length ? +pts[pts.length - 1][valueField] : 0;
|
| 332 |
+
return { group, value: Number.isFinite(last) ? last : 0 };
|
| 333 |
+
}).sort((a, b) => b.value - a.value);
|
| 334 |
+
const finalRank = new Map(finalValues.map((d, i) => [d.group, i]));
|
| 335 |
+
|
| 336 |
+
function trendSubtitle(points, group, groupIndex) {
|
| 337 |
+
if (!points || points.length < 2) return "Trend data";
|
| 338 |
+
const first = +points[0][valueField];
|
| 339 |
+
const last = +points[points.length - 1][valueField];
|
| 340 |
+
const delta = last - first;
|
| 341 |
+
const rank = finalRank.get(group) || 0;
|
| 342 |
+
if (rank === 0 && delta > 0) return "Leading with strong trend";
|
| 343 |
+
if (groupIndex === 0 && delta > 3) return "Strong growth";
|
| 344 |
+
if (delta > 8) return "Explosive growth";
|
| 345 |
+
if (delta > 3) return "Steady and consistent";
|
| 346 |
+
if (delta > 0.5) return "Moderate gain";
|
| 347 |
+
if (delta < -3) return "Declining trend";
|
| 348 |
+
return "Stable trend";
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
groups.forEach((group, groupIndex) => {
|
| 352 |
+
const cy = rowCenters[groupIndex];
|
| 353 |
+
const y = cy - rowHeight / 2;
|
| 354 |
+
const points = grouped.get(group) || [];
|
| 355 |
+
const ramp = rowRamps[groupIndex % rowRamps.length];
|
| 356 |
+
const rowGrad = defs.append("linearGradient")
|
| 357 |
+
.attr("id", `bump10-row-grad-${groupIndex}`)
|
| 358 |
+
.attr("x1", "0%")
|
| 359 |
+
.attr("x2", "100%");
|
| 360 |
+
ramp.forEach((color, i) => {
|
| 361 |
+
rowGrad.append("stop")
|
| 362 |
+
.attr("offset", `${(i / Math.max(ramp.length - 1, 1)) * 100}%`)
|
| 363 |
+
.attr("stop-color", color);
|
| 364 |
+
});
|
| 365 |
+
|
| 366 |
+
chart.append("rect")
|
| 367 |
+
.attr("class", "bump10-row-card")
|
| 368 |
+
.attr("data-group", safeText(group))
|
| 369 |
+
.attr("x", left)
|
| 370 |
+
.attr("y", y)
|
| 371 |
+
.attr("width", right - left)
|
| 372 |
+
.attr("height", rowHeight)
|
| 373 |
+
.attr("rx", 22)
|
| 374 |
+
.attr("fill", "#f8f6f1")
|
| 375 |
+
.attr("stroke", "#ece7dc")
|
| 376 |
+
.attr("stroke-width", 1)
|
| 377 |
+
.attr("filter", "url(#bump10-soft-shadow)");
|
| 378 |
+
|
| 379 |
+
const icon = chart.append("g")
|
| 380 |
+
.attr("class", "reserved-asset-slot bump10-row-icon-slot")
|
| 381 |
+
.attr("data-asset-slot", "row-icon")
|
| 382 |
+
.attr("data-slot-policy", "image2_asset_or_neutral_placeholder")
|
| 383 |
+
.attr("data-asset-source-policy", "image2_asset_or_neutral_placeholder")
|
| 384 |
+
.attr("data-anchor", "row-card-left-center")
|
| 385 |
+
.attr("data-bbox", `${iconX - 23},${cy - 23},46,46`)
|
| 386 |
+
.attr("data-mask-policy", "circle")
|
| 387 |
+
.attr("data-collision-rule", "stay inside row card, left of group label, clear of value markers");
|
| 388 |
+
icon.append("circle")
|
| 389 |
+
.attr("cx", iconX)
|
| 390 |
+
.attr("cy", cy)
|
| 391 |
+
.attr("r", 24)
|
| 392 |
+
.attr("fill", ramp[1])
|
| 393 |
+
.attr("opacity", 0.24)
|
| 394 |
+
.attr("stroke", "#ffffff")
|
| 395 |
+
.attr("stroke-width", 2);
|
| 396 |
+
icon.append("text")
|
| 397 |
+
.attr("x", iconX)
|
| 398 |
+
.attr("y", cy + 6)
|
| 399 |
+
.attr("text-anchor", "middle")
|
| 400 |
+
.attr("font-family", labelFamily)
|
| 401 |
+
.attr("font-size", 15)
|
| 402 |
+
.attr("font-weight", 800)
|
| 403 |
+
.attr("fill", "#2f4a38")
|
| 404 |
+
.text(safeText(group).slice(0, 2).toUpperCase());
|
| 405 |
+
|
| 406 |
+
const groupFont = fitFont(group, markerLeft - labelX - 38, 20, labelFamily, 900, 13);
|
| 407 |
+
chart.append("text")
|
| 408 |
+
.attr("class", "bump10-group-label")
|
| 409 |
+
.attr("x", labelX)
|
| 410 |
+
.attr("y", cy - 9)
|
| 411 |
+
.attr("font-family", labelFamily)
|
| 412 |
+
.attr("font-size", groupFont)
|
| 413 |
+
.attr("font-weight", 900)
|
| 414 |
+
.attr("fill", "#07331d")
|
| 415 |
+
.text(safeText(group));
|
| 416 |
+
const trend = chart.append("text")
|
| 417 |
+
.attr("class", "bump10-trend-subtitle")
|
| 418 |
+
.attr("x", labelX)
|
| 419 |
+
.attr("y", cy + 13)
|
| 420 |
+
.attr("font-family", labelFamily)
|
| 421 |
+
.attr("font-size", 12)
|
| 422 |
+
.attr("font-weight", 400)
|
| 423 |
+
.attr("fill", "#5d6267");
|
| 424 |
+
wrapText(trend, trendSubtitle(points, group, groupIndex), markerLeft - labelX - 22, 2, 15, 12, labelFamily, 400);
|
| 425 |
+
|
| 426 |
+
for (let i = 0; i < points.length - 1; i += 1) {
|
| 427 |
+
const currentIndex = timePoints.indexOf(points[i][timeField]);
|
| 428 |
+
const nextIndex = timePoints.indexOf(points[i + 1][timeField]);
|
| 429 |
+
if (currentIndex < 0 || nextIndex < 0) continue;
|
| 430 |
+
const x1 = markerCenters[currentIndex] + markerRadius - 1;
|
| 431 |
+
const x2 = markerCenters[nextIndex] - markerRadius + 1;
|
| 432 |
+
if (x2 <= x1) continue;
|
| 433 |
+
chart.append("line")
|
| 434 |
+
.attr("class", "bump10-connector")
|
| 435 |
+
.attr("data-group", safeText(group))
|
| 436 |
+
.attr("x1", x1)
|
| 437 |
+
.attr("x2", x2)
|
| 438 |
+
.attr("y1", cy)
|
| 439 |
+
.attr("y2", cy)
|
| 440 |
+
.attr("stroke", ramp[Math.min(2, ramp.length - 1)])
|
| 441 |
+
.attr("stroke-width", 3.4)
|
| 442 |
+
.attr("opacity", 0.9)
|
| 443 |
+
.attr("stroke-linecap", "round");
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
points.forEach(point => {
|
| 447 |
+
const timeIndex = timePoints.indexOf(point[timeField]);
|
| 448 |
+
if (timeIndex < 0) return;
|
| 449 |
+
const cx = markerCenters[timeIndex];
|
| 450 |
+
const value = +point[valueField];
|
| 451 |
+
const t = timePoints.length <= 1 ? 0 : timeIndex / (timePoints.length - 1);
|
| 452 |
+
const fill = d3.interpolateRgb(ramp[0], ramp[ramp.length - 1])(t);
|
| 453 |
+
const valueText = chartUtils.format.compactFixedText(Number.isFinite(value) ? value : 0);
|
| 454 |
+
const valueFont = fitFont(valueText, markerRadius * 1.55, 18, annotationFamily, 800, 11);
|
| 455 |
+
const dark = chartUtils.color.brightness(fill, { fallback: "#ffffff" }) < 0.48;
|
| 456 |
+
chart.append("circle")
|
| 457 |
+
.attr("class", "bump10-value-circle")
|
| 458 |
+
.attr("data-group", safeText(group))
|
| 459 |
+
.attr("data-time", safeText(point[timeField]))
|
| 460 |
+
.attr("data-value", Number.isFinite(value) ? value : "")
|
| 461 |
+
.attr("cx", cx)
|
| 462 |
+
.attr("cy", cy)
|
| 463 |
+
.attr("r", markerRadius)
|
| 464 |
+
.attr("fill", fill)
|
| 465 |
+
.attr("stroke", "#ffffff")
|
| 466 |
+
.attr("stroke-width", 2.4)
|
| 467 |
+
.attr("filter", "url(#bump10-soft-shadow)");
|
| 468 |
+
chart.append("text")
|
| 469 |
+
.attr("class", "bump10-value-label")
|
| 470 |
+
.attr("x", cx)
|
| 471 |
+
.attr("y", cy + 6)
|
| 472 |
+
.attr("text-anchor", "middle")
|
| 473 |
+
.attr("font-family", annotationFamily)
|
| 474 |
+
.attr("font-size", valueFont)
|
| 475 |
+
.attr("font-weight", 800)
|
| 476 |
+
.attr("fill", dark ? "#ffffff" : "#1f2d2d")
|
| 477 |
+
.text(valueText);
|
| 478 |
+
});
|
| 479 |
+
});
|
| 480 |
+
|
| 481 |
+
return svg.node();
|
| 482 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/bump_chart_11.js
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Sorted Lines Chart",
|
| 5 |
+
"chart_name": "bump_chart_11",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"chart_for": "comparison",
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [
|
| 11 |
+
[2, 12],
|
| 12 |
+
[0, "inf"],
|
| 13 |
+
[4, 10]
|
| 14 |
+
],
|
| 15 |
+
"required_fields_icons": [],
|
| 16 |
+
"required_other_icons": [],
|
| 17 |
+
"required_fields_colors": ["group"],
|
| 18 |
+
"required_other_colors": [],
|
| 19 |
+
"supported_effects": ["spacing"],
|
| 20 |
+
"min_height": 400,
|
| 21 |
+
"min_width": 400,
|
| 22 |
+
"background": "no",
|
| 23 |
+
"icon_mark": "none",
|
| 24 |
+
"icon_label": "none",
|
| 25 |
+
"has_x_axis": "no",
|
| 26 |
+
"has_y_axis": "no"
|
| 27 |
+
}
|
| 28 |
+
REQUIREMENTS_END
|
| 29 |
+
*/
|
| 30 |
+
|
| 31 |
+
function makeChart(containerSelector, data) {
|
| 32 |
+
const jsonData = data;
|
| 33 |
+
const chartData = (jsonData.data && jsonData.data.data) || [];
|
| 34 |
+
const variables = jsonData.variables || {};
|
| 35 |
+
const typography = jsonData.typography || {};
|
| 36 |
+
|
| 37 |
+
d3.select(containerSelector).html("");
|
| 38 |
+
|
| 39 |
+
const width = variables.width || 650;
|
| 40 |
+
const height = variables.height || 975;
|
| 41 |
+
const timeField = chartUtils.schema.channel(jsonData, "x").key;
|
| 42 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key;
|
| 43 |
+
const groupField = chartUtils.schema.channel(jsonData, "group").key;
|
| 44 |
+
|
| 45 |
+
function safeText(value) {
|
| 46 |
+
return value == null ? "" : String(value);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function measureText(text, fontSize, fontFamily, fontWeight) {
|
| 50 |
+
return chartUtils.text.measure(null, safeText(text), {
|
| 51 |
+
fontFamily,
|
| 52 |
+
fontSize,
|
| 53 |
+
fontWeight
|
| 54 |
+
}).width;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function fitFont(text, maxWidth, start, family, weight, minSize) {
|
| 58 |
+
let size = start;
|
| 59 |
+
while (size > minSize && measureText(text, size, family, weight) > maxWidth) {
|
| 60 |
+
size -= 1;
|
| 61 |
+
}
|
| 62 |
+
return size;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function computeWrappedLines(text, maxWidth, fontSize, family, weight) {
|
| 66 |
+
const words = safeText(text).split(/\s+/).filter(Boolean);
|
| 67 |
+
const lines = [];
|
| 68 |
+
let line = "";
|
| 69 |
+
words.forEach(word => {
|
| 70 |
+
const candidate = line ? `${line} ${word}` : word;
|
| 71 |
+
if (measureText(candidate, fontSize, family, weight) <= maxWidth || !line) {
|
| 72 |
+
line = candidate;
|
| 73 |
+
} else {
|
| 74 |
+
lines.push(line);
|
| 75 |
+
line = word;
|
| 76 |
+
}
|
| 77 |
+
});
|
| 78 |
+
if (line) lines.push(line);
|
| 79 |
+
return lines;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
function wrapText(selection, text, maxWidth, maxLines, lineHeight, fontSize, family, weight) {
|
| 83 |
+
const lines = computeWrappedLines(text, maxWidth, fontSize, family, weight);
|
| 84 |
+
const visible = lines.slice(0, maxLines);
|
| 85 |
+
if (lines.length > maxLines && visible.length) {
|
| 86 |
+
let last = visible[visible.length - 1];
|
| 87 |
+
while (last.length > 1 && measureText(`${last}...`, fontSize, family, weight) > maxWidth) {
|
| 88 |
+
last = last.slice(0, -1);
|
| 89 |
+
}
|
| 90 |
+
visible[visible.length - 1] = `${last}...`;
|
| 91 |
+
}
|
| 92 |
+
selection.selectAll("tspan")
|
| 93 |
+
.data(visible)
|
| 94 |
+
.enter()
|
| 95 |
+
.append("tspan")
|
| 96 |
+
.attr("x", selection.attr("x"))
|
| 97 |
+
.attr("dy", (d, i) => i === 0 ? 0 : lineHeight)
|
| 98 |
+
.text(d => d);
|
| 99 |
+
return visible.length;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function setWrappedLines(selection, lines, lineHeight) {
|
| 103 |
+
selection.selectAll("tspan")
|
| 104 |
+
.data(lines)
|
| 105 |
+
.enter()
|
| 106 |
+
.append("tspan")
|
| 107 |
+
.attr("x", selection.attr("x"))
|
| 108 |
+
.attr("dy", (d, i) => i === 0 ? 0 : lineHeight)
|
| 109 |
+
.text(d => d);
|
| 110 |
+
return lines.length;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function chooseFittingText(candidates, maxWidth, maxLines, startSize, minSize, family, weight) {
|
| 114 |
+
for (const candidate of candidates) {
|
| 115 |
+
for (let size = startSize; size >= minSize; size -= 1) {
|
| 116 |
+
const lines = computeWrappedLines(candidate, maxWidth, size, family, weight);
|
| 117 |
+
if (lines.length <= maxLines) {
|
| 118 |
+
return {
|
| 119 |
+
fontSize: size,
|
| 120 |
+
lineHeight: Math.round(size * 1.34),
|
| 121 |
+
lines
|
| 122 |
+
};
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
const fallback = candidates[candidates.length - 1] || "Values";
|
| 127 |
+
const size = minSize;
|
| 128 |
+
return {
|
| 129 |
+
fontSize: size,
|
| 130 |
+
lineHeight: Math.round(size * 1.34),
|
| 131 |
+
lines: computeWrappedLines(fallback, maxWidth, size, family, weight).slice(0, maxLines)
|
| 132 |
+
};
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function parseTimeValue(value) {
|
| 136 |
+
const parsed = chartUtils.format.parseDate(value);
|
| 137 |
+
if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed.getTime();
|
| 138 |
+
const numeric = +value;
|
| 139 |
+
return Number.isFinite(numeric) ? numeric : safeText(value);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function humanizeFieldName(fieldName) {
|
| 143 |
+
return safeText(fieldName)
|
| 144 |
+
.replace(/[_-]+/g, " ")
|
| 145 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 146 |
+
.replace(/\s+/g, " ")
|
| 147 |
+
.trim()
|
| 148 |
+
.toLowerCase();
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function hexagonPath(cx, cy, r) {
|
| 152 |
+
const h = r * 0.86;
|
| 153 |
+
return [
|
| 154 |
+
`M${cx - r},${cy}`,
|
| 155 |
+
`L${cx - r * 0.52},${cy - h}`,
|
| 156 |
+
`L${cx + r * 0.52},${cy - h}`,
|
| 157 |
+
`L${cx + r},${cy}`,
|
| 158 |
+
`L${cx + r * 0.52},${cy + h}`,
|
| 159 |
+
`L${cx - r * 0.52},${cy + h}`,
|
| 160 |
+
"Z"
|
| 161 |
+
].join(" ");
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
const titleFamily = (typography.title && typography.title.font_family) || "Arial, Helvetica, sans-serif";
|
| 165 |
+
const labelFamily = (typography.label && typography.label.font_family) || "Arial, Helvetica, sans-serif";
|
| 166 |
+
const annotationFamily = (typography.annotation && typography.annotation.font_family) || "Arial, Helvetica, sans-serif";
|
| 167 |
+
|
| 168 |
+
const rawTitle = safeText((jsonData.titles && jsonData.titles.main_title) || (jsonData.metadata && jsonData.metadata.title) || "Bicycle Commuting Rising");
|
| 169 |
+
const subtitleText = safeText((jsonData.titles && jsonData.titles.sub_title) || (jsonData.metadata && jsonData.metadata.description) || jsonData.description || "");
|
| 170 |
+
|
| 171 |
+
const timePoints = [...new Set(chartData.map(d => d[timeField]))]
|
| 172 |
+
.sort((a, b) => {
|
| 173 |
+
const pa = parseTimeValue(a);
|
| 174 |
+
const pb = parseTimeValue(b);
|
| 175 |
+
if (typeof pa === "number" && typeof pb === "number") return pa - pb;
|
| 176 |
+
return safeText(a).localeCompare(safeText(b));
|
| 177 |
+
});
|
| 178 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 179 |
+
const grouped = new Map(groups.map(group => [
|
| 180 |
+
group,
|
| 181 |
+
chartData.filter(d => d[groupField] === group)
|
| 182 |
+
.sort((a, b) => timePoints.indexOf(a[timeField]) - timePoints.indexOf(b[timeField]))
|
| 183 |
+
]));
|
| 184 |
+
|
| 185 |
+
const rowRamps = [
|
| 186 |
+
["#064c66", "#0b6f88", "#1097b7", "#24bfd2", "#45d3dc"],
|
| 187 |
+
["#a73c28", "#d45642", "#f37759", "#ff9b67", "#ffd27d"],
|
| 188 |
+
["#59743d", "#78934a", "#a7b34a", "#d8cc4b", "#f4d75c"],
|
| 189 |
+
["#0c8746", "#199d55", "#32b85a", "#63cf59", "#8ade5d"],
|
| 190 |
+
["#29588d", "#2e7eb7", "#4aa7d8", "#73c3df", "#a1dce7"],
|
| 191 |
+
["#6f3e9f", "#8d57c2", "#aa76df", "#c997ef", "#e5c2f7"],
|
| 192 |
+
["#b26c22", "#d99031", "#ecb74c", "#f2d071", "#f8df9b"],
|
| 193 |
+
["#3f7f79", "#48a19a", "#5ac0b0", "#77d9c5", "#a7efe0"]
|
| 194 |
+
];
|
| 195 |
+
|
| 196 |
+
const svg = d3.select(containerSelector)
|
| 197 |
+
.append("svg")
|
| 198 |
+
.attr("width", "100%")
|
| 199 |
+
.attr("height", height)
|
| 200 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 201 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 202 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 203 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 204 |
+
|
| 205 |
+
const defs = svg.append("defs");
|
| 206 |
+
const bg = defs.append("radialGradient")
|
| 207 |
+
.attr("id", "bump11-bg")
|
| 208 |
+
.attr("cx", "78%")
|
| 209 |
+
.attr("cy", "12%")
|
| 210 |
+
.attr("r", "96%");
|
| 211 |
+
bg.append("stop").attr("offset", "0%").attr("stop-color", "#eef8fb");
|
| 212 |
+
bg.append("stop").attr("offset", "52%").attr("stop-color", "#f8fcfd");
|
| 213 |
+
bg.append("stop").attr("offset", "100%").attr("stop-color", "#eaf6f8");
|
| 214 |
+
|
| 215 |
+
defs.append("filter")
|
| 216 |
+
.attr("id", "bump11-panel-shadow")
|
| 217 |
+
.attr("x", "-8%")
|
| 218 |
+
.attr("y", "-8%")
|
| 219 |
+
.attr("width", "116%")
|
| 220 |
+
.attr("height", "124%")
|
| 221 |
+
.html('<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#8eb5c0" flood-opacity="0.20"/>');
|
| 222 |
+
|
| 223 |
+
svg.append("rect")
|
| 224 |
+
.attr("width", width)
|
| 225 |
+
.attr("height", height)
|
| 226 |
+
.attr("fill", "url(#bump11-bg)");
|
| 227 |
+
|
| 228 |
+
const titleMaxWidth = 382;
|
| 229 |
+
let titleFont = Math.min(
|
| 230 |
+
fitFont(rawTitle, 640, 42, titleFamily, 900, 28),
|
| 231 |
+
fitFont(rawTitle.split(/\s+/).reduce((a, b) => a.length > b.length ? a : b, ""), titleMaxWidth, 42, titleFamily, 900, 28)
|
| 232 |
+
);
|
| 233 |
+
while (titleFont > 28 && computeWrappedLines(rawTitle, titleMaxWidth, titleFont, titleFamily, 900).length > 2) {
|
| 234 |
+
titleFont -= 1;
|
| 235 |
+
}
|
| 236 |
+
const title = svg.append("text")
|
| 237 |
+
.attr("class", "bump11-title")
|
| 238 |
+
.attr("x", 38)
|
| 239 |
+
.attr("y", 80)
|
| 240 |
+
.attr("font-family", titleFamily)
|
| 241 |
+
.attr("font-size", titleFont)
|
| 242 |
+
.attr("font-weight", 900)
|
| 243 |
+
.attr("fill", "#071d35");
|
| 244 |
+
const titleLines = wrapText(title, rawTitle, titleMaxWidth, 2, Math.round(titleFont * 1.14), titleFont, titleFamily, 900);
|
| 245 |
+
|
| 246 |
+
const subtitleY = 80 + Math.max(1, titleLines) * Math.round(titleFont * 1.14) + 28;
|
| 247 |
+
const subtitle = svg.append("text")
|
| 248 |
+
.attr("class", "bump11-subtitle")
|
| 249 |
+
.attr("x", 40)
|
| 250 |
+
.attr("y", subtitleY)
|
| 251 |
+
.attr("font-family", labelFamily)
|
| 252 |
+
.attr("font-size", 18)
|
| 253 |
+
.attr("font-weight", 400)
|
| 254 |
+
.attr("fill", "#41576b");
|
| 255 |
+
wrapText(subtitle, subtitleText, 350, 3, 27, 18, labelFamily, 400);
|
| 256 |
+
|
| 257 |
+
const headerSlot = svg.append("g")
|
| 258 |
+
.attr("class", "reserved-asset-slot bump11-header-art-slot")
|
| 259 |
+
.attr("data-asset-slot", "header-bicycle-city-illustration")
|
| 260 |
+
.attr("data-slot-policy", "input_asset_or_image2_asset")
|
| 261 |
+
.attr("data-asset-source-policy", "input_asset_or_image2_asset")
|
| 262 |
+
.attr("data-anchor", "top-right-header-safe-area")
|
| 263 |
+
.attr("data-bbox", "420,48,198,228")
|
| 264 |
+
.attr("data-mask-policy", "rounded clipped bbox")
|
| 265 |
+
.attr("data-collision-rule", "stay right of title/subtitle and above chart panel")
|
| 266 |
+
.attr("transform", "translate(420,48)");
|
| 267 |
+
headerSlot.append("rect")
|
| 268 |
+
.attr("width", 198)
|
| 269 |
+
.attr("height", 228)
|
| 270 |
+
.attr("rx", 28)
|
| 271 |
+
.attr("fill", "#e3f3f7")
|
| 272 |
+
.attr("opacity", 0.72);
|
| 273 |
+
headerSlot.append("rect")
|
| 274 |
+
.attr("x", 18)
|
| 275 |
+
.attr("y", 162)
|
| 276 |
+
.attr("width", 162)
|
| 277 |
+
.attr("height", 18)
|
| 278 |
+
.attr("rx", 9)
|
| 279 |
+
.attr("fill", "#9ac8d0")
|
| 280 |
+
.attr("opacity", 0.28);
|
| 281 |
+
headerSlot.append("circle")
|
| 282 |
+
.attr("cx", 162)
|
| 283 |
+
.attr("cy", 64)
|
| 284 |
+
.attr("r", 26)
|
| 285 |
+
.attr("fill", "#cceaf0")
|
| 286 |
+
.attr("opacity", 0.82);
|
| 287 |
+
headerSlot.append("circle")
|
| 288 |
+
.attr("cx", 88)
|
| 289 |
+
.attr("cy", 116)
|
| 290 |
+
.attr("r", 48)
|
| 291 |
+
.attr("fill", "#b7dce5")
|
| 292 |
+
.attr("opacity", 0.42);
|
| 293 |
+
headerSlot.append("text")
|
| 294 |
+
.attr("x", 99)
|
| 295 |
+
.attr("y", 198)
|
| 296 |
+
.attr("text-anchor", "middle")
|
| 297 |
+
.attr("font-family", labelFamily)
|
| 298 |
+
.attr("font-size", 12)
|
| 299 |
+
.attr("font-weight", 700)
|
| 300 |
+
.attr("fill", "#568493")
|
| 301 |
+
.attr("opacity", 0.78)
|
| 302 |
+
.text("reserved image slot");
|
| 303 |
+
|
| 304 |
+
const panelX = 16;
|
| 305 |
+
const panelY = 310;
|
| 306 |
+
const panelW = 618;
|
| 307 |
+
const panelH = 560;
|
| 308 |
+
svg.append("rect")
|
| 309 |
+
.attr("class", "bump11-chart-panel")
|
| 310 |
+
.attr("x", panelX)
|
| 311 |
+
.attr("y", panelY)
|
| 312 |
+
.attr("width", panelW)
|
| 313 |
+
.attr("height", panelH)
|
| 314 |
+
.attr("rx", 20)
|
| 315 |
+
.attr("fill", "#ffffff")
|
| 316 |
+
.attr("filter", "url(#bump11-panel-shadow)");
|
| 317 |
+
|
| 318 |
+
const chart = svg.append("g").attr("class", "bump11-chart");
|
| 319 |
+
const labelColumnX = panelX + 74;
|
| 320 |
+
const markerLeft = panelX + 185;
|
| 321 |
+
const markerRight = panelX + panelW - 45;
|
| 322 |
+
const yearY = panelY + 48;
|
| 323 |
+
const rowTop = panelY + 128;
|
| 324 |
+
const rowBottom = panelY + panelH - 58;
|
| 325 |
+
const rowGap = groups.length > 6 ? 10 : 16;
|
| 326 |
+
const rowHeight = Math.max(44, Math.min(100, (rowBottom - rowTop - rowGap * Math.max(groups.length - 1, 0)) / Math.max(groups.length, 1)));
|
| 327 |
+
const denseRows = groups.length > 5 || rowHeight < 68;
|
| 328 |
+
const rowCenters = groups.map((group, i) => rowTop + rowHeight / 2 + i * (rowHeight + rowGap));
|
| 329 |
+
const markerCenters = timePoints.map((timePoint, i) => {
|
| 330 |
+
if (timePoints.length <= 1) return (markerLeft + markerRight) / 2;
|
| 331 |
+
return markerLeft + i * ((markerRight - markerLeft) / (timePoints.length - 1));
|
| 332 |
+
});
|
| 333 |
+
const markerRadius = Math.max(17, Math.min(30, rowHeight * 0.30, (markerRight - markerLeft) / Math.max(timePoints.length * 2.45, 1)));
|
| 334 |
+
|
| 335 |
+
chart.append("line")
|
| 336 |
+
.attr("class", "bump11-year-separator")
|
| 337 |
+
.attr("x1", panelX + 26)
|
| 338 |
+
.attr("x2", panelX + panelW - 26)
|
| 339 |
+
.attr("y1", panelY + 86)
|
| 340 |
+
.attr("y2", panelY + 86)
|
| 341 |
+
.attr("stroke", "#d6e8ee")
|
| 342 |
+
.attr("stroke-width", 1.2);
|
| 343 |
+
|
| 344 |
+
const widestYear = timePoints.reduce((a, b) => safeText(a).length > safeText(b).length ? a : b, "");
|
| 345 |
+
const yearFont = fitFont(widestYear, Math.max(36, (markerRight - markerLeft) / Math.max(timePoints.length, 1) - 8), 18, labelFamily, 800, 11);
|
| 346 |
+
timePoints.forEach((timePoint, i) => {
|
| 347 |
+
chart.append("text")
|
| 348 |
+
.attr("class", "bump11-year-label")
|
| 349 |
+
.attr("x", markerCenters[i])
|
| 350 |
+
.attr("y", yearY)
|
| 351 |
+
.attr("text-anchor", "middle")
|
| 352 |
+
.attr("font-family", labelFamily)
|
| 353 |
+
.attr("font-size", yearFont)
|
| 354 |
+
.attr("font-weight", 800)
|
| 355 |
+
.attr("fill", "#0a6680")
|
| 356 |
+
.text(safeText(timePoint));
|
| 357 |
+
});
|
| 358 |
+
|
| 359 |
+
groups.forEach((group, groupIndex) => {
|
| 360 |
+
const cy = rowCenters[groupIndex];
|
| 361 |
+
const y = cy - rowHeight / 2;
|
| 362 |
+
const points = grouped.get(group) || [];
|
| 363 |
+
const ramp = rowRamps[groupIndex % rowRamps.length];
|
| 364 |
+
const slotRadius = denseRows
|
| 365 |
+
? Math.max(14, Math.min(22, rowHeight * 0.28))
|
| 366 |
+
: Math.max(21, Math.min(38, rowHeight * 0.36));
|
| 367 |
+
const slotCy = denseRows ? cy : cy - (rowHeight > 68 ? 8 : 0);
|
| 368 |
+
|
| 369 |
+
if (groupIndex > 0) {
|
| 370 |
+
chart.append("line")
|
| 371 |
+
.attr("class", "bump11-row-separator")
|
| 372 |
+
.attr("x1", panelX + 26)
|
| 373 |
+
.attr("x2", panelX + panelW - 26)
|
| 374 |
+
.attr("y1", y - rowGap / 2)
|
| 375 |
+
.attr("y2", y - rowGap / 2)
|
| 376 |
+
.attr("stroke", "#d8eaf0")
|
| 377 |
+
.attr("stroke-width", 1.2);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
const rowSlot = chart.append("g")
|
| 381 |
+
.attr("class", "reserved-asset-slot bump11-row-city-slot")
|
| 382 |
+
.attr("data-asset-slot", "row-city-thumbnail")
|
| 383 |
+
.attr("data-slot-policy", "image2_asset_or_neutral_placeholder")
|
| 384 |
+
.attr("data-asset-source-policy", "image2_asset_or_neutral_placeholder")
|
| 385 |
+
.attr("data-anchor", "row-left-center")
|
| 386 |
+
.attr("data-bbox", `${labelColumnX - slotRadius},${slotCy - slotRadius},${slotRadius * 2},${slotRadius * 2}`)
|
| 387 |
+
.attr("data-mask-policy", "circle")
|
| 388 |
+
.attr("data-collision-rule", "stay inside row band, clear of group label and first marker column")
|
| 389 |
+
.attr("data-group", safeText(group));
|
| 390 |
+
rowSlot.append("circle")
|
| 391 |
+
.attr("cx", labelColumnX)
|
| 392 |
+
.attr("cy", slotCy)
|
| 393 |
+
.attr("r", slotRadius)
|
| 394 |
+
.attr("fill", ramp[Math.min(1, ramp.length - 1)])
|
| 395 |
+
.attr("opacity", 0.16);
|
| 396 |
+
rowSlot.append("circle")
|
| 397 |
+
.attr("cx", labelColumnX)
|
| 398 |
+
.attr("cy", slotCy)
|
| 399 |
+
.attr("r", slotRadius - 3)
|
| 400 |
+
.attr("fill", "#f5fbfd")
|
| 401 |
+
.attr("stroke", ramp[Math.min(2, ramp.length - 1)])
|
| 402 |
+
.attr("stroke-width", 1.5)
|
| 403 |
+
.attr("opacity", 0.9);
|
| 404 |
+
rowSlot.append("text")
|
| 405 |
+
.attr("x", labelColumnX)
|
| 406 |
+
.attr("y", slotCy + 6)
|
| 407 |
+
.attr("text-anchor", "middle")
|
| 408 |
+
.attr("font-family", labelFamily)
|
| 409 |
+
.attr("font-size", Math.max(12, Math.min(16, slotRadius * 0.43)))
|
| 410 |
+
.attr("font-weight", 800)
|
| 411 |
+
.attr("fill", ramp[Math.min(2, ramp.length - 1)])
|
| 412 |
+
.text(safeText(group).split(/\s+/).map(word => word[0]).join("").slice(0, 3).toUpperCase());
|
| 413 |
+
|
| 414 |
+
const groupFont = denseRows
|
| 415 |
+
? fitFont(group, markerLeft - (labelColumnX + slotRadius + 10) - 8, 12, labelFamily, 900, 8)
|
| 416 |
+
: fitFont(group, 130, rowHeight > 72 ? 20 : 14, labelFamily, 900, 9);
|
| 417 |
+
const groupText = chart.append("text")
|
| 418 |
+
.attr("class", "bump11-group-label")
|
| 419 |
+
.attr("x", denseRows ? labelColumnX + slotRadius + 10 : labelColumnX)
|
| 420 |
+
.attr("y", denseRows ? cy - groupFont * 0.35 : slotCy + slotRadius + Math.max(15, groupFont))
|
| 421 |
+
.attr("text-anchor", denseRows ? "start" : "middle")
|
| 422 |
+
.attr("font-family", labelFamily)
|
| 423 |
+
.attr("font-size", groupFont)
|
| 424 |
+
.attr("font-weight", 900)
|
| 425 |
+
.attr("fill", "#071d35");
|
| 426 |
+
if (denseRows) {
|
| 427 |
+
wrapText(groupText, group, markerLeft - (labelColumnX + slotRadius + 10) - 8, 2, Math.round(groupFont * 1.1), groupFont, labelFamily, 900);
|
| 428 |
+
} else {
|
| 429 |
+
groupText.text(safeText(group));
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
for (let i = 0; i < points.length - 1; i += 1) {
|
| 433 |
+
const currentIndex = timePoints.indexOf(points[i][timeField]);
|
| 434 |
+
const nextIndex = timePoints.indexOf(points[i + 1][timeField]);
|
| 435 |
+
if (currentIndex < 0 || nextIndex < 0) continue;
|
| 436 |
+
const x1 = markerCenters[currentIndex] + markerRadius * 0.78;
|
| 437 |
+
const x2 = markerCenters[nextIndex] - markerRadius * 0.78;
|
| 438 |
+
if (x2 <= x1) continue;
|
| 439 |
+
chart.append("line")
|
| 440 |
+
.attr("class", "bump11-connector")
|
| 441 |
+
.attr("data-group", safeText(group))
|
| 442 |
+
.attr("x1", x1)
|
| 443 |
+
.attr("x2", x2)
|
| 444 |
+
.attr("y1", cy)
|
| 445 |
+
.attr("y2", cy)
|
| 446 |
+
.attr("stroke", d3.interpolateRgb(ramp[1], ramp[ramp.length - 1])(0.45))
|
| 447 |
+
.attr("stroke-width", 2.4)
|
| 448 |
+
.attr("opacity", 0.88)
|
| 449 |
+
.attr("stroke-linecap", "round");
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
points.forEach(point => {
|
| 453 |
+
const timeIndex = timePoints.indexOf(point[timeField]);
|
| 454 |
+
if (timeIndex < 0) return;
|
| 455 |
+
const cx = markerCenters[timeIndex];
|
| 456 |
+
const value = +point[valueField];
|
| 457 |
+
const t = timePoints.length <= 1 ? 0 : timeIndex / (timePoints.length - 1);
|
| 458 |
+
const fill = d3.interpolateRgb(ramp[0], ramp[ramp.length - 1])(t);
|
| 459 |
+
const valueText = chartUtils.format.compactFixedText(Number.isFinite(value) ? value : 0);
|
| 460 |
+
const valueFont = fitFont(valueText, markerRadius * 1.42, 18, annotationFamily, 800, 10);
|
| 461 |
+
const dark = chartUtils.color.brightness(fill, { fallback: "#ffffff" }) < 0.55;
|
| 462 |
+
|
| 463 |
+
chart.append("path")
|
| 464 |
+
.attr("class", "bump11-value-hex")
|
| 465 |
+
.attr("data-group", safeText(group))
|
| 466 |
+
.attr("data-time", safeText(point[timeField]))
|
| 467 |
+
.attr("data-value", Number.isFinite(value) ? value : "")
|
| 468 |
+
.attr("d", hexagonPath(cx, cy, markerRadius))
|
| 469 |
+
.attr("fill", fill)
|
| 470 |
+
.attr("stroke", "#ffffff")
|
| 471 |
+
.attr("stroke-width", 1.6);
|
| 472 |
+
chart.append("text")
|
| 473 |
+
.attr("class", "bump11-value-label")
|
| 474 |
+
.attr("x", cx)
|
| 475 |
+
.attr("y", cy + valueFont * 0.34)
|
| 476 |
+
.attr("text-anchor", "middle")
|
| 477 |
+
.attr("font-family", annotationFamily)
|
| 478 |
+
.attr("font-size", valueFont)
|
| 479 |
+
.attr("font-weight", 800)
|
| 480 |
+
.attr("fill", dark ? "#ffffff" : "#0b1f2d")
|
| 481 |
+
.text(valueText);
|
| 482 |
+
});
|
| 483 |
+
});
|
| 484 |
+
|
| 485 |
+
const legendY = 905;
|
| 486 |
+
const legendX = 62;
|
| 487 |
+
chart.append("path")
|
| 488 |
+
.attr("class", "bump11-scale-legend-hex")
|
| 489 |
+
.attr("d", hexagonPath(legendX, legendY + 8, 22))
|
| 490 |
+
.attr("fill", "none")
|
| 491 |
+
.attr("stroke", "#0c6d81")
|
| 492 |
+
.attr("stroke-width", 2);
|
| 493 |
+
chart.append("text")
|
| 494 |
+
.attr("x", legendX)
|
| 495 |
+
.attr("y", legendY + 15)
|
| 496 |
+
.attr("text-anchor", "middle")
|
| 497 |
+
.attr("font-family", annotationFamily)
|
| 498 |
+
.attr("font-size", 16)
|
| 499 |
+
.attr("font-weight", 800)
|
| 500 |
+
.attr("fill", "#0c6d81")
|
| 501 |
+
.text("1K");
|
| 502 |
+
const legendText = chart.append("text")
|
| 503 |
+
.attr("class", "bump11-scale-legend-text")
|
| 504 |
+
.attr("x", legendX + 55)
|
| 505 |
+
.attr("y", legendY)
|
| 506 |
+
.attr("font-family", labelFamily)
|
| 507 |
+
.attr("font-size", 16)
|
| 508 |
+
.attr("font-weight", 400)
|
| 509 |
+
.attr("fill", "#33485d");
|
| 510 |
+
const valueColumn = ((jsonData.data && jsonData.data.columns) || []).find(column => column && column.name === valueField) || {};
|
| 511 |
+
const valueDescription = safeText(valueColumn.description || humanizeFieldName(valueField) || "values");
|
| 512 |
+
const valueUnit = safeText(valueColumn.unit || "").trim();
|
| 513 |
+
const normalizedDescription = valueDescription.toLowerCase();
|
| 514 |
+
const normalizedUnit = valueUnit.toLowerCase();
|
| 515 |
+
const descriptionIncludesUnit = valueUnit && (
|
| 516 |
+
normalizedDescription.includes(`(${normalizedUnit})`) ||
|
| 517 |
+
normalizedDescription.includes(normalizedUnit)
|
| 518 |
+
);
|
| 519 |
+
const legendLabel = valueUnit && !descriptionIncludesUnit
|
| 520 |
+
? `Values represent ${valueDescription} (${valueUnit})`
|
| 521 |
+
: `Values represent ${valueDescription}`;
|
| 522 |
+
const descriptionWithoutParenthetical = valueDescription.replace(/\s*\([^)]*\)\s*$/, "").trim() || valueDescription;
|
| 523 |
+
const compactField = humanizeFieldName(valueField) || "value";
|
| 524 |
+
const compactWithUnit = valueUnit && !compactField.toLowerCase().includes(normalizedUnit)
|
| 525 |
+
? `${compactField} (${valueUnit})`
|
| 526 |
+
: compactField;
|
| 527 |
+
const legendFit = chooseFittingText([
|
| 528 |
+
legendLabel,
|
| 529 |
+
`Values represent ${descriptionWithoutParenthetical}`,
|
| 530 |
+
`Values represent ${compactWithUnit}`,
|
| 531 |
+
`Values: ${compactWithUnit}`,
|
| 532 |
+
"Values"
|
| 533 |
+
], 330, 3, 16, 11, labelFamily, 400);
|
| 534 |
+
legendText
|
| 535 |
+
.attr("font-size", legendFit.fontSize)
|
| 536 |
+
.attr("data-legend-text-policy", "fit-no-ellipsis")
|
| 537 |
+
.attr("data-legend-source-field", safeText(valueField));
|
| 538 |
+
setWrappedLines(legendText, legendFit.lines, legendFit.lineHeight);
|
| 539 |
+
|
| 540 |
+
return svg.node();
|
| 541 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/bump_chart_12.js
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Sorted Lines Chart",
|
| 5 |
+
"chart_name": "bump_chart_12",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"chart_for": "comparison",
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [
|
| 11 |
+
[2, 12],
|
| 12 |
+
[0, "inf"],
|
| 13 |
+
[4, 10]
|
| 14 |
+
],
|
| 15 |
+
"required_fields_icons": ["group"],
|
| 16 |
+
"required_other_icons": [],
|
| 17 |
+
"required_fields_colors": ["group"],
|
| 18 |
+
"required_other_colors": [],
|
| 19 |
+
"supported_effects": ["spacing"],
|
| 20 |
+
"min_height": 400,
|
| 21 |
+
"min_width": 400,
|
| 22 |
+
"background": "no",
|
| 23 |
+
"icon_mark": "none",
|
| 24 |
+
"icon_label": "side",
|
| 25 |
+
"has_x_axis": "no",
|
| 26 |
+
"has_y_axis": "no"
|
| 27 |
+
}
|
| 28 |
+
REQUIREMENTS_END
|
| 29 |
+
*/
|
| 30 |
+
|
| 31 |
+
function makeChart(containerSelector, data) {
|
| 32 |
+
const jsonData = data;
|
| 33 |
+
const chartData = (jsonData.data && jsonData.data.data) || [];
|
| 34 |
+
const variables = jsonData.variables || {};
|
| 35 |
+
const typography = jsonData.typography || {};
|
| 36 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 37 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 38 |
+
|
| 39 |
+
d3.select(containerSelector).html("");
|
| 40 |
+
|
| 41 |
+
const width = Math.max(620, variables.width || 650);
|
| 42 |
+
const height = Math.max(920, variables.height ? Math.round(variables.height * 1.62) : 975);
|
| 43 |
+
const timeField = chartUtils.schema.channel(jsonData, "x").key;
|
| 44 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key;
|
| 45 |
+
const groupField = chartUtils.schema.channel(jsonData, "group").key;
|
| 46 |
+
|
| 47 |
+
function safeText(value) {
|
| 48 |
+
return value == null ? "" : String(value);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function measureText(text, fontSize, fontFamily, fontWeight) {
|
| 52 |
+
return chartUtils.text.measure(null, safeText(text), {
|
| 53 |
+
fontFamily,
|
| 54 |
+
fontSize,
|
| 55 |
+
fontWeight
|
| 56 |
+
}).width;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function fitFont(text, maxWidth, start, family, weight, minSize) {
|
| 60 |
+
let size = start;
|
| 61 |
+
while (size > minSize && measureText(text, size, family, weight) > maxWidth) {
|
| 62 |
+
size -= 1;
|
| 63 |
+
}
|
| 64 |
+
return size;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function computeWrappedLines(text, maxWidth, fontSize, family, weight) {
|
| 68 |
+
const words = safeText(text).split(/\s+/).filter(Boolean);
|
| 69 |
+
const lines = [];
|
| 70 |
+
let line = "";
|
| 71 |
+
words.forEach(word => {
|
| 72 |
+
const candidate = line ? `${line} ${word}` : word;
|
| 73 |
+
if (measureText(candidate, fontSize, family, weight) <= maxWidth || !line) {
|
| 74 |
+
line = candidate;
|
| 75 |
+
} else {
|
| 76 |
+
lines.push(line);
|
| 77 |
+
line = word;
|
| 78 |
+
}
|
| 79 |
+
});
|
| 80 |
+
if (line) lines.push(line);
|
| 81 |
+
return lines;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function wrapText(selection, text, maxWidth, maxLines, lineHeight, fontSize, family, weight) {
|
| 85 |
+
const lines = computeWrappedLines(text, maxWidth, fontSize, family, weight);
|
| 86 |
+
const visible = lines.slice(0, maxLines);
|
| 87 |
+
if (lines.length > maxLines && visible.length) {
|
| 88 |
+
let last = visible[visible.length - 1];
|
| 89 |
+
while (last.length > 1 && measureText(`${last}...`, fontSize, family, weight) > maxWidth) {
|
| 90 |
+
last = last.slice(0, -1);
|
| 91 |
+
}
|
| 92 |
+
visible[visible.length - 1] = `${last}...`;
|
| 93 |
+
}
|
| 94 |
+
selection.selectAll("tspan")
|
| 95 |
+
.data(visible)
|
| 96 |
+
.enter()
|
| 97 |
+
.append("tspan")
|
| 98 |
+
.attr("x", selection.attr("x"))
|
| 99 |
+
.attr("dy", (d, i) => i === 0 ? 0 : lineHeight)
|
| 100 |
+
.text(d => d);
|
| 101 |
+
return visible.length;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function parseTimeValue(value) {
|
| 105 |
+
const parsed = chartUtils.format.parseDate(value);
|
| 106 |
+
if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed.getTime();
|
| 107 |
+
const numeric = +value;
|
| 108 |
+
return Number.isFinite(numeric) ? numeric : safeText(value);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function titleCaseWords(text) {
|
| 112 |
+
return safeText(text)
|
| 113 |
+
.replace(/[_-]+/g, " ")
|
| 114 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 115 |
+
.replace(/\s+/g, " ")
|
| 116 |
+
.trim();
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function deriveHeaderParts(title) {
|
| 120 |
+
const clean = titleCaseWords(title);
|
| 121 |
+
const match = clean.match(/\s+(for|in|across|among)\s+(.+)$/i);
|
| 122 |
+
if (match && match.index > 8) {
|
| 123 |
+
return {
|
| 124 |
+
headline: clean.slice(0, match.index).trim(),
|
| 125 |
+
ribbon: `${match[1]} ${match[2]}`.trim()
|
| 126 |
+
};
|
| 127 |
+
}
|
| 128 |
+
return {
|
| 129 |
+
headline: clean,
|
| 130 |
+
ribbon: groupDescriptor ? `for ${groupDescriptor}` : "by group"
|
| 131 |
+
};
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function initials(text) {
|
| 135 |
+
const parts = titleCaseWords(text).split(/\s+/).filter(Boolean);
|
| 136 |
+
return (parts.length ? parts.map(word => word[0]).join("") : safeText(text).slice(0, 2))
|
| 137 |
+
.slice(0, 3)
|
| 138 |
+
.toUpperCase();
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function compactValue(value) {
|
| 142 |
+
const numeric = +value;
|
| 143 |
+
if (!Number.isFinite(numeric)) return safeText(value);
|
| 144 |
+
const column = dataColumns.find(col => col && col.name === valueField) || {};
|
| 145 |
+
const unit = safeText(column.unit || "").trim();
|
| 146 |
+
const rounded = Math.abs(numeric) >= 100
|
| 147 |
+
? chartUtils.format.compactFixedText(numeric)
|
| 148 |
+
: chartUtils.format.compactFixedText(numeric).replace(/\\.0$/, "");
|
| 149 |
+
if (unit === "%" && !rounded.includes("%")) return `${rounded}%`;
|
| 150 |
+
return rounded;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const titleFamily = (typography.title && typography.title.font_family) || "Arial, Helvetica, sans-serif";
|
| 154 |
+
const labelFamily = (typography.label && typography.label.font_family) || "Arial, Helvetica, sans-serif";
|
| 155 |
+
const annotationFamily = (typography.annotation && typography.annotation.font_family) || "Arial, Helvetica, sans-serif";
|
| 156 |
+
const rawTitle = safeText((jsonData.titles && jsonData.titles.main_title) || (jsonData.metadata && jsonData.metadata.title) || "Increased Graduation Rates");
|
| 157 |
+
const subtitleText = safeText((jsonData.titles && jsonData.titles.sub_title) || (jsonData.metadata && jsonData.metadata.description) || jsonData.description || "");
|
| 158 |
+
const groupDescriptor = titleCaseWords(groupField).replace(/s$/i, "");
|
| 159 |
+
const headerParts = deriveHeaderParts(rawTitle);
|
| 160 |
+
|
| 161 |
+
const timePoints = [...new Set(chartData.map(d => d[timeField]))]
|
| 162 |
+
.sort((a, b) => {
|
| 163 |
+
const pa = parseTimeValue(a);
|
| 164 |
+
const pb = parseTimeValue(b);
|
| 165 |
+
if (typeof pa === "number" && typeof pb === "number") return pa - pb;
|
| 166 |
+
return safeText(a).localeCompare(safeText(b));
|
| 167 |
+
});
|
| 168 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 169 |
+
const grouped = new Map(groups.map(group => [
|
| 170 |
+
group,
|
| 171 |
+
chartData.filter(d => d[groupField] === group)
|
| 172 |
+
.sort((a, b) => timePoints.indexOf(a[timeField]) - timePoints.indexOf(b[timeField]))
|
| 173 |
+
]));
|
| 174 |
+
|
| 175 |
+
const fallbackPalette = [
|
| 176 |
+
"#57a80a", "#eb2d80", "#1261bd", "#f46610", "#16a79c",
|
| 177 |
+
"#7b62d1", "#d79b19", "#00875f", "#e34f4f", "#4b83d8"
|
| 178 |
+
];
|
| 179 |
+
const colorScale = colorResolver.scale(groups, { palette: "category10" });
|
| 180 |
+
|
| 181 |
+
function rowColor(group, index) {
|
| 182 |
+
return colorScale(group) || fallbackPalette[index % fallbackPalette.length];
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
const svg = d3.select(containerSelector)
|
| 186 |
+
.append("svg")
|
| 187 |
+
.attr("width", "100%")
|
| 188 |
+
.attr("height", height)
|
| 189 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 190 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 191 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 192 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 193 |
+
|
| 194 |
+
const defs = svg.append("defs");
|
| 195 |
+
const bg = defs.append("radialGradient")
|
| 196 |
+
.attr("id", "bump12-bg")
|
| 197 |
+
.attr("cx", "50%")
|
| 198 |
+
.attr("cy", "18%")
|
| 199 |
+
.attr("r", "86%");
|
| 200 |
+
bg.append("stop").attr("offset", "0%").attr("stop-color", "#ffffff");
|
| 201 |
+
bg.append("stop").attr("offset", "58%").attr("stop-color", "#f2f8fc");
|
| 202 |
+
bg.append("stop").attr("offset", "100%").attr("stop-color", "#e4f1f8");
|
| 203 |
+
|
| 204 |
+
const railGrad = defs.append("linearGradient")
|
| 205 |
+
.attr("id", "bump12-rail")
|
| 206 |
+
.attr("x1", "0%")
|
| 207 |
+
.attr("x2", "100%")
|
| 208 |
+
.attr("y1", "0%")
|
| 209 |
+
.attr("y2", "0%");
|
| 210 |
+
railGrad.append("stop").attr("offset", "0%").attr("stop-color", "#061b45");
|
| 211 |
+
railGrad.append("stop").attr("offset", "54%").attr("stop-color", "#073b7a");
|
| 212 |
+
railGrad.append("stop").attr("offset", "100%").attr("stop-color", "#061b45");
|
| 213 |
+
|
| 214 |
+
defs.append("filter")
|
| 215 |
+
.attr("id", "bump12-card-shadow")
|
| 216 |
+
.attr("x", "-8%")
|
| 217 |
+
.attr("y", "-18%")
|
| 218 |
+
.attr("width", "116%")
|
| 219 |
+
.attr("height", "140%")
|
| 220 |
+
.html('<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#8baebe" flood-opacity="0.22"/>');
|
| 221 |
+
|
| 222 |
+
defs.append("filter")
|
| 223 |
+
.attr("id", "bump12-marker-shadow")
|
| 224 |
+
.attr("x", "-30%")
|
| 225 |
+
.attr("y", "-30%")
|
| 226 |
+
.attr("width", "160%")
|
| 227 |
+
.attr("height", "160%")
|
| 228 |
+
.html('<feDropShadow dx="0" dy="3" stdDeviation="3" flood-color="#496b80" flood-opacity="0.25"/>');
|
| 229 |
+
|
| 230 |
+
svg.append("rect")
|
| 231 |
+
.attr("width", width)
|
| 232 |
+
.attr("height", height)
|
| 233 |
+
.attr("fill", "url(#bump12-bg)");
|
| 234 |
+
|
| 235 |
+
const headerArt = svg.append("g")
|
| 236 |
+
.attr("class", "reserved-asset-slot bump12-header-art-slot")
|
| 237 |
+
.attr("data-asset-slot", "header-graduation-art")
|
| 238 |
+
.attr("data-slot-policy", "image2_asset")
|
| 239 |
+
.attr("data-asset-source-policy", "image2_asset")
|
| 240 |
+
.attr("data-anchor", "header-safe-area")
|
| 241 |
+
.attr("data-bbox", `${Math.round(width * 0.02)},${Math.round(height * 0.02)},${Math.round(width * 0.96)},${Math.round(height * 0.25)}`)
|
| 242 |
+
.attr("data-mask-policy", "soft clipped header bbox")
|
| 243 |
+
.attr("data-collision-rule", "clear title, ribbon, subtitle, year rail, and row cards");
|
| 244 |
+
headerArt.append("path")
|
| 245 |
+
.attr("d", `M${width * 0.04},${height * 0.08} L${width * 0.18},${height * 0.045} L${width * 0.30},${height * 0.08} L${width * 0.18},${height * 0.125} Z`)
|
| 246 |
+
.attr("fill", "#9bc4e3")
|
| 247 |
+
.attr("opacity", 0.28);
|
| 248 |
+
headerArt.append("circle")
|
| 249 |
+
.attr("cx", width * 0.90)
|
| 250 |
+
.attr("cy", height * 0.10)
|
| 251 |
+
.attr("r", 44)
|
| 252 |
+
.attr("fill", "#9fc8e5")
|
| 253 |
+
.attr("opacity", 0.18);
|
| 254 |
+
headerArt.append("circle")
|
| 255 |
+
.attr("cx", width * 0.94)
|
| 256 |
+
.attr("cy", height * 0.18)
|
| 257 |
+
.attr("r", 30)
|
| 258 |
+
.attr("fill", "#82b8dd")
|
| 259 |
+
.attr("opacity", 0.12);
|
| 260 |
+
[[0.06, 0.19], [0.12, 0.16], [0.78, 0.09], [0.84, 0.16], [0.96, 0.035]].forEach((pt, i) => {
|
| 261 |
+
headerArt.append("path")
|
| 262 |
+
.attr("d", d3.symbol().type(i % 2 ? d3.symbolCircle : d3.symbolDiamond).size(i % 2 ? 22 : 46)())
|
| 263 |
+
.attr("transform", `translate(${width * pt[0]},${height * pt[1]})`)
|
| 264 |
+
.attr("fill", "#6aa3d2")
|
| 265 |
+
.attr("opacity", 0.24);
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
const titleMaxWidth = width * 0.84;
|
| 269 |
+
let titleFont = Math.min(
|
| 270 |
+
fitFont(headerParts.headline.toUpperCase(), titleMaxWidth, 58, titleFamily, 900, 32),
|
| 271 |
+
fitFont(headerParts.headline.split(/\s+/).reduce((a, b) => a.length > b.length ? a : b, "").toUpperCase(), titleMaxWidth, 58, titleFamily, 900, 32)
|
| 272 |
+
);
|
| 273 |
+
let titleLines = computeWrappedLines(headerParts.headline.toUpperCase(), titleMaxWidth, titleFont, titleFamily, 900);
|
| 274 |
+
while (titleFont > 25 && titleLines.length > 3) {
|
| 275 |
+
titleFont -= 1;
|
| 276 |
+
titleLines = computeWrappedLines(headerParts.headline.toUpperCase(), titleMaxWidth, titleFont, titleFamily, 900);
|
| 277 |
+
}
|
| 278 |
+
if (titleLines.length > 3) {
|
| 279 |
+
titleLines = titleLines.slice(0, 3);
|
| 280 |
+
let last = titleLines[2];
|
| 281 |
+
while (last.length > 1 && measureText(`${last}...`, titleFont, titleFamily, 900) > titleMaxWidth) {
|
| 282 |
+
last = last.slice(0, -1);
|
| 283 |
+
}
|
| 284 |
+
titleLines[2] = `${last}...`;
|
| 285 |
+
}
|
| 286 |
+
const title = svg.append("text")
|
| 287 |
+
.attr("class", "bump12-title")
|
| 288 |
+
.attr("x", width / 2)
|
| 289 |
+
.attr("y", 72)
|
| 290 |
+
.attr("text-anchor", "middle")
|
| 291 |
+
.attr("font-family", titleFamily)
|
| 292 |
+
.attr("font-size", titleFont)
|
| 293 |
+
.attr("font-weight", 900)
|
| 294 |
+
.attr("letter-spacing", "0")
|
| 295 |
+
.attr("fill", "#061b45");
|
| 296 |
+
title.selectAll("tspan")
|
| 297 |
+
.data(titleLines)
|
| 298 |
+
.enter()
|
| 299 |
+
.append("tspan")
|
| 300 |
+
.attr("x", width / 2)
|
| 301 |
+
.attr("dy", (d, i) => i === 0 ? 0 : Math.round(titleFont * 1.02))
|
| 302 |
+
.text(d => d);
|
| 303 |
+
|
| 304 |
+
const titleBottom = 72 + Math.max(1, titleLines.length - 1) * Math.round(titleFont * 1.02) + titleFont * 0.24;
|
| 305 |
+
const ribbonY = Math.max(172, titleBottom + 34);
|
| 306 |
+
const ribbonW = Math.min(420, width * 0.66);
|
| 307 |
+
const ribbonH = 38;
|
| 308 |
+
const ribbonX = (width - ribbonW) / 2;
|
| 309 |
+
svg.append("path")
|
| 310 |
+
.attr("class", "bump12-title-ribbon")
|
| 311 |
+
.attr("d", [
|
| 312 |
+
`M${ribbonX + 18},${ribbonY}`,
|
| 313 |
+
`L${ribbonX + ribbonW - 18},${ribbonY}`,
|
| 314 |
+
`L${ribbonX + ribbonW},${ribbonY + ribbonH / 2}`,
|
| 315 |
+
`L${ribbonX + ribbonW - 18},${ribbonY + ribbonH}`,
|
| 316 |
+
`L${ribbonX + 18},${ribbonY + ribbonH}`,
|
| 317 |
+
`L${ribbonX},${ribbonY + ribbonH / 2}`,
|
| 318 |
+
"Z"
|
| 319 |
+
].join(" "))
|
| 320 |
+
.attr("fill", "#0b63c8");
|
| 321 |
+
svg.append("line")
|
| 322 |
+
.attr("x1", ribbonX - 46)
|
| 323 |
+
.attr("x2", ribbonX - 10)
|
| 324 |
+
.attr("y1", ribbonY + ribbonH / 2)
|
| 325 |
+
.attr("y2", ribbonY + ribbonH / 2)
|
| 326 |
+
.attr("stroke", "#0b63c8")
|
| 327 |
+
.attr("stroke-width", 3)
|
| 328 |
+
.attr("stroke-linecap", "round");
|
| 329 |
+
svg.append("line")
|
| 330 |
+
.attr("x1", ribbonX + ribbonW + 10)
|
| 331 |
+
.attr("x2", ribbonX + ribbonW + 46)
|
| 332 |
+
.attr("y1", ribbonY + ribbonH / 2)
|
| 333 |
+
.attr("y2", ribbonY + ribbonH / 2)
|
| 334 |
+
.attr("stroke", "#0b63c8")
|
| 335 |
+
.attr("stroke-width", 3)
|
| 336 |
+
.attr("stroke-linecap", "round");
|
| 337 |
+
|
| 338 |
+
const ribbonText = headerParts.ribbon.toUpperCase();
|
| 339 |
+
svg.append("text")
|
| 340 |
+
.attr("class", "bump12-ribbon-text")
|
| 341 |
+
.attr("x", width / 2)
|
| 342 |
+
.attr("y", ribbonY + ribbonH / 2 + 8)
|
| 343 |
+
.attr("text-anchor", "middle")
|
| 344 |
+
.attr("font-family", labelFamily)
|
| 345 |
+
.attr("font-size", fitFont(ribbonText, ribbonW - 50, 22, labelFamily, 900, 12))
|
| 346 |
+
.attr("font-weight", 900)
|
| 347 |
+
.attr("fill", "#ffffff")
|
| 348 |
+
.text(ribbonText);
|
| 349 |
+
|
| 350 |
+
const subtitle = svg.append("text")
|
| 351 |
+
.attr("class", "bump12-subtitle")
|
| 352 |
+
.attr("x", width / 2)
|
| 353 |
+
.attr("y", ribbonY + ribbonH + 34)
|
| 354 |
+
.attr("text-anchor", "middle")
|
| 355 |
+
.attr("font-family", labelFamily)
|
| 356 |
+
.attr("font-size", 18)
|
| 357 |
+
.attr("font-weight", 400)
|
| 358 |
+
.attr("fill", "#304254");
|
| 359 |
+
wrapText(subtitle, subtitleText, width * 0.58, 2, 25, 18, labelFamily, 400);
|
| 360 |
+
|
| 361 |
+
const railX = 43;
|
| 362 |
+
const railY = ribbonY + ribbonH + 96;
|
| 363 |
+
const railW = width - railX * 2;
|
| 364 |
+
const railH = 42;
|
| 365 |
+
const rowAreaTop = railY + railH + 28;
|
| 366 |
+
const rowAreaBottom = Math.min(height - 58, rowAreaTop + Math.max(410, height - rowAreaTop - 76));
|
| 367 |
+
const rowGap = groups.length > 6 ? 12 : 16;
|
| 368 |
+
const rowHeight = Math.max(48, Math.min(88, (rowAreaBottom - rowAreaTop - rowGap * Math.max(groups.length - 1, 0)) / Math.max(groups.length, 1)));
|
| 369 |
+
const rowCenters = groups.map((group, i) => rowAreaTop + rowHeight / 2 + i * (rowHeight + rowGap));
|
| 370 |
+
const markerLeft = railX + 230;
|
| 371 |
+
const markerRight = railX + railW - 68;
|
| 372 |
+
const markerCenters = timePoints.map((timePoint, i) => {
|
| 373 |
+
if (timePoints.length <= 1) return (markerLeft + markerRight) / 2;
|
| 374 |
+
return markerLeft + i * ((markerRight - markerLeft) / (timePoints.length - 1));
|
| 375 |
+
});
|
| 376 |
+
const timeGap = timePoints.length > 1 ? (markerRight - markerLeft) / (timePoints.length - 1) : 120;
|
| 377 |
+
const markerRadius = Math.max(18, Math.min(30, rowHeight * 0.34, timeGap * 0.22));
|
| 378 |
+
|
| 379 |
+
svg.append("rect")
|
| 380 |
+
.attr("class", "bump12-year-rail")
|
| 381 |
+
.attr("x", railX)
|
| 382 |
+
.attr("y", railY)
|
| 383 |
+
.attr("width", railW)
|
| 384 |
+
.attr("height", railH)
|
| 385 |
+
.attr("rx", 10)
|
| 386 |
+
.attr("fill", "url(#bump12-rail)")
|
| 387 |
+
.attr("filter", "url(#bump12-marker-shadow)");
|
| 388 |
+
svg.append("text")
|
| 389 |
+
.attr("x", railX + 58)
|
| 390 |
+
.attr("y", railY + railH / 2 + 7)
|
| 391 |
+
.attr("text-anchor", "middle")
|
| 392 |
+
.attr("font-family", labelFamily)
|
| 393 |
+
.attr("font-size", 16)
|
| 394 |
+
.attr("font-weight", 900)
|
| 395 |
+
.attr("fill", "#ffffff")
|
| 396 |
+
.text(safeText(timeField).toUpperCase());
|
| 397 |
+
|
| 398 |
+
const widestYear = timePoints.reduce((a, b) => safeText(a).length > safeText(b).length ? a : b, "");
|
| 399 |
+
const yearFont = fitFont(widestYear, Math.max(34, timeGap - 12), 18, labelFamily, 900, 11);
|
| 400 |
+
timePoints.forEach((timePoint, i) => {
|
| 401 |
+
svg.append("text")
|
| 402 |
+
.attr("class", "bump12-year-label")
|
| 403 |
+
.attr("x", markerCenters[i])
|
| 404 |
+
.attr("y", railY + railH / 2 + 7)
|
| 405 |
+
.attr("text-anchor", "middle")
|
| 406 |
+
.attr("font-family", labelFamily)
|
| 407 |
+
.attr("font-size", yearFont)
|
| 408 |
+
.attr("font-weight", 900)
|
| 409 |
+
.attr("fill", "#ffffff")
|
| 410 |
+
.text(safeText(timePoint));
|
| 411 |
+
});
|
| 412 |
+
|
| 413 |
+
const guideLayer = svg.append("g").attr("class", "bump12-guide-layer");
|
| 414 |
+
markerCenters.forEach((x, i) => {
|
| 415 |
+
guideLayer.append("line")
|
| 416 |
+
.attr("class", "bump12-year-guide")
|
| 417 |
+
.attr("data-time", safeText(timePoints[i]))
|
| 418 |
+
.attr("x1", x)
|
| 419 |
+
.attr("x2", x)
|
| 420 |
+
.attr("y1", railY + railH)
|
| 421 |
+
.attr("y2", rowCenters[rowCenters.length - 1] + rowHeight / 2 - 3)
|
| 422 |
+
.attr("stroke", "#8aa7bf")
|
| 423 |
+
.attr("stroke-width", 1.4)
|
| 424 |
+
.attr("stroke-dasharray", "2 7")
|
| 425 |
+
.attr("stroke-linecap", "round")
|
| 426 |
+
.attr("opacity", 0.72);
|
| 427 |
+
});
|
| 428 |
+
|
| 429 |
+
const chart = svg.append("g").attr("class", "bump12-chart");
|
| 430 |
+
groups.forEach((group, groupIndex) => {
|
| 431 |
+
const color = rowColor(group, groupIndex);
|
| 432 |
+
const cy = rowCenters[groupIndex];
|
| 433 |
+
const y = cy - rowHeight / 2;
|
| 434 |
+
const points = grouped.get(group) || [];
|
| 435 |
+
const card = chart.append("g")
|
| 436 |
+
.attr("class", "bump12-row")
|
| 437 |
+
.attr("data-group", safeText(group));
|
| 438 |
+
|
| 439 |
+
card.append("rect")
|
| 440 |
+
.attr("class", "bump12-row-card")
|
| 441 |
+
.attr("x", railX)
|
| 442 |
+
.attr("y", y)
|
| 443 |
+
.attr("width", railW)
|
| 444 |
+
.attr("height", rowHeight)
|
| 445 |
+
.attr("rx", 12)
|
| 446 |
+
.attr("fill", "#ffffff")
|
| 447 |
+
.attr("stroke", "#dce9f1")
|
| 448 |
+
.attr("stroke-width", 1)
|
| 449 |
+
.attr("filter", "url(#bump12-card-shadow)");
|
| 450 |
+
card.append("path")
|
| 451 |
+
.attr("class", "bump12-row-accent-strip")
|
| 452 |
+
.attr("d", `M${railX},${y + 12} Q${railX},${y} ${railX + 12},${y} L${railX + 12},${y + rowHeight} Q${railX},${y + rowHeight} ${railX},${y + rowHeight - 12} Z`)
|
| 453 |
+
.attr("fill", color);
|
| 454 |
+
|
| 455 |
+
const slotR = Math.max(18, Math.min(31, rowHeight * 0.34));
|
| 456 |
+
const slotX = railX + 56;
|
| 457 |
+
const iconSlot = card.append("g")
|
| 458 |
+
.attr("class", "reserved-asset-slot bump12-row-icon-slot")
|
| 459 |
+
.attr("data-asset-slot", "row-group-icon")
|
| 460 |
+
.attr("data-slot-policy", "input_asset_or_image2_asset_or_neutral_placeholder")
|
| 461 |
+
.attr("data-asset-source-policy", "input_asset_or_image2_asset_or_neutral_placeholder")
|
| 462 |
+
.attr("data-anchor", "row-left-center")
|
| 463 |
+
.attr("data-bbox", `${Math.round(slotX - slotR)},${Math.round(cy - slotR)},${Math.round(slotR * 2)},${Math.round(slotR * 2)}`)
|
| 464 |
+
.attr("data-mask-policy", "circle")
|
| 465 |
+
.attr("data-collision-rule", "inside row card; clear group label and first marker column")
|
| 466 |
+
.attr("data-group", safeText(group));
|
| 467 |
+
iconSlot.append("circle")
|
| 468 |
+
.attr("cx", slotX)
|
| 469 |
+
.attr("cy", cy)
|
| 470 |
+
.attr("r", slotR)
|
| 471 |
+
.attr("fill", "#f8fcff")
|
| 472 |
+
.attr("stroke", "#d3e5ee")
|
| 473 |
+
.attr("stroke-width", 1.4);
|
| 474 |
+
iconSlot.append("circle")
|
| 475 |
+
.attr("cx", slotX)
|
| 476 |
+
.attr("cy", cy)
|
| 477 |
+
.attr("r", slotR - 7)
|
| 478 |
+
.attr("fill", color)
|
| 479 |
+
.attr("opacity", 0.16);
|
| 480 |
+
iconSlot.append("text")
|
| 481 |
+
.attr("x", slotX)
|
| 482 |
+
.attr("y", cy + Math.max(5, slotR * 0.18))
|
| 483 |
+
.attr("text-anchor", "middle")
|
| 484 |
+
.attr("font-family", labelFamily)
|
| 485 |
+
.attr("font-size", Math.max(9, Math.min(14, slotR * 0.48)))
|
| 486 |
+
.attr("font-weight", 900)
|
| 487 |
+
.attr("fill", color)
|
| 488 |
+
.text(initials(group));
|
| 489 |
+
|
| 490 |
+
const labelX = slotX + slotR + 16;
|
| 491 |
+
const labelMax = Math.max(80, markerLeft - labelX - markerRadius - 12);
|
| 492 |
+
const groupFont = fitFont(safeText(group).toUpperCase(), labelMax, rowHeight > 62 ? 20 : 15, labelFamily, 900, 9);
|
| 493 |
+
const groupLabel = card.append("text")
|
| 494 |
+
.attr("class", "bump12-group-label")
|
| 495 |
+
.attr("x", labelX)
|
| 496 |
+
.attr("y", cy + groupFont * 0.34)
|
| 497 |
+
.attr("font-family", labelFamily)
|
| 498 |
+
.attr("font-size", groupFont)
|
| 499 |
+
.attr("font-weight", 900)
|
| 500 |
+
.attr("letter-spacing", "0")
|
| 501 |
+
.attr("fill", "#061b45");
|
| 502 |
+
if (measureText(safeText(group).toUpperCase(), groupFont, labelFamily, 900) > labelMax) {
|
| 503 |
+
groupLabel.attr("y", cy - groupFont * 0.18);
|
| 504 |
+
wrapText(groupLabel, safeText(group).toUpperCase(), labelMax, 2, Math.round(groupFont * 1.05), groupFont, labelFamily, 900);
|
| 505 |
+
} else {
|
| 506 |
+
groupLabel.text(safeText(group).toUpperCase());
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
for (let i = 0; i < points.length - 1; i += 1) {
|
| 510 |
+
const currentIndex = timePoints.indexOf(points[i][timeField]);
|
| 511 |
+
const nextIndex = timePoints.indexOf(points[i + 1][timeField]);
|
| 512 |
+
if (currentIndex < 0 || nextIndex < 0) continue;
|
| 513 |
+
const x1 = markerCenters[currentIndex] + markerRadius - 1;
|
| 514 |
+
const x2 = markerCenters[nextIndex] - markerRadius + 1;
|
| 515 |
+
if (x2 <= x1) continue;
|
| 516 |
+
card.append("line")
|
| 517 |
+
.attr("class", "bump12-connector")
|
| 518 |
+
.attr("data-group", safeText(group))
|
| 519 |
+
.attr("x1", x1)
|
| 520 |
+
.attr("x2", x2)
|
| 521 |
+
.attr("y1", cy)
|
| 522 |
+
.attr("y2", cy)
|
| 523 |
+
.attr("stroke", color)
|
| 524 |
+
.attr("stroke-width", 3.2)
|
| 525 |
+
.attr("opacity", 0.88)
|
| 526 |
+
.attr("stroke-linecap", "round");
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
points.forEach(point => {
|
| 530 |
+
const timeIndex = timePoints.indexOf(point[timeField]);
|
| 531 |
+
if (timeIndex < 0) return;
|
| 532 |
+
const cx = markerCenters[timeIndex];
|
| 533 |
+
const valueText = compactValue(point[valueField]);
|
| 534 |
+
const valueFont = fitFont(valueText, markerRadius * 1.55, 18, annotationFamily, 900, 9);
|
| 535 |
+
card.append("circle")
|
| 536 |
+
.attr("class", "bump12-value-marker")
|
| 537 |
+
.attr("data-group", safeText(group))
|
| 538 |
+
.attr("data-time", safeText(point[timeField]))
|
| 539 |
+
.attr("data-value", Number.isFinite(+point[valueField]) ? +point[valueField] : "")
|
| 540 |
+
.attr("cx", cx)
|
| 541 |
+
.attr("cy", cy)
|
| 542 |
+
.attr("r", markerRadius)
|
| 543 |
+
.attr("fill", "#f8fcfb")
|
| 544 |
+
.attr("stroke", color)
|
| 545 |
+
.attr("stroke-width", 2.4)
|
| 546 |
+
.attr("filter", "url(#bump12-marker-shadow)");
|
| 547 |
+
card.append("text")
|
| 548 |
+
.attr("class", "bump12-value-label")
|
| 549 |
+
.attr("x", cx)
|
| 550 |
+
.attr("y", cy + valueFont * 0.34)
|
| 551 |
+
.attr("text-anchor", "middle")
|
| 552 |
+
.attr("font-family", annotationFamily)
|
| 553 |
+
.attr("font-size", valueFont)
|
| 554 |
+
.attr("font-weight", 900)
|
| 555 |
+
.attr("fill", color)
|
| 556 |
+
.text(valueText);
|
| 557 |
+
});
|
| 558 |
+
});
|
| 559 |
+
|
| 560 |
+
return svg.node();
|
| 561 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/bump_chart_13.js
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Sorted Lines Chart",
|
| 5 |
+
"chart_name": "bump_chart_13",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"chart_for": "comparison",
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [
|
| 11 |
+
[2, 12],
|
| 12 |
+
[0, "inf"],
|
| 13 |
+
[4, 10]
|
| 14 |
+
],
|
| 15 |
+
"required_fields_icons": [],
|
| 16 |
+
"required_other_icons": [],
|
| 17 |
+
"required_fields_colors": ["group"],
|
| 18 |
+
"required_other_colors": [],
|
| 19 |
+
"supported_effects": ["spacing"],
|
| 20 |
+
"min_height": 400,
|
| 21 |
+
"min_width": 400,
|
| 22 |
+
"background": "no",
|
| 23 |
+
"icon_mark": "none",
|
| 24 |
+
"icon_label": "side",
|
| 25 |
+
"has_x_axis": "no",
|
| 26 |
+
"has_y_axis": "no"
|
| 27 |
+
}
|
| 28 |
+
REQUIREMENTS_END
|
| 29 |
+
*/
|
| 30 |
+
|
| 31 |
+
function makeChart(containerSelector, data) {
|
| 32 |
+
const jsonData = data;
|
| 33 |
+
const chartData = (jsonData.data && jsonData.data.data) || [];
|
| 34 |
+
const variables = jsonData.variables || {};
|
| 35 |
+
const typography = jsonData.typography || {};
|
| 36 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 37 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 38 |
+
|
| 39 |
+
d3.select(containerSelector).html("");
|
| 40 |
+
|
| 41 |
+
const width = Math.max(600, +variables.width || 600);
|
| 42 |
+
const timeField = chartUtils.schema.channel(jsonData, "x").key;
|
| 43 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key;
|
| 44 |
+
const groupField = chartUtils.schema.channel(jsonData, "group").key;
|
| 45 |
+
const groups = Array.from(new Set(chartData.map(d => d[groupField])));
|
| 46 |
+
const denseGroupHeight = 610 + Math.max(0, groups.length - 4) * 36;
|
| 47 |
+
const height = Math.max(denseGroupHeight, Math.round((+variables.height || 600) * 1.03));
|
| 48 |
+
|
| 49 |
+
const titleFamily = (typography.title && typography.title.font_family) || "Arial, Helvetica, sans-serif";
|
| 50 |
+
const labelFamily = (typography.label && typography.label.font_family) || "Arial, Helvetica, sans-serif";
|
| 51 |
+
const annotationFamily = (typography.annotation && typography.annotation.font_family) || "Arial, Helvetica, sans-serif";
|
| 52 |
+
|
| 53 |
+
function safeText(value) {
|
| 54 |
+
return value == null ? "" : String(value);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function parseTime(value) {
|
| 58 |
+
const parsed = chartUtils.format.parseDate(value);
|
| 59 |
+
if (parsed instanceof Date && !Number.isNaN(parsed.getTime())) return parsed.getTime();
|
| 60 |
+
const numeric = +value;
|
| 61 |
+
if (Number.isFinite(numeric)) return numeric;
|
| 62 |
+
return safeText(value).toLowerCase();
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function compactValue(value) {
|
| 66 |
+
const numeric = +value;
|
| 67 |
+
if (!Number.isFinite(numeric)) return safeText(value);
|
| 68 |
+
const column = dataColumns.find(col => col && col.name === valueField) || {};
|
| 69 |
+
const unit = safeText(column.unit || "").trim();
|
| 70 |
+
let formatted = chartUtils.format.compactFixedText(numeric).replace(/\.0$/, "");
|
| 71 |
+
if (unit === "%" && !formatted.includes("%")) formatted = `${formatted}%`;
|
| 72 |
+
return formatted;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function measure(text, fontSize, fontWeight = "700", fontFamily = labelFamily) {
|
| 76 |
+
return chartUtils.text.measure(null, safeText(text), {
|
| 77 |
+
fontFamily,
|
| 78 |
+
fontSize,
|
| 79 |
+
fontWeight
|
| 80 |
+
}).width;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function fitSize(text, maxWidth, start, min, weight = "700", family = labelFamily) {
|
| 84 |
+
let size = start;
|
| 85 |
+
while (size > min && measure(text, size, weight, family) > maxWidth) size -= 1;
|
| 86 |
+
return size;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function wrapWords(text, maxWidth, fontSize, fontWeight, maxLines) {
|
| 90 |
+
const words = safeText(text).split(/\s+/).filter(Boolean);
|
| 91 |
+
const lines = [];
|
| 92 |
+
let line = "";
|
| 93 |
+
words.forEach(word => {
|
| 94 |
+
const candidate = line ? `${line} ${word}` : word;
|
| 95 |
+
if (!line || measure(candidate, fontSize, fontWeight) <= maxWidth) {
|
| 96 |
+
line = candidate;
|
| 97 |
+
} else {
|
| 98 |
+
lines.push(line);
|
| 99 |
+
line = word;
|
| 100 |
+
}
|
| 101 |
+
});
|
| 102 |
+
if (line) lines.push(line);
|
| 103 |
+
const visible = lines.slice(0, maxLines);
|
| 104 |
+
if (lines.length > maxLines && visible.length) {
|
| 105 |
+
let last = visible[visible.length - 1];
|
| 106 |
+
while (last.length > 1 && measure(`${last}...`, fontSize, fontWeight) > maxWidth) {
|
| 107 |
+
last = last.slice(0, -1);
|
| 108 |
+
}
|
| 109 |
+
visible[visible.length - 1] = `${last}...`;
|
| 110 |
+
}
|
| 111 |
+
return visible;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function hexagonPath(cx, cy, r) {
|
| 115 |
+
const points = d3.range(6).map(i => {
|
| 116 |
+
const angle = Math.PI / 6 + i * Math.PI / 3;
|
| 117 |
+
return [cx + r * Math.cos(angle), cy + r * Math.sin(angle)];
|
| 118 |
+
});
|
| 119 |
+
return d3.line()(points) + "Z";
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function tint(color, amount = 0.9) {
|
| 123 |
+
const c = d3.color(color) || d3.color("#426BB5");
|
| 124 |
+
const r = Math.round(c.r + (255 - c.r) * amount);
|
| 125 |
+
const g = Math.round(c.g + (255 - c.g) * amount);
|
| 126 |
+
const b = Math.round(c.b + (255 - c.b) * amount);
|
| 127 |
+
return `rgb(${r},${g},${b})`;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
const timePoints = Array.from(new Set(chartData.map(d => d[timeField])))
|
| 131 |
+
.sort((a, b) => {
|
| 132 |
+
const pa = parseTime(a);
|
| 133 |
+
const pb = parseTime(b);
|
| 134 |
+
if (typeof pa === "number" && typeof pb === "number" && pa !== pb) return pa - pb;
|
| 135 |
+
return safeText(a).localeCompare(safeText(b), undefined, { numeric: true });
|
| 136 |
+
});
|
| 137 |
+
const colorScale = colorResolver.scale(groups, { palette: "category10" });
|
| 138 |
+
const referencePalette = ["#1E5CE6", "#D31678", "#36AF32", "#0FB6CF", "#7B61FF", "#F59E0B"];
|
| 139 |
+
const maxValue = d3.max(chartData, d => +d[valueField]) || 1;
|
| 140 |
+
const valueUnit = (chartUtils.schema.channel(jsonData, "y").unit || "").replace(/^none$/i, "");
|
| 141 |
+
|
| 142 |
+
function groupColor(group, index) {
|
| 143 |
+
const referenceColor = referencePalette[index % referencePalette.length];
|
| 144 |
+
const resolvedField = colorResolver.field(group, index, { fallback: referenceColor });
|
| 145 |
+
let resolved = null;
|
| 146 |
+
if (resolvedField && typeof resolvedField.value === "function") {
|
| 147 |
+
resolved = resolvedField.value(group);
|
| 148 |
+
} else if (resolvedField && resolvedField.value) {
|
| 149 |
+
resolved = resolvedField.value;
|
| 150 |
+
}
|
| 151 |
+
const parsed = d3.color(resolved);
|
| 152 |
+
if (!parsed || index < 4) return referenceColor;
|
| 153 |
+
return parsed.formatHex();
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const svg = d3.select(containerSelector)
|
| 157 |
+
.append("svg")
|
| 158 |
+
.attr("width", "100%")
|
| 159 |
+
.attr("height", height)
|
| 160 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 161 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 162 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 163 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 164 |
+
|
| 165 |
+
const defs = svg.append("defs");
|
| 166 |
+
defs.append("filter")
|
| 167 |
+
.attr("id", "bump13-soft-shadow")
|
| 168 |
+
.attr("x", "-8%")
|
| 169 |
+
.attr("y", "-20%")
|
| 170 |
+
.attr("width", "116%")
|
| 171 |
+
.attr("height", "150%")
|
| 172 |
+
.append("feDropShadow")
|
| 173 |
+
.attr("dx", 0)
|
| 174 |
+
.attr("dy", 4)
|
| 175 |
+
.attr("stdDeviation", 6)
|
| 176 |
+
.attr("flood-color", "#0B2E61")
|
| 177 |
+
.attr("flood-opacity", 0.12);
|
| 178 |
+
|
| 179 |
+
const markerGradientIds = {};
|
| 180 |
+
groups.forEach((group, index) => {
|
| 181 |
+
const base = d3.color(groupColor(group, index)) || d3.color("#426BB5");
|
| 182 |
+
const gradient = defs.append("radialGradient")
|
| 183 |
+
.attr("id", `bump13-marker-${index}`)
|
| 184 |
+
.attr("cx", "35%")
|
| 185 |
+
.attr("cy", "28%")
|
| 186 |
+
.attr("r", "78%");
|
| 187 |
+
gradient.append("stop").attr("offset", "0%").attr("stop-color", base.brighter(0.75).formatHex());
|
| 188 |
+
gradient.append("stop").attr("offset", "58%").attr("stop-color", base.formatHex());
|
| 189 |
+
gradient.append("stop").attr("offset", "100%").attr("stop-color", base.darker(0.35).formatHex());
|
| 190 |
+
markerGradientIds[group] = `bump13-marker-${index}`;
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
const outerPad = 14;
|
| 194 |
+
svg.append("rect")
|
| 195 |
+
.attr("class", "bump13-main-panel")
|
| 196 |
+
.attr("data-layout-role", "main-panel")
|
| 197 |
+
.attr("x", outerPad)
|
| 198 |
+
.attr("y", 8)
|
| 199 |
+
.attr("width", width - outerPad * 2)
|
| 200 |
+
.attr("height", height - 18)
|
| 201 |
+
.attr("rx", 22)
|
| 202 |
+
.attr("fill", "#FFFFFF")
|
| 203 |
+
.attr("fill-opacity", 0.94)
|
| 204 |
+
.attr("filter", "url(#bump13-soft-shadow)");
|
| 205 |
+
|
| 206 |
+
const panelX = 30;
|
| 207 |
+
const panelW = width - 60;
|
| 208 |
+
const legendY = 24;
|
| 209 |
+
const legendH = groups.length > 4 ? 72 : 54;
|
| 210 |
+
const yearY = legendY + legendH + 60;
|
| 211 |
+
const rowTop = yearY + 34;
|
| 212 |
+
const noteH = 34;
|
| 213 |
+
const rowGap = Math.max(12, Math.min(18, height * 0.025));
|
| 214 |
+
const minRowH = groups.length > 5 ? 58 : groups.length > 4 ? 64 : 74;
|
| 215 |
+
const rowH = Math.max(minRowH, (height - rowTop - noteH - 28 - rowGap * Math.max(0, groups.length - 1)) / Math.max(1, groups.length));
|
| 216 |
+
const labelW = Math.min(148, Math.max(128, panelW * 0.25));
|
| 217 |
+
const iconSize = Math.min(42, rowH * 0.42);
|
| 218 |
+
const markerX0 = panelX + labelW + 28;
|
| 219 |
+
const markerX1 = panelX + panelW - 28;
|
| 220 |
+
const xScale = d3.scalePoint()
|
| 221 |
+
.domain(timePoints)
|
| 222 |
+
.range([markerX0, markerX1])
|
| 223 |
+
.padding(0.08);
|
| 224 |
+
|
| 225 |
+
const legend = svg.append("g")
|
| 226 |
+
.attr("class", "bump13-legend-strip")
|
| 227 |
+
.attr("data-layout-role", "legend")
|
| 228 |
+
.attr("data-coordinate-rule", "independent-strip-above-year-rail");
|
| 229 |
+
legend.append("rect")
|
| 230 |
+
.attr("x", panelX + 10)
|
| 231 |
+
.attr("y", legendY)
|
| 232 |
+
.attr("width", panelW - 20)
|
| 233 |
+
.attr("height", legendH)
|
| 234 |
+
.attr("rx", 14)
|
| 235 |
+
.attr("fill", "#FFFFFF")
|
| 236 |
+
.attr("stroke", "#EDF2FA")
|
| 237 |
+
.attr("stroke-width", 1)
|
| 238 |
+
.attr("filter", "url(#bump13-soft-shadow)");
|
| 239 |
+
|
| 240 |
+
const legendInnerX = panelX + 32;
|
| 241 |
+
const legendInnerW = panelW - 64;
|
| 242 |
+
const legendItemW = legendInnerW / Math.max(1, Math.min(groups.length, 4));
|
| 243 |
+
groups.forEach((group, index) => {
|
| 244 |
+
const color = groupColor(group, index);
|
| 245 |
+
const col = groups.length <= 4 ? index : index % 4;
|
| 246 |
+
const row = groups.length <= 4 ? 0 : Math.floor(index / 4);
|
| 247 |
+
const itemX = legendInnerX + col * legendItemW;
|
| 248 |
+
const itemY = legendY + 29 + row * 28;
|
| 249 |
+
legend.append("path")
|
| 250 |
+
.attr("d", hexagonPath(itemX + 8, itemY, 8))
|
| 251 |
+
.attr("fill", `url(#${markerGradientIds[group]})`)
|
| 252 |
+
.attr("stroke", d3.color(color).darker(0.25).formatHex())
|
| 253 |
+
.attr("stroke-width", 0.8);
|
| 254 |
+
const label = safeText(group);
|
| 255 |
+
const fontSize = fitSize(label, Math.max(40, legendItemW - 26), 13, 8, "600", labelFamily);
|
| 256 |
+
legend.append("text")
|
| 257 |
+
.attr("x", itemX + 26)
|
| 258 |
+
.attr("y", itemY + 4)
|
| 259 |
+
.attr("dominant-baseline", "middle")
|
| 260 |
+
.attr("font-family", labelFamily)
|
| 261 |
+
.attr("font-size", fontSize)
|
| 262 |
+
.attr("font-weight", 600)
|
| 263 |
+
.attr("fill", "#13233F")
|
| 264 |
+
.text(label);
|
| 265 |
+
});
|
| 266 |
+
|
| 267 |
+
const yearLayer = svg.append("g")
|
| 268 |
+
.attr("class", "bump13-year-rail")
|
| 269 |
+
.attr("data-layout-role", "year-rail")
|
| 270 |
+
.attr("data-coordinate-rule", "grid-aligned-to-marker-columns")
|
| 271 |
+
.attr("data-time-order", "chronological");
|
| 272 |
+
timePoints.forEach(tp => {
|
| 273 |
+
const x = xScale(tp);
|
| 274 |
+
yearLayer.append("text")
|
| 275 |
+
.attr("x", x)
|
| 276 |
+
.attr("y", yearY)
|
| 277 |
+
.attr("text-anchor", "middle")
|
| 278 |
+
.attr("font-family", titleFamily)
|
| 279 |
+
.attr("font-size", 15)
|
| 280 |
+
.attr("font-weight", 800)
|
| 281 |
+
.attr("fill", "#071A45")
|
| 282 |
+
.text(safeText(tp));
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
const rows = svg.append("g")
|
| 286 |
+
.attr("class", "bump13-rows")
|
| 287 |
+
.attr("data-layout-role", "row-bands")
|
| 288 |
+
.attr("data-coordinate-rule", "row-band-centerline-aligned-label-icon-and-markers");
|
| 289 |
+
|
| 290 |
+
const radiusScale = d3.scaleSqrt()
|
| 291 |
+
.domain([0, maxValue])
|
| 292 |
+
.range([7, Math.min(30, rowH * 0.34)])
|
| 293 |
+
.clamp(true);
|
| 294 |
+
|
| 295 |
+
groups.forEach((group, groupIndex) => {
|
| 296 |
+
const color = groupColor(group, groupIndex);
|
| 297 |
+
const rowY = rowTop + groupIndex * (rowH + rowGap);
|
| 298 |
+
const rowCenter = rowY + rowH / 2;
|
| 299 |
+
const groupData = chartData
|
| 300 |
+
.filter(d => d[groupField] === group)
|
| 301 |
+
.sort((a, b) => timePoints.indexOf(a[timeField]) - timePoints.indexOf(b[timeField]));
|
| 302 |
+
|
| 303 |
+
const row = rows.append("g")
|
| 304 |
+
.attr("class", "bump13-row")
|
| 305 |
+
.attr("data-series", safeText(group))
|
| 306 |
+
.attr("data-row-index", groupIndex);
|
| 307 |
+
|
| 308 |
+
row.append("rect")
|
| 309 |
+
.attr("class", "bump13-row-band")
|
| 310 |
+
.attr("x", panelX)
|
| 311 |
+
.attr("y", rowY)
|
| 312 |
+
.attr("width", panelW)
|
| 313 |
+
.attr("height", rowH)
|
| 314 |
+
.attr("rx", 13)
|
| 315 |
+
.attr("fill", tint(color, 0.88))
|
| 316 |
+
.attr("fill-opacity", 0.72);
|
| 317 |
+
|
| 318 |
+
const iconX = panelX + (labelW - iconSize) / 2;
|
| 319 |
+
const iconY = rowY + 10;
|
| 320 |
+
const icon = row.append("g")
|
| 321 |
+
.attr("class", "reserved-asset-slot bump13-group-icon-slot")
|
| 322 |
+
.attr("data-asset-slot", "group-icon")
|
| 323 |
+
.attr("data-slot-policy", "input_asset_or_image2_asset")
|
| 324 |
+
.attr("data-asset-source-policy", "input_asset_or_image2_asset")
|
| 325 |
+
.attr("data-reserved-slot", "group_icon_slot")
|
| 326 |
+
.attr("data-group", safeText(group))
|
| 327 |
+
.attr("data-anchor", "group-row-label-block")
|
| 328 |
+
.attr("data-collision-rule", "stay-inside-row-band-left-of-marker-columns-and-clear-of-group-label")
|
| 329 |
+
.attr("data-bbox", `${iconX.toFixed(2)},${iconY.toFixed(2)},${iconSize.toFixed(2)},${iconSize.toFixed(2)}`)
|
| 330 |
+
.attr("data-bbox-policy", "transform plus local width/height define the visible reserved bbox")
|
| 331 |
+
.attr("data-mask-policy", "downstream asset may replace only within this hex slot")
|
| 332 |
+
.attr("transform", `translate(${iconX},${iconY})`);
|
| 333 |
+
icon.append("path")
|
| 334 |
+
.attr("d", hexagonPath(iconSize / 2, iconSize / 2, iconSize / 2))
|
| 335 |
+
.attr("fill", `url(#${markerGradientIds[group]})`)
|
| 336 |
+
.attr("stroke", d3.color(color).darker(0.35).formatHex())
|
| 337 |
+
.attr("stroke-width", 1.1);
|
| 338 |
+
icon.append("text")
|
| 339 |
+
.attr("x", iconSize / 2)
|
| 340 |
+
.attr("y", iconSize / 2 + 1)
|
| 341 |
+
.attr("text-anchor", "middle")
|
| 342 |
+
.attr("dominant-baseline", "middle")
|
| 343 |
+
.attr("font-family", titleFamily)
|
| 344 |
+
.attr("font-size", Math.max(10, iconSize * 0.32))
|
| 345 |
+
.attr("font-weight", 900)
|
| 346 |
+
.attr("fill", "#FFFFFF")
|
| 347 |
+
.text(safeText(group).slice(0, 1).toUpperCase());
|
| 348 |
+
|
| 349 |
+
const labelX = panelX + labelW / 2;
|
| 350 |
+
const labelMaxW = labelW - 18;
|
| 351 |
+
const labelLines = wrapWords(group, labelMaxW, 11, "800", 2);
|
| 352 |
+
const labelStartY = iconY + iconSize + 15 - (labelLines.length - 1) * 7;
|
| 353 |
+
labelLines.forEach((line, lineIndex) => {
|
| 354 |
+
row.append("text")
|
| 355 |
+
.attr("class", "bump13-group-label")
|
| 356 |
+
.attr("x", labelX)
|
| 357 |
+
.attr("y", labelStartY + lineIndex * 17)
|
| 358 |
+
.attr("dominant-baseline", "middle")
|
| 359 |
+
.attr("text-anchor", "middle")
|
| 360 |
+
.attr("font-family", titleFamily)
|
| 361 |
+
.attr("font-size", fitSize(line, labelMaxW, 11, 8, "800", titleFamily))
|
| 362 |
+
.attr("font-weight", 800)
|
| 363 |
+
.attr("fill", d3.color(color).darker(0.32).formatHex())
|
| 364 |
+
.text(line);
|
| 365 |
+
});
|
| 366 |
+
|
| 367 |
+
const points = groupData.map(d => ({
|
| 368 |
+
datum: d,
|
| 369 |
+
x: xScale(d[timeField]),
|
| 370 |
+
y: rowCenter - Math.min(10, rowH * 0.13),
|
| 371 |
+
value: +d[valueField],
|
| 372 |
+
radius: radiusScale(+d[valueField])
|
| 373 |
+
})).filter(p => Number.isFinite(p.x));
|
| 374 |
+
|
| 375 |
+
if (points.length >= 2) {
|
| 376 |
+
row.append("path")
|
| 377 |
+
.attr("class", "bump13-connector")
|
| 378 |
+
.attr("fill", "none")
|
| 379 |
+
.attr("stroke", color)
|
| 380 |
+
.attr("stroke-width", 1.6)
|
| 381 |
+
.attr("stroke-opacity", 0.92)
|
| 382 |
+
.attr("d", d3.line().x(d => d.x).y(d => d.y)(points));
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
points.forEach(point => {
|
| 386 |
+
row.append("path")
|
| 387 |
+
.attr("class", "bump13-hex-marker")
|
| 388 |
+
.attr("data-year", safeText(point.datum[timeField]))
|
| 389 |
+
.attr("data-value", safeText(point.datum[valueField]))
|
| 390 |
+
.attr("d", hexagonPath(point.x, point.y, point.radius))
|
| 391 |
+
.attr("fill", `url(#${markerGradientIds[group]})`)
|
| 392 |
+
.attr("stroke", d3.color(color).darker(0.28).formatHex())
|
| 393 |
+
.attr("stroke-width", 0.8);
|
| 394 |
+
|
| 395 |
+
row.append("text")
|
| 396 |
+
.attr("class", "bump13-value-label")
|
| 397 |
+
.attr("x", point.x)
|
| 398 |
+
.attr("y", Math.min(rowY + rowH - 11, point.y + point.radius + 17))
|
| 399 |
+
.attr("text-anchor", "middle")
|
| 400 |
+
.attr("dominant-baseline", "middle")
|
| 401 |
+
.attr("font-family", annotationFamily)
|
| 402 |
+
.attr("font-size", 13)
|
| 403 |
+
.attr("font-weight", 700)
|
| 404 |
+
.attr("fill", "#071A45")
|
| 405 |
+
.text(compactValue(point.value));
|
| 406 |
+
});
|
| 407 |
+
});
|
| 408 |
+
|
| 409 |
+
const noteY = Math.min(height - 26, rowTop + groups.length * rowH + Math.max(0, groups.length - 1) * rowGap + 24);
|
| 410 |
+
const note = svg.append("g")
|
| 411 |
+
.attr("class", "bump13-unit-note")
|
| 412 |
+
.attr("data-layout-role", "chart-owned-unit-note")
|
| 413 |
+
.attr("data-coordinate-rule", "bottom-anchored-independent-note");
|
| 414 |
+
note.append("circle")
|
| 415 |
+
.attr("cx", panelX + 12)
|
| 416 |
+
.attr("cy", noteY - 4)
|
| 417 |
+
.attr("r", 8)
|
| 418 |
+
.attr("fill", "#7F8CA3");
|
| 419 |
+
note.append("text")
|
| 420 |
+
.attr("x", panelX + 12)
|
| 421 |
+
.attr("y", noteY - 3)
|
| 422 |
+
.attr("text-anchor", "middle")
|
| 423 |
+
.attr("dominant-baseline", "middle")
|
| 424 |
+
.attr("font-family", annotationFamily)
|
| 425 |
+
.attr("font-size", 11)
|
| 426 |
+
.attr("font-weight", 800)
|
| 427 |
+
.attr("fill", "#FFFFFF")
|
| 428 |
+
.text("i");
|
| 429 |
+
note.append("text")
|
| 430 |
+
.attr("x", panelX + 30)
|
| 431 |
+
.attr("y", noteY)
|
| 432 |
+
.attr("font-family", annotationFamily)
|
| 433 |
+
.attr("font-size", 12)
|
| 434 |
+
.attr("font-style", "italic")
|
| 435 |
+
.attr("fill", "#69758B")
|
| 436 |
+
.text(valueUnit ? `Values represent ${valueField} (${valueUnit}).` : `Values represent ${valueField}.`);
|
| 437 |
+
|
| 438 |
+
return svg.node();
|
| 439 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/bump_chart_14.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Sorted Lines Chart",
|
| 5 |
+
"chart_name": "bump_chart_14",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"chart_for": "comparison",
|
| 8 |
+
"required_fields": ["x", "y", "group"],
|
| 9 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 10 |
+
"required_fields_range": [
|
| 11 |
+
[2, 12],
|
| 12 |
+
[0, "inf"],
|
| 13 |
+
[4, 10]
|
| 14 |
+
],
|
| 15 |
+
"required_fields_icons": ["group"],
|
| 16 |
+
"required_other_icons": [],
|
| 17 |
+
"required_fields_colors": ["group"],
|
| 18 |
+
"required_other_colors": [],
|
| 19 |
+
"supported_effects": ["spacing"],
|
| 20 |
+
"min_height": 400,
|
| 21 |
+
"min_width": 400,
|
| 22 |
+
"background": "no",
|
| 23 |
+
"icon_mark": "none",
|
| 24 |
+
"icon_label": "none",
|
| 25 |
+
"has_x_axis": "no",
|
| 26 |
+
"has_y_axis": "no"
|
| 27 |
+
}
|
| 28 |
+
REQUIREMENTS_END
|
| 29 |
+
*/
|
| 30 |
+
|
| 31 |
+
function makeChart(containerSelector, data) {
|
| 32 |
+
const jsonData = data;
|
| 33 |
+
const chartData = jsonData.data.data;
|
| 34 |
+
const variables = jsonData.variables || {};
|
| 35 |
+
const typography = jsonData.typography || {
|
| 36 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 37 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "bold" }
|
| 38 |
+
};
|
| 39 |
+
const colors = jsonData.colors || { text_color: "#333333" };
|
| 40 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 41 |
+
const images = jsonData.images || { field: {}, other: {} };
|
| 42 |
+
|
| 43 |
+
d3.select(containerSelector).html("");
|
| 44 |
+
|
| 45 |
+
const width = +variables.width || 800;
|
| 46 |
+
const height = +variables.height || 600;
|
| 47 |
+
const timeField = chartUtils.schema.channel(jsonData, "x").key;
|
| 48 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key;
|
| 49 |
+
const groupField = chartUtils.schema.channel(jsonData, "group").key;
|
| 50 |
+
const valueChannel = chartUtils.schema.channel(jsonData, "y") || {};
|
| 51 |
+
const valueUnit = valueChannel.unit && valueChannel.unit !== "none" ? valueChannel.unit : "";
|
| 52 |
+
|
| 53 |
+
const timePoints = [...new Set(chartData.map(d => d[timeField]))]
|
| 54 |
+
.sort((a, b) => {
|
| 55 |
+
const da = chartUtils.format.parseDate(a);
|
| 56 |
+
const db = chartUtils.format.parseDate(b);
|
| 57 |
+
const ta = da instanceof Date ? da.getTime() : +da;
|
| 58 |
+
const tb = db instanceof Date ? db.getTime() : +db;
|
| 59 |
+
if (Number.isFinite(ta) && Number.isFinite(tb) && ta !== tb) return ta - tb;
|
| 60 |
+
return String(a).localeCompare(String(b), undefined, { numeric: true });
|
| 61 |
+
});
|
| 62 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 63 |
+
|
| 64 |
+
const svg = d3.select(containerSelector)
|
| 65 |
+
.append("svg")
|
| 66 |
+
.attr("width", "100%")
|
| 67 |
+
.attr("height", height)
|
| 68 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 69 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 70 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 71 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 72 |
+
|
| 73 |
+
const fontFamily = typography.label.font_family || "Arial, Helvetica, sans-serif";
|
| 74 |
+
const textColor = colorResolver.text({ fallback: colors.text_color || "#17213A" }).value;
|
| 75 |
+
const navy = "#101A3F";
|
| 76 |
+
|
| 77 |
+
const defs = svg.append("defs");
|
| 78 |
+
defs.append("filter")
|
| 79 |
+
.attr("id", "bump14-soft-shadow")
|
| 80 |
+
.attr("x", "-8%")
|
| 81 |
+
.attr("y", "-24%")
|
| 82 |
+
.attr("width", "116%")
|
| 83 |
+
.attr("height", "160%")
|
| 84 |
+
.append("feDropShadow")
|
| 85 |
+
.attr("dx", 0)
|
| 86 |
+
.attr("dy", 5)
|
| 87 |
+
.attr("stdDeviation", 7)
|
| 88 |
+
.attr("flood-color", "#23345D")
|
| 89 |
+
.attr("flood-opacity", 0.13);
|
| 90 |
+
|
| 91 |
+
svg.append("g")
|
| 92 |
+
.attr("class", "reserved-asset-slot bump14-header-primary-slot")
|
| 93 |
+
.attr("data-asset-slot", "header-primary")
|
| 94 |
+
.attr("data-asset-source-policy", images.other && images.other.primary ? "input_provided" : "input_asset_or_image2_asset")
|
| 95 |
+
.attr("data-anchor", "header-left-safe-area")
|
| 96 |
+
.attr("data-bbox", `${Math.round(width * 0.04)},${Math.round(height * 0.02)},${Math.round(width * 0.20)},${Math.round(height * 0.16)}`)
|
| 97 |
+
.attr("data-collision-rule", "owned by header wrapper; avoid title, subtitle, legend, and chart panels")
|
| 98 |
+
.attr("data-no-copied-reference-art", "true")
|
| 99 |
+
.attr("opacity", 0);
|
| 100 |
+
|
| 101 |
+
function measure(text, fontSize, fontWeight = "700") {
|
| 102 |
+
return chartUtils.text.measure(svg, String(text), { fontFamily, fontSize, fontWeight }).width;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function fitText(text, maxWidth, fontSize, fontWeight = "700", minSize = 7) {
|
| 106 |
+
let size = fontSize;
|
| 107 |
+
while (size > minSize && measure(text, size, fontWeight) > maxWidth) size -= 1;
|
| 108 |
+
return size;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function ellipsize(text, maxWidth, fontSize, fontWeight = "700") {
|
| 112 |
+
const value = String(text);
|
| 113 |
+
if (measure(value, fontSize, fontWeight) <= maxWidth) return value;
|
| 114 |
+
let clipped = value;
|
| 115 |
+
while (clipped.length > 2 && measure(`${clipped}...`, fontSize, fontWeight) > maxWidth) {
|
| 116 |
+
clipped = clipped.slice(0, -1);
|
| 117 |
+
}
|
| 118 |
+
return `${clipped}...`;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function getColor(group, index) {
|
| 122 |
+
const scale = colorResolver.scale(groups, { palette: "category10" });
|
| 123 |
+
return scale(group) || colorResolver.field(group, index, { fallback: "#2F6DEB" }).value || "#2F6DEB";
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function rgba(color, alpha) {
|
| 127 |
+
const c = d3.color(color);
|
| 128 |
+
if (!c) return `rgba(47,109,235,${alpha})`;
|
| 129 |
+
return `rgba(${c.r},${c.g},${c.b},${alpha})`;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function circleFill(color, timeIndex) {
|
| 133 |
+
const c = d3.color(color);
|
| 134 |
+
if (!c) return color;
|
| 135 |
+
const n = Math.max(1, timePoints.length - 1);
|
| 136 |
+
const t = timeIndex / n;
|
| 137 |
+
if (t < 0.45) return c.brighter(0.75 - t).formatHex();
|
| 138 |
+
return c.darker(Math.max(0, (t - 0.25) * 0.95)).formatHex();
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function contrastText(fill) {
|
| 142 |
+
const c = d3.color(fill);
|
| 143 |
+
if (!c) return "#FFFFFF";
|
| 144 |
+
const luminance = (0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b) / 255;
|
| 145 |
+
return luminance > 0.62 ? "#17213A" : "#FFFFFF";
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function drawGroupIcon(parent, group, x, y, size, color, anchorName) {
|
| 149 |
+
const slot = parent.append("g")
|
| 150 |
+
.attr("class", "bump14-group-icon-slot reserved-asset-slot")
|
| 151 |
+
.attr("data-asset-slot", "group-icon")
|
| 152 |
+
.attr("data-group", String(group))
|
| 153 |
+
.attr("data-asset-source-policy", "neutral_placeholder_or_downstream_asset")
|
| 154 |
+
.attr("data-anchor", anchorName)
|
| 155 |
+
.attr("data-collision-rule", "stay inside legend item or row card bbox")
|
| 156 |
+
.attr("data-no-copied-reference-art", "true")
|
| 157 |
+
.attr("data-no-embedded-raster", "true")
|
| 158 |
+
.attr("transform", `translate(${x},${y})`);
|
| 159 |
+
|
| 160 |
+
slot.append("circle")
|
| 161 |
+
.attr("r", size / 2)
|
| 162 |
+
.attr("fill", "#F6FAFF")
|
| 163 |
+
.attr("stroke", rgba(color, 0.30))
|
| 164 |
+
.attr("stroke-width", 1.4);
|
| 165 |
+
|
| 166 |
+
slot.append("circle")
|
| 167 |
+
.attr("r", Math.max(2.2, size * 0.14))
|
| 168 |
+
.attr("fill", color)
|
| 169 |
+
.attr("opacity", 0.72);
|
| 170 |
+
|
| 171 |
+
slot.append("text")
|
| 172 |
+
.attr("text-anchor", "middle")
|
| 173 |
+
.attr("dominant-baseline", "middle")
|
| 174 |
+
.attr("font-family", fontFamily)
|
| 175 |
+
.attr("font-weight", "900")
|
| 176 |
+
.attr("font-size", Math.max(8, size * 0.42))
|
| 177 |
+
.attr("fill", color)
|
| 178 |
+
.attr("opacity", 0.92)
|
| 179 |
+
.text(String(group).slice(0, 1).toUpperCase());
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
const values = chartData.map(d => +d[valueField]).filter(Number.isFinite);
|
| 183 |
+
const maxValue = d3.max(values) || 1;
|
| 184 |
+
const minValue = d3.min(values) || 0;
|
| 185 |
+
|
| 186 |
+
const outerX = Math.max(20, width * 0.04);
|
| 187 |
+
const outerRight = Math.max(20, width * 0.035);
|
| 188 |
+
const legendX = outerX;
|
| 189 |
+
const legendY = Math.max(8, height * 0.018);
|
| 190 |
+
const legendW = width - outerX - outerRight;
|
| 191 |
+
const legendCols = Math.max(1, Math.min(4, groups.length));
|
| 192 |
+
const legendRows = Math.ceil(groups.length / legendCols);
|
| 193 |
+
const legendRowH = Math.max(34, Math.min(42, height * 0.07));
|
| 194 |
+
const legendH = 16 + legendRows * legendRowH;
|
| 195 |
+
const yearLabelY = legendY + legendH + Math.max(28, height * 0.055);
|
| 196 |
+
const dotY = yearLabelY + 20;
|
| 197 |
+
const rowTop = dotY + Math.max(18, height * 0.035);
|
| 198 |
+
const bottomPad = Math.max(18, height * 0.035);
|
| 199 |
+
const rowGap = Math.max(8, Math.min(15, height * 0.022));
|
| 200 |
+
let rowH = (height - rowTop - bottomPad - rowGap * Math.max(0, groups.length - 1)) / Math.max(1, groups.length);
|
| 201 |
+
rowH = Math.max(34, Math.min(82, rowH));
|
| 202 |
+
const rowChartBottom = rowTop + groups.length * rowH + Math.max(0, groups.length - 1) * rowGap;
|
| 203 |
+
|
| 204 |
+
const labelCardW = Math.max(82, Math.min(118, width * 0.20));
|
| 205 |
+
const plotX0 = outerX + labelCardW + Math.max(25, width * 0.045);
|
| 206 |
+
const plotX1 = width - outerRight - 4;
|
| 207 |
+
const xScale = d3.scalePoint()
|
| 208 |
+
.domain(timePoints)
|
| 209 |
+
.range([plotX0, plotX1])
|
| 210 |
+
.padding(0.03);
|
| 211 |
+
const columnGap = timePoints.length > 1
|
| 212 |
+
? d3.min(timePoints.slice(1).map((d, i) => xScale(d) - xScale(timePoints[i])))
|
| 213 |
+
: plotX1 - plotX0;
|
| 214 |
+
const maxRadius = Math.max(7, Math.min(rowH * 0.31, columnGap * 0.30, 24));
|
| 215 |
+
const minRadius = Math.max(4.5, Math.min(maxRadius * 0.38, 8));
|
| 216 |
+
const radiusScale = d3.scaleSqrt()
|
| 217 |
+
.domain([Math.min(0, minValue), maxValue])
|
| 218 |
+
.range([minRadius, maxRadius])
|
| 219 |
+
.clamp(true);
|
| 220 |
+
|
| 221 |
+
const legend = svg.append("g")
|
| 222 |
+
.attr("class", "bump14-legend-card")
|
| 223 |
+
.attr("data-coordinate-relationship", "independent_grid_aligned")
|
| 224 |
+
.attr("data-anchor", "below-title-above-year-grid");
|
| 225 |
+
|
| 226 |
+
legend.append("rect")
|
| 227 |
+
.attr("x", legendX)
|
| 228 |
+
.attr("y", legendY)
|
| 229 |
+
.attr("width", legendW)
|
| 230 |
+
.attr("height", legendH)
|
| 231 |
+
.attr("rx", 12)
|
| 232 |
+
.attr("fill", "rgba(255,255,255,0.88)")
|
| 233 |
+
.attr("stroke", "rgba(255,255,255,0.95)")
|
| 234 |
+
.attr("filter", "url(#bump14-soft-shadow)");
|
| 235 |
+
|
| 236 |
+
groups.forEach((group, index) => {
|
| 237 |
+
const color = getColor(group, index);
|
| 238 |
+
const col = index % legendCols;
|
| 239 |
+
const row = Math.floor(index / legendCols);
|
| 240 |
+
const itemW = legendW / legendCols;
|
| 241 |
+
const itemX = legendX + col * itemW + 14;
|
| 242 |
+
const itemY = legendY + 11 + row * legendRowH;
|
| 243 |
+
const iconSize = Math.max(18, Math.min(28, legendRowH * 0.65));
|
| 244 |
+
drawGroupIcon(legend, group, itemX + iconSize / 2, itemY + iconSize / 2, iconSize, color, `legend-item-${index + 1}`);
|
| 245 |
+
|
| 246 |
+
const labelX = itemX + iconSize + 10;
|
| 247 |
+
const labelMaxW = Math.max(30, itemW - iconSize - 28);
|
| 248 |
+
const labelFont = fitText(group, labelMaxW, Math.max(10, Math.min(14, legendRowH * 0.34)), "800", 7);
|
| 249 |
+
legend.append("text")
|
| 250 |
+
.attr("x", labelX)
|
| 251 |
+
.attr("y", itemY + iconSize / 2)
|
| 252 |
+
.attr("dominant-baseline", "middle")
|
| 253 |
+
.attr("text-anchor", "start")
|
| 254 |
+
.attr("font-family", fontFamily)
|
| 255 |
+
.attr("font-size", labelFont)
|
| 256 |
+
.attr("font-weight", "800")
|
| 257 |
+
.attr("fill", navy)
|
| 258 |
+
.text(ellipsize(group, labelMaxW, labelFont, "800"));
|
| 259 |
+
|
| 260 |
+
legend.append("line")
|
| 261 |
+
.attr("x1", labelX)
|
| 262 |
+
.attr("x2", Math.min(labelX + 34, itemX + itemW - 12))
|
| 263 |
+
.attr("y1", itemY + iconSize + 7)
|
| 264 |
+
.attr("y2", itemY + iconSize + 7)
|
| 265 |
+
.attr("stroke", color)
|
| 266 |
+
.attr("stroke-width", 4)
|
| 267 |
+
.attr("stroke-linecap", "round");
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
const axisLayer = svg.append("g")
|
| 271 |
+
.attr("class", "bump14-year-grid")
|
| 272 |
+
.attr("data-coordinate-relationship", "grid_aligned")
|
| 273 |
+
.attr("data-anchor", "chronological-time-scale");
|
| 274 |
+
|
| 275 |
+
timePoints.forEach(timePoint => {
|
| 276 |
+
const x = xScale(timePoint);
|
| 277 |
+
axisLayer.append("line")
|
| 278 |
+
.attr("x1", x)
|
| 279 |
+
.attr("x2", x)
|
| 280 |
+
.attr("y1", dotY + 6)
|
| 281 |
+
.attr("y2", rowChartBottom + 4)
|
| 282 |
+
.attr("stroke", "#B8C7EA")
|
| 283 |
+
.attr("stroke-width", 1)
|
| 284 |
+
.attr("stroke-dasharray", "2 6")
|
| 285 |
+
.attr("opacity", 0.75);
|
| 286 |
+
axisLayer.append("text")
|
| 287 |
+
.attr("x", x)
|
| 288 |
+
.attr("y", yearLabelY)
|
| 289 |
+
.attr("text-anchor", "middle")
|
| 290 |
+
.attr("dominant-baseline", "middle")
|
| 291 |
+
.attr("font-family", fontFamily)
|
| 292 |
+
.attr("font-size", fitText(timePoint, Math.max(24, columnGap - 8), 12, "700", 8))
|
| 293 |
+
.attr("font-weight", "700")
|
| 294 |
+
.attr("fill", navy)
|
| 295 |
+
.text(timePoint);
|
| 296 |
+
axisLayer.append("circle")
|
| 297 |
+
.attr("cx", x)
|
| 298 |
+
.attr("cy", dotY)
|
| 299 |
+
.attr("r", 4.2)
|
| 300 |
+
.attr("fill", "#C8D3F3");
|
| 301 |
+
});
|
| 302 |
+
|
| 303 |
+
const rowLayer = svg.append("g")
|
| 304 |
+
.attr("class", "bump14-row-layer")
|
| 305 |
+
.attr("data-coordinate-relationship", "row_cards_span_matched_to_panels");
|
| 306 |
+
|
| 307 |
+
groups.forEach((group, index) => {
|
| 308 |
+
const color = getColor(group, index);
|
| 309 |
+
const rowY = rowTop + index * (rowH + rowGap);
|
| 310 |
+
const rowCenterY = rowY + rowH / 2;
|
| 311 |
+
const panelX = Math.max(plotX0 - 18, outerX + labelCardW + 12);
|
| 312 |
+
const panelW = Math.min(width - panelX - outerRight, plotX1 - panelX + 22);
|
| 313 |
+
|
| 314 |
+
rowLayer.append("rect")
|
| 315 |
+
.attr("class", "bump14-row-panel")
|
| 316 |
+
.attr("data-group", group)
|
| 317 |
+
.attr("x", panelX)
|
| 318 |
+
.attr("y", rowY + 2)
|
| 319 |
+
.attr("width", panelW)
|
| 320 |
+
.attr("height", rowH - 4)
|
| 321 |
+
.attr("rx", Math.min(13, rowH * 0.18))
|
| 322 |
+
.attr("fill", rgba(color, 0.10))
|
| 323 |
+
.attr("stroke", rgba(color, 0.10));
|
| 324 |
+
|
| 325 |
+
const card = rowLayer.append("g")
|
| 326 |
+
.attr("class", "bump14-left-row-card")
|
| 327 |
+
.attr("data-group", group)
|
| 328 |
+
.attr("data-coordinate-relationship", "span_matched")
|
| 329 |
+
.attr("data-anchor", `group-row-${index + 1}`);
|
| 330 |
+
card.append("rect")
|
| 331 |
+
.attr("x", outerX)
|
| 332 |
+
.attr("y", rowY + 1)
|
| 333 |
+
.attr("width", labelCardW)
|
| 334 |
+
.attr("height", rowH - 2)
|
| 335 |
+
.attr("rx", Math.min(10, rowH * 0.18))
|
| 336 |
+
.attr("fill", "rgba(255,255,255,0.92)")
|
| 337 |
+
.attr("stroke", "rgba(255,255,255,0.90)")
|
| 338 |
+
.attr("filter", "url(#bump14-soft-shadow)");
|
| 339 |
+
card.append("rect")
|
| 340 |
+
.attr("x", outerX)
|
| 341 |
+
.attr("y", rowY + 1)
|
| 342 |
+
.attr("width", Math.max(4, Math.min(7, labelCardW * 0.07)))
|
| 343 |
+
.attr("height", rowH - 2)
|
| 344 |
+
.attr("rx", Math.min(7, rowH * 0.16))
|
| 345 |
+
.attr("fill", color);
|
| 346 |
+
|
| 347 |
+
const iconSize = Math.max(20, Math.min(34, rowH * 0.42));
|
| 348 |
+
const iconY = rowY + Math.max(iconSize * 0.55, rowH * 0.35);
|
| 349 |
+
drawGroupIcon(card, group, outerX + labelCardW / 2, iconY, iconSize, color, `row-card-${index + 1}`);
|
| 350 |
+
|
| 351 |
+
const labelFont = fitText(group, labelCardW - 14, Math.max(10, Math.min(15, rowH * 0.20)), "900", 7);
|
| 352 |
+
card.append("text")
|
| 353 |
+
.attr("x", outerX + labelCardW / 2)
|
| 354 |
+
.attr("y", rowY + rowH * 0.64)
|
| 355 |
+
.attr("dominant-baseline", "middle")
|
| 356 |
+
.attr("text-anchor", "middle")
|
| 357 |
+
.attr("font-family", fontFamily)
|
| 358 |
+
.attr("font-size", labelFont)
|
| 359 |
+
.attr("font-weight", "900")
|
| 360 |
+
.attr("fill", navy)
|
| 361 |
+
.text(ellipsize(group, labelCardW - 14, labelFont, "900"));
|
| 362 |
+
if (valueUnit) {
|
| 363 |
+
card.append("text")
|
| 364 |
+
.attr("x", outerX + labelCardW / 2)
|
| 365 |
+
.attr("y", rowY + rowH * 0.82)
|
| 366 |
+
.attr("dominant-baseline", "middle")
|
| 367 |
+
.attr("text-anchor", "middle")
|
| 368 |
+
.attr("font-family", fontFamily)
|
| 369 |
+
.attr("font-size", Math.max(7, Math.min(10, rowH * 0.14)))
|
| 370 |
+
.attr("font-weight", "500")
|
| 371 |
+
.attr("fill", "#4D5878")
|
| 372 |
+
.text(`(${valueUnit})`);
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
const groupData = chartData
|
| 376 |
+
.filter(d => d[groupField] === group)
|
| 377 |
+
.sort((a, b) => timePoints.indexOf(a[timeField]) - timePoints.indexOf(b[timeField]));
|
| 378 |
+
const finiteGroupValues = groupData
|
| 379 |
+
.map(d => +d[valueField])
|
| 380 |
+
.filter(Number.isFinite);
|
| 381 |
+
const groupMin = d3.min(finiteGroupValues);
|
| 382 |
+
const groupMax = d3.max(finiteGroupValues);
|
| 383 |
+
const rowValueTop = rowY + 2 + maxRadius + 5;
|
| 384 |
+
const rowValueBottom = rowY + rowH - 2 - maxRadius - 5;
|
| 385 |
+
const rowTravel = Math.max(0, rowValueBottom - rowValueTop);
|
| 386 |
+
|
| 387 |
+
function rowLocalY(value) {
|
| 388 |
+
if (!Number.isFinite(value) || !Number.isFinite(groupMin) || !Number.isFinite(groupMax) || groupMax <= groupMin || rowTravel <= 1) {
|
| 389 |
+
return rowCenterY;
|
| 390 |
+
}
|
| 391 |
+
const normalized = (value - groupMin) / (groupMax - groupMin);
|
| 392 |
+
return rowValueBottom - normalized * rowTravel;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
const points = groupData
|
| 396 |
+
.filter(d => timePoints.includes(d[timeField]) && Number.isFinite(+d[valueField]))
|
| 397 |
+
.map(d => ({
|
| 398 |
+
x: xScale(d[timeField]),
|
| 399 |
+
y: rowLocalY(+d[valueField]),
|
| 400 |
+
value: +d[valueField],
|
| 401 |
+
time: d[timeField]
|
| 402 |
+
}));
|
| 403 |
+
|
| 404 |
+
if (points.length >= 2) {
|
| 405 |
+
rowLayer.append("path")
|
| 406 |
+
.datum(points)
|
| 407 |
+
.attr("class", "bump14-connector-line")
|
| 408 |
+
.attr("data-group", group)
|
| 409 |
+
.attr("data-y-encoding", "row-local-group-normalized-value")
|
| 410 |
+
.attr("fill", "none")
|
| 411 |
+
.attr("stroke", color)
|
| 412 |
+
.attr("stroke-width", Math.max(2.4, Math.min(4.2, rowH * 0.055)))
|
| 413 |
+
.attr("stroke-linecap", "round")
|
| 414 |
+
.attr("stroke-linejoin", "round")
|
| 415 |
+
.attr("opacity", 0.95)
|
| 416 |
+
.attr("d", d3.line().x(d => d.x).y(d => d.y));
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
points.forEach(point => {
|
| 420 |
+
const r = radiusScale(point.value);
|
| 421 |
+
const timeIndex = timePoints.indexOf(point.time);
|
| 422 |
+
const fill = circleFill(color, timeIndex);
|
| 423 |
+
rowLayer.append("circle")
|
| 424 |
+
.attr("class", "bump14-value-circle")
|
| 425 |
+
.attr("data-group", group)
|
| 426 |
+
.attr("data-x", point.time)
|
| 427 |
+
.attr("data-value", point.value)
|
| 428 |
+
.attr("data-y-encoding", "row-local-group-normalized-value")
|
| 429 |
+
.attr("cx", point.x)
|
| 430 |
+
.attr("cy", point.y)
|
| 431 |
+
.attr("r", r)
|
| 432 |
+
.attr("fill", fill)
|
| 433 |
+
.attr("stroke", "rgba(255,255,255,0.90)")
|
| 434 |
+
.attr("stroke-width", Math.max(1.6, r * 0.14))
|
| 435 |
+
.attr("filter", r >= 9 ? "url(#bump14-soft-shadow)" : null);
|
| 436 |
+
|
| 437 |
+
const formattedValue = chartUtils.format.compactFixedText(point.value);
|
| 438 |
+
const valueFont = fitText(formattedValue, r * 1.55, Math.max(7, Math.min(15, r * 0.70)), "800", 6);
|
| 439 |
+
rowLayer.append("text")
|
| 440 |
+
.attr("class", "bump14-value-label")
|
| 441 |
+
.attr("data-anchor", "attached_to_value_circle_center")
|
| 442 |
+
.attr("x", point.x)
|
| 443 |
+
.attr("y", point.y)
|
| 444 |
+
.attr("dominant-baseline", "middle")
|
| 445 |
+
.attr("text-anchor", "middle")
|
| 446 |
+
.attr("font-family", fontFamily)
|
| 447 |
+
.attr("font-size", valueFont)
|
| 448 |
+
.attr("font-weight", "800")
|
| 449 |
+
.attr("fill", contrastText(fill))
|
| 450 |
+
.text(formattedValue);
|
| 451 |
+
});
|
| 452 |
+
});
|
| 453 |
+
|
| 454 |
+
return svg.node();
|
| 455 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/line_chart_01.js
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Vertical Bar Chart",
|
| 5 |
+
"chart_name": "line_chart_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["temporal"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 12], [0, "inf"]],
|
| 10 |
+
"required_fields_icons": [],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": [],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": ["shadow", "radius_corner", "gradient", "stroke", "spacing"],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "overlay",
|
| 19 |
+
"icon_label": "side",
|
| 20 |
+
"has_x_axis": "no",
|
| 21 |
+
"has_y_axis": "no"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
// 使用D3.js创建简单的垂直条形图
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 29 |
+
// ---------- 1. 数据准备 ----------
|
| 30 |
+
const jsonData = data; // 完整的JSON数据对象
|
| 31 |
+
const chartData = jsonData.data.data || []; // 图表数据
|
| 32 |
+
const variables = jsonData.variables || {}; // 图表配置
|
| 33 |
+
const typography = jsonData.typography || { // 字体设置
|
| 34 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 35 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 36 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 37 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 38 |
+
};
|
| 39 |
+
const colors = jsonData.colors || {
|
| 40 |
+
text_color: "#333333",
|
| 41 |
+
other: { primary: "#73D2C7", secondary: "#4682B4" }
|
| 42 |
+
};
|
| 43 |
+
const images = jsonData.images || { field: {}, other: {} }; // 图片(标志等)
|
| 44 |
+
const dataColumns = chartUtils.schema.columns(jsonData); // 数据列
|
| 45 |
+
|
| 46 |
+
// 设置视觉效果变量
|
| 47 |
+
variables.has_rounded_corners = variables.has_rounded_corners || false;
|
| 48 |
+
variables.has_shadow = variables.has_shadow || false;
|
| 49 |
+
variables.has_gradient = variables.has_gradient || false;
|
| 50 |
+
variables.has_stroke = variables.has_stroke || false;
|
| 51 |
+
variables.has_spacing = variables.has_spacing || false;
|
| 52 |
+
|
| 53 |
+
// 清空容器
|
| 54 |
+
d3.select(containerSelector).html("");
|
| 55 |
+
|
| 56 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 57 |
+
|
| 58 |
+
// 设置图表尺寸和边距
|
| 59 |
+
const width = variables.width || 800;
|
| 60 |
+
const height = variables.height || 600;
|
| 61 |
+
|
| 62 |
+
// 边距: 上, 右, 下, 左
|
| 63 |
+
const margin = {
|
| 64 |
+
top: 60, // 柱子上方数值标签的空间
|
| 65 |
+
right: 30,
|
| 66 |
+
bottom: 150, // x轴和图标的空间
|
| 67 |
+
left: 60
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
// 计算实际图表区域尺寸
|
| 71 |
+
const innerWidth = width - margin.left - margin.right;
|
| 72 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 73 |
+
|
| 74 |
+
// ---------- 3. 提取字段名称和单位 ----------
|
| 75 |
+
|
| 76 |
+
let xField, yField;
|
| 77 |
+
let xUnit = "", yUnit = "";
|
| 78 |
+
|
| 79 |
+
// 安全提取字段名称
|
| 80 |
+
const xChannel = chartUtils.schema.channel(jsonData, "x");
|
| 81 |
+
const yChannel = chartUtils.schema.channel(jsonData, "y");
|
| 82 |
+
|
| 83 |
+
xField = xChannel.key;
|
| 84 |
+
yField = yChannel.key;
|
| 85 |
+
|
| 86 |
+
// 获取字段单位(如果存在)
|
| 87 |
+
if (xChannel.unit) {
|
| 88 |
+
xUnit = xChannel.unit;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if (yChannel.unit) {
|
| 92 |
+
yUnit = yChannel.unit;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// ---------- 4. 数据处理 ----------
|
| 96 |
+
|
| 97 |
+
// 获取唯一的x轴值
|
| 98 |
+
const xValues = [...new Set(chartData.map(d => d[xField]))];
|
| 99 |
+
|
| 100 |
+
// 创建按y值降序排序的数据
|
| 101 |
+
const sortedData = chartData.map(d => ({
|
| 102 |
+
x: d[xField],
|
| 103 |
+
y: +d[yField] // 确保y是数值类型
|
| 104 |
+
})).sort((a, b) => b.y - a.y);
|
| 105 |
+
|
| 106 |
+
// ---------- 5. 创建SVG容器 ----------
|
| 107 |
+
|
| 108 |
+
const svg = d3.select(containerSelector)
|
| 109 |
+
.append("svg")
|
| 110 |
+
.attr("width", "100%")
|
| 111 |
+
.attr("height", height)
|
| 112 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 113 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 114 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 115 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
// ---------- 6. 创建视觉效果 ----------
|
| 120 |
+
|
| 121 |
+
const defs = svg.append("defs");
|
| 122 |
+
|
| 123 |
+
// 获取颜色函数
|
| 124 |
+
const getColor = () => colorResolver.other("primary").value;
|
| 125 |
+
|
| 126 |
+
// 如果需要创建阴影滤镜
|
| 127 |
+
if (variables.has_shadow) {
|
| 128 |
+
const filter = defs.append("filter")
|
| 129 |
+
.attr("id", "shadow")
|
| 130 |
+
.attr("filterUnits", "userSpaceOnUse")
|
| 131 |
+
.attr("width", "200%")
|
| 132 |
+
.attr("height", "200%");
|
| 133 |
+
|
| 134 |
+
filter.append("feGaussianBlur")
|
| 135 |
+
.attr("in", "SourceAlpha")
|
| 136 |
+
.attr("stdDeviation", 3);
|
| 137 |
+
|
| 138 |
+
filter.append("feOffset")
|
| 139 |
+
.attr("dx", 2)
|
| 140 |
+
.attr("dy", 2)
|
| 141 |
+
.attr("result", "offsetblur");
|
| 142 |
+
|
| 143 |
+
const feMerge = filter.append("feMerge");
|
| 144 |
+
feMerge.append("feMergeNode");
|
| 145 |
+
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// 如果需要创建渐变
|
| 149 |
+
if (variables.has_gradient) {
|
| 150 |
+
sortedData.forEach((item, idx) => {
|
| 151 |
+
// 添加安全的ID处理
|
| 152 |
+
const safeCategory = typeof item.x === 'string' ?
|
| 153 |
+
item.x.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
|
| 154 |
+
`category-${idx}`;
|
| 155 |
+
|
| 156 |
+
const baseColor = getColor();
|
| 157 |
+
const gradientId = `gradient-${safeCategory}`;
|
| 158 |
+
|
| 159 |
+
const gradient = defs.append("linearGradient")
|
| 160 |
+
.attr("id", gradientId)
|
| 161 |
+
.attr("x1", "0%")
|
| 162 |
+
.attr("y1", "100%")
|
| 163 |
+
.attr("x2", "0%")
|
| 164 |
+
.attr("y2", "0%");
|
| 165 |
+
|
| 166 |
+
gradient.append("stop")
|
| 167 |
+
.attr("offset", "0%")
|
| 168 |
+
.attr("stop-color", colorResolver.variant(baseColor, { mode: "darker", amount: 0.1 }));
|
| 169 |
+
|
| 170 |
+
gradient.append("stop")
|
| 171 |
+
.attr("offset", "100%")
|
| 172 |
+
.attr("stop-color", colorResolver.variant(baseColor, { mode: "brighter", amount: 0.1 }));
|
| 173 |
+
});
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// ---------- 7. 创建图表 ----------
|
| 177 |
+
|
| 178 |
+
// 创建图表组
|
| 179 |
+
const chart = svg.append("g")
|
| 180 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 181 |
+
|
| 182 |
+
// 创建比例尺
|
| 183 |
+
const xScale = d3.scaleBand()
|
| 184 |
+
.domain(sortedData.map(d => d.x))
|
| 185 |
+
.range([0, innerWidth])
|
| 186 |
+
.padding(variables.has_spacing ? 0.4 : 0.2);
|
| 187 |
+
|
| 188 |
+
const yMax = d3.max(sortedData, d => d.y);
|
| 189 |
+
const yScale = d3.scaleLinear()
|
| 190 |
+
.domain([0, yMax > 0 ? yMax * 1.1 : 1]) // 处理y都为0的情况,并添加10%的填充
|
| 191 |
+
.range([innerHeight, 0]);
|
| 192 |
+
|
| 193 |
+
// *******************************************************************
|
| 194 |
+
// ** 开始修改:预计算标签字体大小和最大行数 **
|
| 195 |
+
// *******************************************************************
|
| 196 |
+
const defaultLabelFontSize = parseFloat(typography.label.font_size || 12);
|
| 197 |
+
const minLabelFontSize = 10; // 最小字体大小
|
| 198 |
+
const currentBarWidth = xScale.bandwidth(); // 使用实际的bar宽度
|
| 199 |
+
let finalLabelFontSize = defaultLabelFontSize;
|
| 200 |
+
let maxLinesNeeded = 1; // 记录所有标签所需的最大行数
|
| 201 |
+
const labelFontFamily = typography.label.font_family || "Arial";
|
| 202 |
+
const labelFontWeight = typography.label.font_weight || "normal";
|
| 203 |
+
const lineHeightFactor = 1.2; // 行高倍数
|
| 204 |
+
|
| 205 |
+
// 文本宽度测量辅助函数 (使用 canvas 提高性能)
|
| 206 |
+
const canvas = document.createElement('canvas');
|
| 207 |
+
const ctx = canvas.getContext('2d');
|
| 208 |
+
function measureTextWidth(text, fontFamily, fontSize, fontWeight) {
|
| 209 |
+
ctx.font = `${fontWeight || 'normal'} ${fontSize}px ${fontFamily || 'Arial'}`;
|
| 210 |
+
return chartUtils.text.contextWidth(ctx, text);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// 第一遍计算:确定统一的字体大小
|
| 214 |
+
if (currentBarWidth > 0) { // 确保 barWidth 有效
|
| 215 |
+
let maxRatio = 1;
|
| 216 |
+
sortedData.forEach(d => {
|
| 217 |
+
const labelText = String(d.x); // 确保是字符串
|
| 218 |
+
const textWidth = measureTextWidth(labelText, labelFontFamily, defaultLabelFontSize, labelFontWeight);
|
| 219 |
+
const ratio = textWidth / currentBarWidth;
|
| 220 |
+
if (ratio > maxRatio) {
|
| 221 |
+
maxRatio = ratio;
|
| 222 |
+
}
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
if (maxRatio > 1) {
|
| 226 |
+
finalLabelFontSize = Math.max(minLabelFontSize, Math.floor(defaultLabelFontSize / maxRatio));
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// 第二遍计算:确定是否需要换行以及最大行数 (使用最终字体大小)
|
| 230 |
+
sortedData.forEach(d => {
|
| 231 |
+
const labelText = String(d.x);
|
| 232 |
+
const textWidth = measureTextWidth(labelText, labelFontFamily, finalLabelFontSize, labelFontWeight);
|
| 233 |
+
|
| 234 |
+
if (textWidth > currentBarWidth) {
|
| 235 |
+
// 模拟换行计算行数
|
| 236 |
+
const words = labelText.split(/\s+/); // 按空格分词
|
| 237 |
+
let currentLine = '';
|
| 238 |
+
let lines = 1;
|
| 239 |
+
let simulationSuccess = false; // 标记模拟是否成功
|
| 240 |
+
|
| 241 |
+
if (words.length > 1) { // 优先按单词换行
|
| 242 |
+
for (let i = 0; i < words.length; i++) {
|
| 243 |
+
const testLine = currentLine ? currentLine + " " + words[i] : words[i];
|
| 244 |
+
const testWidth = measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight);
|
| 245 |
+
if (testWidth > currentBarWidth && currentLine !== '') {
|
| 246 |
+
lines++;
|
| 247 |
+
currentLine = words[i];
|
| 248 |
+
// 检查单个单词是否也超长
|
| 249 |
+
if (measureTextWidth(currentLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > currentBarWidth) {
|
| 250 |
+
// 单个单词超长,需要转字符换行
|
| 251 |
+
simulationSuccess = false;
|
| 252 |
+
break; // 退出单词循环
|
| 253 |
+
}
|
| 254 |
+
} else {
|
| 255 |
+
currentLine = testLine;
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
if(currentLine !== '') simulationSuccess = true; // 如果有剩余单词则标记成功
|
| 259 |
+
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// 如果单词换行不成功或只有一个单词,尝试字符换行
|
| 263 |
+
if(words.length <= 1 || !simulationSuccess) {
|
| 264 |
+
lines = 1; // 重置行数
|
| 265 |
+
const chars = labelText.split('');
|
| 266 |
+
currentLine = '';
|
| 267 |
+
for (let i = 0; i < chars.length; i++) {
|
| 268 |
+
const testLine = currentLine + chars[i];
|
| 269 |
+
if (measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > currentBarWidth && currentLine !== '') {
|
| 270 |
+
lines++;
|
| 271 |
+
currentLine = chars[i];
|
| 272 |
+
} else {
|
| 273 |
+
currentLine += chars[i];
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
if (lines > maxLinesNeeded) {
|
| 280 |
+
maxLinesNeeded = lines;
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
});
|
| 284 |
+
} else {
|
| 285 |
+
// 如果barWidth无效,则使用默认字体大小且不换行
|
| 286 |
+
finalLabelFontSize = defaultLabelFontSize;
|
| 287 |
+
maxLinesNeeded = 1;
|
| 288 |
+
}
|
| 289 |
+
// *******************************************************************
|
| 290 |
+
// ** 结束修改:预计算标签字体大小和最大行数 **
|
| 291 |
+
// *******************************************************************
|
| 292 |
+
|
| 293 |
+
// *******************************************************************
|
| 294 |
+
// ** 开始修改:计算图标的统一Y位置 **
|
| 295 |
+
// *******************************************************************
|
| 296 |
+
const labelStartY = innerHeight + 10; // 标签开始的 Y 位置 (保持和原来一致)
|
| 297 |
+
const lineHeight = finalLabelFontSize * lineHeightFactor; // 行高
|
| 298 |
+
const labelBottomApprox = labelStartY + (maxLinesNeeded - 1) * lineHeight + finalLabelFontSize * 0.71; // 估算最下方标签基线位置
|
| 299 |
+
const iconRadius = 15; // 图标半径
|
| 300 |
+
const iconYPosition = labelBottomApprox + iconRadius + 5; // 在标签下方留出 5px 间距放置图标中心
|
| 301 |
+
const iconBottomY = iconYPosition + iconRadius; // 图标底部的Y坐标
|
| 302 |
+
const barExtensionBuffer = 5; // 柱子比图标底部多延伸的距离
|
| 303 |
+
const barBottomY = iconBottomY + barExtensionBuffer; // 柱子需要延伸到的最终Y坐标
|
| 304 |
+
// *******************************************************************
|
| 305 |
+
// ** 结束修改:计算图标的统一Y位置 **
|
| 306 |
+
// *******************************************************************
|
| 307 |
+
|
| 308 |
+
// 添加延伸到图标的柱子
|
| 309 |
+
chart.selectAll(".bar")
|
| 310 |
+
.data(sortedData)
|
| 311 |
+
.enter()
|
| 312 |
+
.append("rect")
|
| 313 |
+
.attr("class", "bar")
|
| 314 |
+
.attr("x", d => xScale(d.x))
|
| 315 |
+
.attr("y", d => yScale(d.y))
|
| 316 |
+
.attr("width", xScale.bandwidth())
|
| 317 |
+
.attr("height", d => Math.max(0, barBottomY - yScale(d.y)))
|
| 318 |
+
.attr("fill", d => {
|
| 319 |
+
if (variables.has_gradient) {
|
| 320 |
+
const safeCategory = typeof d.x === 'string' ?
|
| 321 |
+
d.x.toString().replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() :
|
| 322 |
+
`category-${sortedData.findIndex(item => item.x === d.x)}`; // 修正: 使用 findIndex 确保唯一性
|
| 323 |
+
return `url(#gradient-${safeCategory})`;
|
| 324 |
+
}
|
| 325 |
+
return getColor();
|
| 326 |
+
})
|
| 327 |
+
.attr("rx", variables.has_rounded_corners ? 4 : 0)
|
| 328 |
+
.attr("ry", variables.has_rounded_corners ? 4 : 0)
|
| 329 |
+
.style("stroke", variables.has_stroke ? "#333" : "none")
|
| 330 |
+
.style("stroke-width", variables.has_stroke ? 1 : 0)
|
| 331 |
+
.style("filter", variables.has_shadow ? "url(#shadow)" : "none")
|
| 332 |
+
.on("mouseover", function() {
|
| 333 |
+
d3.select(this).attr("opacity", 0.8);
|
| 334 |
+
})
|
| 335 |
+
.on("mouseout", function() {
|
| 336 |
+
d3.select(this).attr("opacity", 1);
|
| 337 |
+
});
|
| 338 |
+
|
| 339 |
+
// 在柱子上方添加数值标签
|
| 340 |
+
const defaultAnnotationFontSize = parseFloat(typography.annotation.font_size || 12);
|
| 341 |
+
const minAnnotationFontSize = 6; // 数值标签最小字体
|
| 342 |
+
const annotationFontFamily = typography.annotation.font_family || "Arial";
|
| 343 |
+
const annotationFontWeight = typography.annotation.font_weight || "bold";
|
| 344 |
+
|
| 345 |
+
chart.selectAll(".value-label")
|
| 346 |
+
.data(sortedData)
|
| 347 |
+
.enter()
|
| 348 |
+
.append("text")
|
| 349 |
+
.attr("class", "value-label")
|
| 350 |
+
.attr("x", d => xScale(d.x) + xScale.bandwidth() / 2)
|
| 351 |
+
.attr("y", d => yScale(d.y) - 5)
|
| 352 |
+
.attr("text-anchor", "middle")
|
| 353 |
+
.style("font-family", annotationFontFamily)
|
| 354 |
+
.style("font-weight", annotationFontWeight)
|
| 355 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 356 |
+
.each(function(d) {
|
| 357 |
+
const valueText = chartUtils.format.fixed(d.y, 1, { unit: yUnit }).text;
|
| 358 |
+
const maxWidth = xScale.bandwidth() * 1.1; // 最大允许宽度为柱宽的1.1倍
|
| 359 |
+
let finalValueFontSize = defaultAnnotationFontSize;
|
| 360 |
+
|
| 361 |
+
if (maxWidth > 0) { // 仅当宽度有效时计算
|
| 362 |
+
const textWidth = measureTextWidth(valueText, annotationFontFamily, defaultAnnotationFontSize, annotationFontWeight);
|
| 363 |
+
if (textWidth > maxWidth) {
|
| 364 |
+
finalValueFontSize = Math.max(minAnnotationFontSize, Math.floor(defaultAnnotationFontSize * (maxWidth / textWidth)));
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
d3.select(this)
|
| 369 |
+
.style("font-size", `${finalValueFontSize}px`)
|
| 370 |
+
.text(valueText);
|
| 371 |
+
});
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
// *******************************************************************
|
| 375 |
+
// ** 开始修改:添加维度标签 (统一字体大小,自动换行) **
|
| 376 |
+
// *******************************************************************
|
| 377 |
+
chart.selectAll(".dimension-label")
|
| 378 |
+
.data(sortedData)
|
| 379 |
+
.enter()
|
| 380 |
+
.append("text")
|
| 381 |
+
.attr("class", "dimension-label")
|
| 382 |
+
.attr("text-anchor", "middle")
|
| 383 |
+
.style("font-family", labelFontFamily)
|
| 384 |
+
.style("font-size", `${finalLabelFontSize}px`) // 应用预计算的字体大小
|
| 385 |
+
.style("font-weight", labelFontWeight)
|
| 386 |
+
.style("fill", "#ffffff") // 使用默认文本颜色
|
| 387 |
+
.each(function(d) {
|
| 388 |
+
const labelText = String(d.x);
|
| 389 |
+
const textWidth = measureTextWidth(labelText, labelFontFamily, finalLabelFontSize, labelFontWeight);
|
| 390 |
+
const textElement = d3.select(this);
|
| 391 |
+
const xPos = xScale(d.x) + xScale.bandwidth() / 2;
|
| 392 |
+
const availableWidth = xScale.bandwidth();
|
| 393 |
+
|
| 394 |
+
if (textWidth > availableWidth && availableWidth > 0) {
|
| 395 |
+
// 需要换行 (重复之前的换行逻辑来渲染)
|
| 396 |
+
const words = labelText.split(/\s+/);
|
| 397 |
+
let lines = [];
|
| 398 |
+
let currentLine = '';
|
| 399 |
+
let simulationSuccess = false;
|
| 400 |
+
|
| 401 |
+
if(words.length > 1){
|
| 402 |
+
for (let i = 0; i < words.length; i++) {
|
| 403 |
+
const testLine = currentLine ? currentLine + " " + words[i] : words[i];
|
| 404 |
+
const testWidth = measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight);
|
| 405 |
+
if (testWidth > availableWidth && currentLine !== '') {
|
| 406 |
+
lines.push(currentLine);
|
| 407 |
+
currentLine = words[i];
|
| 408 |
+
if (measureTextWidth(currentLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > availableWidth) {
|
| 409 |
+
simulationSuccess = false; break;
|
| 410 |
+
}
|
| 411 |
+
} else { currentLine = testLine; }
|
| 412 |
+
}
|
| 413 |
+
if(currentLine !== '') { lines.push(currentLine); simulationSuccess = true; }
|
| 414 |
+
}
|
| 415 |
+
if(words.length <= 1 || !simulationSuccess) {
|
| 416 |
+
lines = [];
|
| 417 |
+
const chars = labelText.split('');
|
| 418 |
+
currentLine = '';
|
| 419 |
+
for (let i = 0; i < chars.length; i++) {
|
| 420 |
+
const testLine = currentLine + chars[i];
|
| 421 |
+
if (measureTextWidth(testLine, labelFontFamily, finalLabelFontSize, labelFontWeight) > availableWidth && currentLine !== '') {
|
| 422 |
+
lines.push(currentLine); currentLine = chars[i];
|
| 423 |
+
} else { currentLine += chars[i]; }
|
| 424 |
+
}
|
| 425 |
+
lines.push(currentLine);
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
lines.forEach((line, i) => {
|
| 429 |
+
textElement.append("tspan")
|
| 430 |
+
.attr("x", xPos)
|
| 431 |
+
.attr("y", labelStartY + i * lineHeight)
|
| 432 |
+
.attr("dy", "0.71em")
|
| 433 |
+
.text(line);
|
| 434 |
+
});
|
| 435 |
+
} else {
|
| 436 |
+
// 不需要换行
|
| 437 |
+
textElement.append("tspan")
|
| 438 |
+
.attr("x", xPos)
|
| 439 |
+
.attr("y", labelStartY)
|
| 440 |
+
.attr("dy", "0.71em")
|
| 441 |
+
.text(labelText);
|
| 442 |
+
}
|
| 443 |
+
});
|
| 444 |
+
// *******************************************************************
|
| 445 |
+
// ** 结束修改:添加维度标签 **
|
| 446 |
+
// *******************************************************************
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
// *******************************************************************
|
| 450 |
+
// ** 开始修改:添加圆形图标 - 动态调整 Y 位置 **
|
| 451 |
+
// *******************************************************************
|
| 452 |
+
chart.selectAll(".icon-circle")
|
| 453 |
+
.data(sortedData)
|
| 454 |
+
.enter()
|
| 455 |
+
.append("g")
|
| 456 |
+
.attr("class", "icon-circle")
|
| 457 |
+
.attr("transform", d => `translate(${xScale(d.x) + xScale.bandwidth() / 2}, ${iconYPosition})`) // 应用动态Y位置
|
| 458 |
+
.each(function(d) {
|
| 459 |
+
// 添加圆形轮廓
|
| 460 |
+
d3.select(this)
|
| 461 |
+
.append("circle")
|
| 462 |
+
.attr("r", iconRadius) // 使用变量
|
| 463 |
+
.attr("fill", "none") // 无填充
|
| 464 |
+
.attr("stroke", "#2D3748") // 更深灰色的描边
|
| 465 |
+
.attr("stroke-width", 0.1);
|
| 466 |
+
|
| 467 |
+
// 如果有该类别的图像
|
| 468 |
+
if (images && images.field && images.field[d.x]) {
|
| 469 |
+
d3.select(this)
|
| 470 |
+
.append("image")
|
| 471 |
+
.attr("xlink:href", images.field[d.x])
|
| 472 |
+
.attr("x", -12)
|
| 473 |
+
.attr("y", -12)
|
| 474 |
+
.attr("width", 24)
|
| 475 |
+
.attr("preserveAspectRatio","xMidYMid meet")
|
| 476 |
+
.attr("height", 24);
|
| 477 |
+
}
|
| 478 |
+
});
|
| 479 |
+
// *******************************************************************
|
| 480 |
+
// ** 结束修改:添加圆形图标 **
|
| 481 |
+
// *******************************************************************
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
// 返回SVG节点
|
| 485 |
+
return svg.node();
|
| 486 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_01.js
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Line Graph",
|
| 5 |
+
"chart_name": "multiple_line_graph_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[4, 30], ["-inf", "inf"], [2, 8]],
|
| 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": 600,
|
| 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 |
+
d3.select(containerSelector).html("");
|
| 35 |
+
|
| 36 |
+
const width = variables.width || 1024;
|
| 37 |
+
const height = variables.height || 1536;
|
| 38 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 39 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 40 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 41 |
+
const yColumn = dataColumns[1] || {};
|
| 42 |
+
const yUnit = yColumn.unit || chartUtils.schema.channel(jsonData, "y").unit || "";
|
| 43 |
+
const titleText = (jsonData.titles && jsonData.titles.main_title) || (jsonData.metadata && jsonData.metadata.title) || "Multiple Line Trend";
|
| 44 |
+
const subtitleText = (jsonData.titles && jsonData.titles.sub_title) || (jsonData.metadata && jsonData.metadata.description) || "";
|
| 45 |
+
const labelFont = (typography.label && typography.label.font_family) || "Arial, Helvetica, sans-serif";
|
| 46 |
+
|
| 47 |
+
const parseTemporal = (value) => {
|
| 48 |
+
if (value instanceof Date) return value;
|
| 49 |
+
if (typeof value === "number" && Number.isFinite(value)) {
|
| 50 |
+
return new Date(value, 0, 1);
|
| 51 |
+
}
|
| 52 |
+
const asNumber = Number(value);
|
| 53 |
+
if (Number.isFinite(asNumber) && String(value).trim().length <= 4) {
|
| 54 |
+
return new Date(asNumber, 0, 1);
|
| 55 |
+
}
|
| 56 |
+
const parsedByUtils = chartUtils.format.parseDate(value);
|
| 57 |
+
if (parsedByUtils instanceof Date && !Number.isNaN(+parsedByUtils)) return parsedByUtils;
|
| 58 |
+
const parsed = new Date(value);
|
| 59 |
+
if (!Number.isNaN(+parsed)) return parsed;
|
| 60 |
+
return new Date(0);
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
const sanitizeId = (value) => String(value).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "item";
|
| 64 |
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
| 65 |
+
const approxWidth = (text, fontSize, weight) => String(text).length * fontSize * (weight >= 700 ? 0.62 : 0.54);
|
| 66 |
+
const lighten = (hex, amount) => {
|
| 67 |
+
const c = d3.color(hex);
|
| 68 |
+
if (!c) return hex;
|
| 69 |
+
c.r = Math.round(c.r + (255 - c.r) * amount);
|
| 70 |
+
c.g = Math.round(c.g + (255 - c.g) * amount);
|
| 71 |
+
c.b = Math.round(c.b + (255 - c.b) * amount);
|
| 72 |
+
return c.formatHex();
|
| 73 |
+
};
|
| 74 |
+
const darken = (hex, amount) => {
|
| 75 |
+
const c = d3.color(hex);
|
| 76 |
+
if (!c) return hex;
|
| 77 |
+
c.r = Math.round(c.r * (1 - amount));
|
| 78 |
+
c.g = Math.round(c.g * (1 - amount));
|
| 79 |
+
c.b = Math.round(c.b * (1 - amount));
|
| 80 |
+
return c.formatHex();
|
| 81 |
+
};
|
| 82 |
+
const formatX = (date) => {
|
| 83 |
+
if (date.getMonth() === 0 && date.getDate() === 1) return String(date.getFullYear());
|
| 84 |
+
return d3.timeFormat("%b %Y")(date);
|
| 85 |
+
};
|
| 86 |
+
const formatValue = (value) => {
|
| 87 |
+
const num = Number(value);
|
| 88 |
+
const rounded = Math.abs(num) >= 100 ? Math.round(num) : Math.round(num * 10) / 10;
|
| 89 |
+
const display = Number.isInteger(rounded) ? String(rounded) : String(rounded.toFixed(1)).replace(/\.0$/, "");
|
| 90 |
+
return yUnit === "%" ? `${display}%` : display;
|
| 91 |
+
};
|
| 92 |
+
const splitTitle = (title, maxChars) => {
|
| 93 |
+
if (title.includes(",")) {
|
| 94 |
+
const parts = title.split(",");
|
| 95 |
+
const first = `${parts.shift().trim()},`;
|
| 96 |
+
const second = parts.join(",").trim();
|
| 97 |
+
return [first, second].filter(Boolean);
|
| 98 |
+
}
|
| 99 |
+
const words = title.split(/\s+/).filter(Boolean);
|
| 100 |
+
const lines = [];
|
| 101 |
+
let line = "";
|
| 102 |
+
words.forEach((word) => {
|
| 103 |
+
const candidate = line ? `${line} ${word}` : word;
|
| 104 |
+
if (candidate.length > maxChars && line) {
|
| 105 |
+
lines.push(line);
|
| 106 |
+
line = word;
|
| 107 |
+
} else {
|
| 108 |
+
line = candidate;
|
| 109 |
+
}
|
| 110 |
+
});
|
| 111 |
+
if (line) lines.push(line);
|
| 112 |
+
return lines.slice(0, 2);
|
| 113 |
+
};
|
| 114 |
+
const wrapWordsByWidth = (text, fontSize, maxWidth) => {
|
| 115 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 116 |
+
const lines = [];
|
| 117 |
+
let line = "";
|
| 118 |
+
words.forEach((word) => {
|
| 119 |
+
const candidate = line ? `${line} ${word}` : word;
|
| 120 |
+
if (approxWidth(candidate, fontSize, 900) > maxWidth && line) {
|
| 121 |
+
lines.push(line);
|
| 122 |
+
line = word;
|
| 123 |
+
} else {
|
| 124 |
+
line = candidate;
|
| 125 |
+
}
|
| 126 |
+
});
|
| 127 |
+
if (line) lines.push(line);
|
| 128 |
+
return lines;
|
| 129 |
+
};
|
| 130 |
+
const wrapText = (selection, text, maxWidth, lineHeight, maxLines) => {
|
| 131 |
+
const fontSize = Number(selection.attr("font-size")) || 18;
|
| 132 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 133 |
+
const lines = [];
|
| 134 |
+
let line = "";
|
| 135 |
+
words.forEach((word) => {
|
| 136 |
+
const candidate = line ? `${line} ${word}` : word;
|
| 137 |
+
if (approxWidth(candidate, fontSize, 500) > maxWidth && line) {
|
| 138 |
+
lines.push(line);
|
| 139 |
+
line = word;
|
| 140 |
+
} else {
|
| 141 |
+
line = candidate;
|
| 142 |
+
}
|
| 143 |
+
});
|
| 144 |
+
if (line) lines.push(line);
|
| 145 |
+
const visible = lines.slice(0, maxLines);
|
| 146 |
+
if (lines.length > maxLines && visible.length) {
|
| 147 |
+
visible[visible.length - 1] = `${visible[visible.length - 1].replace(/[.,;:]$/, "")}...`;
|
| 148 |
+
}
|
| 149 |
+
selection.text(null);
|
| 150 |
+
visible.forEach((lineText, i) => {
|
| 151 |
+
selection.append("tspan")
|
| 152 |
+
.attr("x", selection.attr("x"))
|
| 153 |
+
.attr("dy", i === 0 ? 0 : lineHeight)
|
| 154 |
+
.text(lineText);
|
| 155 |
+
});
|
| 156 |
+
return visible.length;
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
const groups = Array.from(new Set(chartData.map((d) => String(d[groupField]))));
|
| 160 |
+
const rowsByGroup = new Map(groups.map((group) => {
|
| 161 |
+
const rows = chartData
|
| 162 |
+
.filter((d) => String(d[groupField]) === group)
|
| 163 |
+
.slice()
|
| 164 |
+
.sort((a, b) => +parseTemporal(a[xField]) - +parseTemporal(b[xField]));
|
| 165 |
+
return [group, rows];
|
| 166 |
+
}));
|
| 167 |
+
const xValues = Array.from(new Set(chartData.map((d) => +parseTemporal(d[xField]))))
|
| 168 |
+
.sort((a, b) => a - b)
|
| 169 |
+
.map((v) => new Date(v));
|
| 170 |
+
const yValues = chartData.map((d) => Number(d[yField])).filter(Number.isFinite);
|
| 171 |
+
const allPositive = d3.min(yValues) >= 0;
|
| 172 |
+
const yMinRaw = d3.min(yValues);
|
| 173 |
+
const yMaxRaw = d3.max(yValues);
|
| 174 |
+
const yMax = allPositive ? Math.ceil((yMaxRaw || 1) / 10) * 10 : yMaxRaw + Math.abs(yMaxRaw - yMinRaw) * 0.12;
|
| 175 |
+
const yMin = allPositive ? 0 : yMinRaw - Math.abs(yMaxRaw - yMinRaw) * 0.12;
|
| 176 |
+
|
| 177 |
+
const getColor = (group, index) => {
|
| 178 |
+
const resolved = colorResolver.field(group, index, { fallbackKey: index === 0 ? "primary" : "secondary" }).value;
|
| 179 |
+
const fallback = ["#38e7b4", "#ff785f", "#9c7cff", "#f5c84c", "#48b7ff", "#8bd45c", "#f08bd8", "#f58d44"][index % 8];
|
| 180 |
+
return resolved || fallback;
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
const svg = d3.select(containerSelector)
|
| 184 |
+
.append("svg")
|
| 185 |
+
.attr("width", "100%")
|
| 186 |
+
.attr("height", height)
|
| 187 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 188 |
+
.attr("style", "max-width: 100%; height: auto; font-family: Arial, Helvetica, sans-serif;")
|
| 189 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 190 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 191 |
+
|
| 192 |
+
const defs = svg.append("defs");
|
| 193 |
+
const bgGradient = defs.append("radialGradient")
|
| 194 |
+
.attr("id", "mlg01-bg-glow")
|
| 195 |
+
.attr("cx", "48%")
|
| 196 |
+
.attr("cy", "48%")
|
| 197 |
+
.attr("r", "82%");
|
| 198 |
+
bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#064244");
|
| 199 |
+
bgGradient.append("stop").attr("offset", "48%").attr("stop-color", "#042c36");
|
| 200 |
+
bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#031522");
|
| 201 |
+
const panelGradient = defs.append("linearGradient")
|
| 202 |
+
.attr("id", "mlg01-panel-fill")
|
| 203 |
+
.attr("x1", "0%")
|
| 204 |
+
.attr("x2", "100%")
|
| 205 |
+
.attr("y1", "0%")
|
| 206 |
+
.attr("y2", "100%");
|
| 207 |
+
panelGradient.append("stop").attr("offset", "0%").attr("stop-color", "#082c36").attr("stop-opacity", 0.92);
|
| 208 |
+
panelGradient.append("stop").attr("offset", "100%").attr("stop-color", "#021825").attr("stop-opacity", 0.82);
|
| 209 |
+
const glow = defs.append("filter").attr("id", "mlg01-soft-glow").attr("x", "-30%").attr("y", "-30%").attr("width", "160%").attr("height", "160%");
|
| 210 |
+
glow.append("feGaussianBlur").attr("stdDeviation", "4").attr("result", "blur");
|
| 211 |
+
const merge = glow.append("feMerge");
|
| 212 |
+
merge.append("feMergeNode").attr("in", "blur");
|
| 213 |
+
merge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 214 |
+
|
| 215 |
+
groups.forEach((group, index) => {
|
| 216 |
+
const color = getColor(group, index);
|
| 217 |
+
const id = sanitizeId(group);
|
| 218 |
+
const areaGradient = defs.append("linearGradient")
|
| 219 |
+
.attr("id", `mlg01-area-${id}`)
|
| 220 |
+
.attr("x1", "0%")
|
| 221 |
+
.attr("x2", "0%")
|
| 222 |
+
.attr("y1", "0%")
|
| 223 |
+
.attr("y2", "100%");
|
| 224 |
+
areaGradient.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 0.38);
|
| 225 |
+
areaGradient.append("stop").attr("offset", "62%").attr("stop-color", color).attr("stop-opacity", 0.16);
|
| 226 |
+
areaGradient.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0.02);
|
| 227 |
+
defs.append("clipPath")
|
| 228 |
+
.attr("id", `mlg01-icon-clip-${id}`)
|
| 229 |
+
.append("circle")
|
| 230 |
+
.attr("cx", 0)
|
| 231 |
+
.attr("cy", 0)
|
| 232 |
+
.attr("r", 54);
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
svg.append("rect").attr("width", width).attr("height", height).attr("fill", "url(#mlg01-bg-glow)");
|
| 236 |
+
svg.append("circle").attr("cx", 120).attr("cy", 118).attr("r", 340).attr("fill", "#0b5960").attr("opacity", 0.12);
|
| 237 |
+
svg.append("circle").attr("cx", 920).attr("cy", 1260).attr("r", 420).attr("fill", "#09a881").attr("opacity", 0.06);
|
| 238 |
+
|
| 239 |
+
const headerX = 56;
|
| 240 |
+
const accent = "#4de3a0";
|
| 241 |
+
const white = "#f7f9f8";
|
| 242 |
+
const muted = "#d9e2df";
|
| 243 |
+
|
| 244 |
+
const header = svg.append("g").attr("class", "mlg01-header");
|
| 245 |
+
const badge = header.append("g")
|
| 246 |
+
.attr("class", "mlg01-kicker-badge")
|
| 247 |
+
.attr("transform", `translate(${headerX},62)`);
|
| 248 |
+
badge.append("rect")
|
| 249 |
+
.attr("width", 286)
|
| 250 |
+
.attr("height", 54)
|
| 251 |
+
.attr("rx", 26)
|
| 252 |
+
.attr("fill", "#062a30")
|
| 253 |
+
.attr("stroke", "#0f8077")
|
| 254 |
+
.attr("stroke-width", 1.5)
|
| 255 |
+
.attr("opacity", 0.96);
|
| 256 |
+
const kickerSlot = badge.append("g")
|
| 257 |
+
.attr("data-asset-slot", "kicker-icon")
|
| 258 |
+
.attr("data-no-embedded-raster", "true")
|
| 259 |
+
.attr("data-no-copied-reference-art", "true")
|
| 260 |
+
.attr("data-anchor", "header-badge-left")
|
| 261 |
+
.attr("data-collision-rule", "inside badge before kicker text")
|
| 262 |
+
.attr("transform", "translate(35,27)");
|
| 263 |
+
kickerSlot.append("circle").attr("r", 11).attr("fill", "none").attr("stroke", accent).attr("stroke-width", 2);
|
| 264 |
+
kickerSlot.append("circle").attr("r", 4).attr("fill", accent).attr("opacity", 0.9);
|
| 265 |
+
badge.append("text")
|
| 266 |
+
.attr("x", 62)
|
| 267 |
+
.attr("y", 34)
|
| 268 |
+
.attr("font-family", labelFont)
|
| 269 |
+
.attr("font-size", 22)
|
| 270 |
+
.attr("font-weight", 800)
|
| 271 |
+
.attr("fill", accent)
|
| 272 |
+
.attr("letter-spacing", 1.5)
|
| 273 |
+
.text("SHIFTING TRENDS");
|
| 274 |
+
|
| 275 |
+
let titleFont = titleText.length > 70 ? 46 : titleText.length > 54 ? 52 : 62;
|
| 276 |
+
let titleLines = splitTitle(titleText, titleText.includes(",") ? 34 : 26);
|
| 277 |
+
if (!titleText.includes(",")) {
|
| 278 |
+
titleFont = titleText.length > 58 ? 44 : titleText.length > 45 ? 52 : titleFont;
|
| 279 |
+
titleLines = wrapWordsByWidth(titleText, titleFont, 900);
|
| 280 |
+
while (titleLines.length > 2 && titleFont > 38) {
|
| 281 |
+
titleFont -= 2;
|
| 282 |
+
titleLines = wrapWordsByWidth(titleText, titleFont, 900);
|
| 283 |
+
}
|
| 284 |
+
titleLines = titleLines.slice(0, 2);
|
| 285 |
+
}
|
| 286 |
+
const titleLineGap = titleFont + 10;
|
| 287 |
+
const titleBaseY = 176;
|
| 288 |
+
titleLines.forEach((lineText, i) => {
|
| 289 |
+
header.append("text")
|
| 290 |
+
.attr("x", headerX)
|
| 291 |
+
.attr("y", titleBaseY + i * titleLineGap)
|
| 292 |
+
.attr("font-family", "Arial, Helvetica, sans-serif")
|
| 293 |
+
.attr("font-size", titleFont)
|
| 294 |
+
.attr("font-weight", 900)
|
| 295 |
+
.attr("fill", i === 0 ? white : accent)
|
| 296 |
+
.attr("letter-spacing", 0)
|
| 297 |
+
.attr("filter", "url(#mlg01-soft-glow)")
|
| 298 |
+
.text(lineText);
|
| 299 |
+
});
|
| 300 |
+
const subtitleY = titleBaseY + titleLines.length * titleLineGap + 12;
|
| 301 |
+
const subtitle = header.append("text")
|
| 302 |
+
.attr("x", headerX)
|
| 303 |
+
.attr("y", subtitleY)
|
| 304 |
+
.attr("font-family", labelFont)
|
| 305 |
+
.attr("font-size", 25)
|
| 306 |
+
.attr("font-weight", 400)
|
| 307 |
+
.attr("fill", muted)
|
| 308 |
+
.attr("opacity", 0.95);
|
| 309 |
+
subtitle.text(subtitleText);
|
| 310 |
+
if (approxWidth(subtitleText, 25, 400) > 720) {
|
| 311 |
+
subtitle.attr("textLength", 720).attr("lengthAdjust", "spacingAndGlyphs");
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
const panel = { x: 42, y: 360, w: 706, h: 988 };
|
| 315 |
+
const plot = { x: 98, y: 446, w: 490, h: 820 };
|
| 316 |
+
const cardColumn = { x: 778, y: 360, w: 202, h: 988 };
|
| 317 |
+
|
| 318 |
+
svg.append("rect")
|
| 319 |
+
.attr("class", "mlg01-plot-panel")
|
| 320 |
+
.attr("x", panel.x)
|
| 321 |
+
.attr("y", panel.y)
|
| 322 |
+
.attr("width", panel.w)
|
| 323 |
+
.attr("height", panel.h)
|
| 324 |
+
.attr("rx", 28)
|
| 325 |
+
.attr("fill", "url(#mlg01-panel-fill)")
|
| 326 |
+
.attr("stroke", "#23606c")
|
| 327 |
+
.attr("stroke-width", 1.5)
|
| 328 |
+
.attr("opacity", 0.96);
|
| 329 |
+
|
| 330 |
+
const chart = svg.append("g").attr("class", "mlg01-chart-layer");
|
| 331 |
+
chart.append("text")
|
| 332 |
+
.attr("x", panel.x + 32)
|
| 333 |
+
.attr("y", panel.y + 52)
|
| 334 |
+
.attr("font-family", labelFont)
|
| 335 |
+
.attr("font-size", 22)
|
| 336 |
+
.attr("font-weight", 800)
|
| 337 |
+
.attr("fill", white)
|
| 338 |
+
.attr("letter-spacing", 0.5)
|
| 339 |
+
.text(`${String(yField).replace(/([a-z])([A-Z])/g, "$1 $2").toUpperCase()}${yUnit ? ` (${yUnit})` : ""}`);
|
| 340 |
+
|
| 341 |
+
const xScale = d3.scaleTime()
|
| 342 |
+
.domain(d3.extent(xValues))
|
| 343 |
+
.range([plot.x, plot.x + plot.w]);
|
| 344 |
+
const yScale = d3.scaleLinear()
|
| 345 |
+
.domain([yMin, yMax])
|
| 346 |
+
.nice(5)
|
| 347 |
+
.range([plot.y + plot.h, plot.y]);
|
| 348 |
+
const yTicks = yScale.ticks(6);
|
| 349 |
+
const xTickStep = Math.max(1, Math.ceil(xValues.length / 8));
|
| 350 |
+
const xTicks = xValues.filter((_, i) => i % xTickStep === 0 || i === xValues.length - 1);
|
| 351 |
+
|
| 352 |
+
chart.selectAll("line.mlg01-y-grid")
|
| 353 |
+
.data(yTicks)
|
| 354 |
+
.enter()
|
| 355 |
+
.append("line")
|
| 356 |
+
.attr("class", "mlg01-y-grid")
|
| 357 |
+
.attr("x1", plot.x)
|
| 358 |
+
.attr("x2", plot.x + plot.w)
|
| 359 |
+
.attr("y1", (d) => yScale(d))
|
| 360 |
+
.attr("y2", (d) => yScale(d))
|
| 361 |
+
.attr("stroke", "#ffffff")
|
| 362 |
+
.attr("stroke-width", 1)
|
| 363 |
+
.attr("stroke-dasharray", "4 4")
|
| 364 |
+
.attr("opacity", 0.18);
|
| 365 |
+
chart.selectAll("text.mlg01-y-tick")
|
| 366 |
+
.data(yTicks)
|
| 367 |
+
.enter()
|
| 368 |
+
.append("text")
|
| 369 |
+
.attr("class", "mlg01-y-tick")
|
| 370 |
+
.attr("x", plot.x - 16)
|
| 371 |
+
.attr("y", (d) => yScale(d) + 8)
|
| 372 |
+
.attr("text-anchor", "end")
|
| 373 |
+
.attr("font-family", labelFont)
|
| 374 |
+
.attr("font-size", 26)
|
| 375 |
+
.attr("font-weight", 400)
|
| 376 |
+
.attr("fill", white)
|
| 377 |
+
.attr("opacity", 0.92)
|
| 378 |
+
.text((d) => d);
|
| 379 |
+
chart.selectAll("text.mlg01-x-tick")
|
| 380 |
+
.data(xTicks)
|
| 381 |
+
.enter()
|
| 382 |
+
.append("text")
|
| 383 |
+
.attr("class", "mlg01-x-tick")
|
| 384 |
+
.attr("x", (d) => xScale(d))
|
| 385 |
+
.attr("y", plot.y + plot.h + 34)
|
| 386 |
+
.attr("text-anchor", "middle")
|
| 387 |
+
.attr("font-family", labelFont)
|
| 388 |
+
.attr("font-size", 22)
|
| 389 |
+
.attr("font-weight", 500)
|
| 390 |
+
.attr("fill", white)
|
| 391 |
+
.text((d) => formatX(d));
|
| 392 |
+
chart.append("line")
|
| 393 |
+
.attr("x1", plot.x)
|
| 394 |
+
.attr("x2", plot.x + plot.w + 8)
|
| 395 |
+
.attr("y1", yScale(0))
|
| 396 |
+
.attr("y2", yScale(0))
|
| 397 |
+
.attr("stroke", "#ffffff")
|
| 398 |
+
.attr("stroke-width", 1.2)
|
| 399 |
+
.attr("opacity", 0.75);
|
| 400 |
+
|
| 401 |
+
const area = d3.area()
|
| 402 |
+
.x((d) => xScale(parseTemporal(d[xField])))
|
| 403 |
+
.y0(yScale(Math.max(0, yMin)))
|
| 404 |
+
.y1((d) => yScale(Number(d[yField])))
|
| 405 |
+
.curve(d3.curveMonotoneX);
|
| 406 |
+
const line = d3.line()
|
| 407 |
+
.defined((d) => Number.isFinite(Number(d[yField])))
|
| 408 |
+
.x((d) => xScale(parseTemporal(d[xField])))
|
| 409 |
+
.y((d) => yScale(Number(d[yField])))
|
| 410 |
+
.curve(d3.curveMonotoneX);
|
| 411 |
+
|
| 412 |
+
groups.forEach((group, index) => {
|
| 413 |
+
const rows = rowsByGroup.get(group);
|
| 414 |
+
const color = getColor(group, index);
|
| 415 |
+
const id = sanitizeId(group);
|
| 416 |
+
chart.append("path")
|
| 417 |
+
.datum(rows)
|
| 418 |
+
.attr("class", "mlg01-area")
|
| 419 |
+
.attr("data-series", group)
|
| 420 |
+
.attr("fill", `url(#mlg01-area-${id})`)
|
| 421 |
+
.attr("d", area);
|
| 422 |
+
chart.append("path")
|
| 423 |
+
.datum(rows)
|
| 424 |
+
.attr("class", "mlg01-line")
|
| 425 |
+
.attr("data-series", group)
|
| 426 |
+
.attr("fill", "none")
|
| 427 |
+
.attr("stroke", color)
|
| 428 |
+
.attr("stroke-width", 5)
|
| 429 |
+
.attr("stroke-linecap", "round")
|
| 430 |
+
.attr("stroke-linejoin", "round")
|
| 431 |
+
.attr("filter", "url(#mlg01-soft-glow)")
|
| 432 |
+
.attr("d", line);
|
| 433 |
+
});
|
| 434 |
+
|
| 435 |
+
const lastDate = xValues[xValues.length - 1];
|
| 436 |
+
const finalX = xScale(lastDate);
|
| 437 |
+
chart.append("line")
|
| 438 |
+
.attr("class", "mlg01-final-guide")
|
| 439 |
+
.attr("x1", finalX)
|
| 440 |
+
.attr("x2", finalX)
|
| 441 |
+
.attr("y1", plot.y - 8)
|
| 442 |
+
.attr("y2", plot.y + plot.h)
|
| 443 |
+
.attr("stroke", "#ffffff")
|
| 444 |
+
.attr("stroke-width", 2)
|
| 445 |
+
.attr("stroke-dasharray", "7 8")
|
| 446 |
+
.attr("opacity", 0.92);
|
| 447 |
+
const yearLabel = chart.append("g")
|
| 448 |
+
.attr("class", "mlg01-final-year-label")
|
| 449 |
+
.attr("transform", `translate(${finalX},${plot.y - 40})`);
|
| 450 |
+
yearLabel.append("rect")
|
| 451 |
+
.attr("x", -34)
|
| 452 |
+
.attr("y", -18)
|
| 453 |
+
.attr("width", 68)
|
| 454 |
+
.attr("height", 34)
|
| 455 |
+
.attr("rx", 7)
|
| 456 |
+
.attr("fill", "#f7f8f6");
|
| 457 |
+
yearLabel.append("path").attr("d", "M -8 16 L 8 16 L 0 28 Z").attr("fill", "#f7f8f6");
|
| 458 |
+
yearLabel.append("text")
|
| 459 |
+
.attr("y", 7)
|
| 460 |
+
.attr("text-anchor", "middle")
|
| 461 |
+
.attr("font-family", labelFont)
|
| 462 |
+
.attr("font-size", 24)
|
| 463 |
+
.attr("font-weight", 900)
|
| 464 |
+
.attr("fill", "#111f26")
|
| 465 |
+
.text(formatX(lastDate));
|
| 466 |
+
|
| 467 |
+
const endpointData = groups.map((group, index) => {
|
| 468 |
+
const rows = rowsByGroup.get(group);
|
| 469 |
+
const first = rows[0];
|
| 470 |
+
const last = rows[rows.length - 1];
|
| 471 |
+
const y = yScale(Number(last[yField]));
|
| 472 |
+
const startValue = Number(first[yField]);
|
| 473 |
+
const finalValue = Number(last[yField]);
|
| 474 |
+
return {
|
| 475 |
+
group,
|
| 476 |
+
index,
|
| 477 |
+
color: getColor(group, index),
|
| 478 |
+
first,
|
| 479 |
+
last,
|
| 480 |
+
y,
|
| 481 |
+
targetY: y,
|
| 482 |
+
finalValue,
|
| 483 |
+
delta: finalValue - startValue
|
| 484 |
+
};
|
| 485 |
+
}).sort((a, b) => a.targetY - b.targetY);
|
| 486 |
+
const tagH = groups.length > 3 ? 58 : 82;
|
| 487 |
+
const minGap = tagH + 10;
|
| 488 |
+
endpointData.forEach((d, i) => {
|
| 489 |
+
if (i === 0) {
|
| 490 |
+
d.labelY = clamp(d.targetY, plot.y + tagH / 2, plot.y + plot.h - tagH / 2);
|
| 491 |
+
} else {
|
| 492 |
+
d.labelY = clamp(Math.max(d.targetY, endpointData[i - 1].labelY + minGap), plot.y + tagH / 2, plot.y + plot.h - tagH / 2);
|
| 493 |
+
}
|
| 494 |
+
});
|
| 495 |
+
for (let i = endpointData.length - 2; i >= 0; i -= 1) {
|
| 496 |
+
endpointData[i].labelY = Math.min(endpointData[i].labelY, endpointData[i + 1].labelY - minGap);
|
| 497 |
+
endpointData[i].labelY = clamp(endpointData[i].labelY, plot.y + tagH / 2, plot.y + plot.h - tagH / 2);
|
| 498 |
+
}
|
| 499 |
+
endpointData.forEach((d) => {
|
| 500 |
+
const markerY = yScale(Number(d.last[yField]));
|
| 501 |
+
chart.append("circle")
|
| 502 |
+
.attr("class", "mlg01-endpoint-marker")
|
| 503 |
+
.attr("data-series", d.group)
|
| 504 |
+
.attr("data-x", d.last[xField])
|
| 505 |
+
.attr("data-y", d.last[yField])
|
| 506 |
+
.attr("cx", finalX)
|
| 507 |
+
.attr("cy", markerY)
|
| 508 |
+
.attr("r", 10)
|
| 509 |
+
.attr("fill", d.color)
|
| 510 |
+
.attr("stroke", white)
|
| 511 |
+
.attr("stroke-width", 5);
|
| 512 |
+
const tagX = finalX + 34;
|
| 513 |
+
const valueFont = groups.length > 3 ? 23 : Math.max(26, Math.min(34, 240 / String(formatValue(d.finalValue)).length));
|
| 514 |
+
const baseTagW = groups.length > 3 ? 96 : 92;
|
| 515 |
+
const tagW = Math.max(
|
| 516 |
+
baseTagW,
|
| 517 |
+
approxWidth(formatValue(d.finalValue), valueFont, 900) + 28,
|
| 518 |
+
Math.min(140, approxWidth(d.group, groups.length > 3 ? 11 : 13, 600) + 22)
|
| 519 |
+
);
|
| 520 |
+
const labelFontSize = Math.max(groups.length > 3 ? 9 : 10, Math.min(groups.length > 3 ? 12 : 17, (tagW - 18) / Math.max(5, d.group.length * 0.54)));
|
| 521 |
+
const tag = chart.append("g")
|
| 522 |
+
.attr("class", "mlg01-endpoint-tag")
|
| 523 |
+
.attr("data-series", d.group)
|
| 524 |
+
.attr("data-coordinate-relationship", "leader-connected")
|
| 525 |
+
.attr("data-anchor", "last-chronological-point")
|
| 526 |
+
.attr("transform", `translate(${tagX},${d.labelY})`);
|
| 527 |
+
tag.append("path")
|
| 528 |
+
.attr("d", `M 0 ${clamp(markerY - d.labelY, -tagH / 2 + 12, tagH / 2 - 12)} L 14 -12 L 14 12 Z`)
|
| 529 |
+
.attr("fill", d.color)
|
| 530 |
+
.attr("opacity", 0.95);
|
| 531 |
+
tag.append("rect")
|
| 532 |
+
.attr("x", 14)
|
| 533 |
+
.attr("y", -tagH / 2)
|
| 534 |
+
.attr("width", tagW)
|
| 535 |
+
.attr("height", tagH)
|
| 536 |
+
.attr("rx", 10)
|
| 537 |
+
.attr("fill", d.color)
|
| 538 |
+
.attr("opacity", 0.95);
|
| 539 |
+
tag.append("text")
|
| 540 |
+
.attr("x", 14 + tagW / 2)
|
| 541 |
+
.attr("y", groups.length > 3 ? -4 : -8)
|
| 542 |
+
.attr("text-anchor", "middle")
|
| 543 |
+
.attr("font-family", labelFont)
|
| 544 |
+
.attr("font-size", valueFont)
|
| 545 |
+
.attr("font-weight", 900)
|
| 546 |
+
.attr("fill", white)
|
| 547 |
+
.text(formatValue(d.finalValue));
|
| 548 |
+
tag.append("text")
|
| 549 |
+
.attr("x", 14 + tagW / 2)
|
| 550 |
+
.attr("y", groups.length > 3 ? 20 : 24)
|
| 551 |
+
.attr("text-anchor", "middle")
|
| 552 |
+
.attr("font-family", labelFont)
|
| 553 |
+
.attr("font-size", labelFontSize)
|
| 554 |
+
.attr("font-weight", 600)
|
| 555 |
+
.attr("fill", white)
|
| 556 |
+
.text(d.group);
|
| 557 |
+
});
|
| 558 |
+
|
| 559 |
+
const cardGap = groups.length <= 2 ? 24 : 18;
|
| 560 |
+
const cardH = groups.length <= 2 ? 330 : groups.length === 3 ? 265 : 220;
|
| 561 |
+
const cardStartY = cardColumn.y;
|
| 562 |
+
endpointData
|
| 563 |
+
.slice()
|
| 564 |
+
.sort((a, b) => b.finalValue - a.finalValue)
|
| 565 |
+
.forEach((d, i) => {
|
| 566 |
+
const y = cardStartY + i * (cardH + cardGap);
|
| 567 |
+
if (y + cardH > cardColumn.y + cardColumn.h) return;
|
| 568 |
+
const card = svg.append("g")
|
| 569 |
+
.attr("class", "mlg01-summary-card")
|
| 570 |
+
.attr("data-series", d.group)
|
| 571 |
+
.attr("data-coordinate-relationship", "independent")
|
| 572 |
+
.attr("data-anchor", "right-body-column")
|
| 573 |
+
.attr("transform", `translate(${cardColumn.x},${y})`);
|
| 574 |
+
card.append("rect")
|
| 575 |
+
.attr("width", cardColumn.w)
|
| 576 |
+
.attr("height", cardH)
|
| 577 |
+
.attr("rx", 28)
|
| 578 |
+
.attr("fill", "#061e27")
|
| 579 |
+
.attr("stroke", d.color)
|
| 580 |
+
.attr("stroke-width", 2.2)
|
| 581 |
+
.attr("opacity", 0.9);
|
| 582 |
+
const iconR = groups.length <= 2 ? 66 : 48;
|
| 583 |
+
const iconCx = cardColumn.w / 2;
|
| 584 |
+
const iconCy = groups.length <= 2 ? 86 : 60;
|
| 585 |
+
const iconSlot = card.append("g")
|
| 586 |
+
.attr("data-asset-slot", "group-icon")
|
| 587 |
+
.attr("data-series", d.group)
|
| 588 |
+
.attr("data-no-copied-reference-art", "true")
|
| 589 |
+
.attr("data-anchor", "right-summary-card-top-center")
|
| 590 |
+
.attr("data-collision-rule", "inside card; avoid title plot endpoint tags and sibling cards")
|
| 591 |
+
.attr("transform", `translate(${iconCx},${iconCy})`);
|
| 592 |
+
iconSlot.append("circle")
|
| 593 |
+
.attr("r", iconR)
|
| 594 |
+
.attr("fill", darken(d.color, 0.78))
|
| 595 |
+
.attr("stroke", d.color)
|
| 596 |
+
.attr("stroke-width", 3)
|
| 597 |
+
.attr("opacity", 0.96);
|
| 598 |
+
iconSlot.attr("data-no-embedded-raster", "true");
|
| 599 |
+
iconSlot.append("text")
|
| 600 |
+
.attr("text-anchor", "middle")
|
| 601 |
+
.attr("dominant-baseline", "middle")
|
| 602 |
+
.attr("font-family", labelFont)
|
| 603 |
+
.attr("font-size", iconR * 0.52)
|
| 604 |
+
.attr("font-weight", 900)
|
| 605 |
+
.attr("fill", lighten(d.color, 0.15))
|
| 606 |
+
.text(d.group.split(/\s+/).map((word) => word[0]).join("").slice(0, 2).toUpperCase());
|
| 607 |
+
const titleY = groups.length <= 2 ? 178 : 124;
|
| 608 |
+
const cardTitle = d.group.toUpperCase();
|
| 609 |
+
let cardTitleFont = Math.max(15, Math.min(groups.length <= 2 ? 27 : 20, (cardColumn.w - 26) / Math.max(1, cardTitle.length * 0.62)));
|
| 610 |
+
let cardTitleLines = wrapWordsByWidth(cardTitle, cardTitleFont, cardColumn.w - 28);
|
| 611 |
+
while (cardTitleLines.length > 2 && cardTitleFont > 11) {
|
| 612 |
+
cardTitleFont -= 1;
|
| 613 |
+
cardTitleLines = wrapWordsByWidth(cardTitle, cardTitleFont, cardColumn.w - 28);
|
| 614 |
+
}
|
| 615 |
+
const titleText = card.append("text")
|
| 616 |
+
.attr("x", cardColumn.w / 2)
|
| 617 |
+
.attr("y", titleY)
|
| 618 |
+
.attr("text-anchor", "middle")
|
| 619 |
+
.attr("font-family", labelFont)
|
| 620 |
+
.attr("font-size", cardTitleFont)
|
| 621 |
+
.attr("font-weight", 900)
|
| 622 |
+
.attr("fill", d.color)
|
| 623 |
+
.attr("letter-spacing", 1);
|
| 624 |
+
cardTitleLines.slice(0, 2).forEach((lineText, lineIndex) => {
|
| 625 |
+
titleText.append("tspan")
|
| 626 |
+
.attr("x", cardColumn.w / 2)
|
| 627 |
+
.attr("dy", lineIndex === 0 ? 0 : cardTitleFont + 3)
|
| 628 |
+
.text(lineText);
|
| 629 |
+
});
|
| 630 |
+
const titleBlockOffset = cardTitleLines.length > 1 ? cardTitleFont + 16 : 0;
|
| 631 |
+
const trendWord = d.delta >= 0 ? "Up" : "Down";
|
| 632 |
+
const trendText = `${trendWord} ${formatValue(Math.abs(d.delta))} since ${formatX(parseTemporal(d.first[xField]))}`;
|
| 633 |
+
const finalText = `Final ${formatValue(d.finalValue)}`;
|
| 634 |
+
card.append("text")
|
| 635 |
+
.attr("x", cardColumn.w / 2)
|
| 636 |
+
.attr("y", titleY + titleBlockOffset + (groups.length <= 2 ? 42 : 30))
|
| 637 |
+
.attr("text-anchor", "middle")
|
| 638 |
+
.attr("font-family", labelFont)
|
| 639 |
+
.attr("font-size", groups.length <= 2 ? 20 : 15)
|
| 640 |
+
.attr("font-weight", 700)
|
| 641 |
+
.attr("fill", white)
|
| 642 |
+
.text(finalText);
|
| 643 |
+
const desc = card.append("text")
|
| 644 |
+
.attr("x", cardColumn.w / 2)
|
| 645 |
+
.attr("y", titleY + titleBlockOffset + (groups.length <= 2 ? 78 : 56))
|
| 646 |
+
.attr("text-anchor", "middle")
|
| 647 |
+
.attr("font-family", labelFont)
|
| 648 |
+
.attr("font-size", groups.length <= 2 ? 18 : 13)
|
| 649 |
+
.attr("font-weight", 400)
|
| 650 |
+
.attr("fill", "#d9e4e0");
|
| 651 |
+
wrapText(desc, trendText, cardColumn.w - 30, groups.length <= 2 ? 26 : 19, groups.length <= 2 ? 2 : 1);
|
| 652 |
+
});
|
| 653 |
+
|
| 654 |
+
return svg.node();
|
| 655 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_03.js
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Line Graph",
|
| 5 |
+
"chart_name": "multiple_line_graph_03",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 30], ["-inf", "inf"], [2, 3]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 400,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "dark",
|
| 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_dark || {};
|
| 32 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 33 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 34 |
+
const images = jsonData.images || {};
|
| 35 |
+
// 清空容器
|
| 36 |
+
d3.select(containerSelector).html("");
|
| 37 |
+
|
| 38 |
+
// 设置尺寸和边距 - 右侧留出更多空间用于标签
|
| 39 |
+
const width = variables.width;
|
| 40 |
+
const height = variables.height;
|
| 41 |
+
const margin = { top: 40, right: 180, bottom: 60, left: 80 };
|
| 42 |
+
const innerWidth = width - margin.left - margin.right;
|
| 43 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 44 |
+
|
| 45 |
+
// 获取字段名
|
| 46 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 47 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 48 |
+
const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
|
| 49 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 50 |
+
|
| 51 |
+
// 获取唯一的组值和X值
|
| 52 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 53 |
+
const xValues = [...new Set(chartData.map(d => d[xField]))]
|
| 54 |
+
.sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
|
| 55 |
+
|
| 56 |
+
// 创建SVG
|
| 57 |
+
const svg = d3.select(containerSelector)
|
| 58 |
+
.append("svg")
|
| 59 |
+
.attr("width", "100%")
|
| 60 |
+
.attr("height", height)
|
| 61 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 62 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 63 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 64 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 65 |
+
|
| 66 |
+
// 创建图表组
|
| 67 |
+
const g = svg.append("g")
|
| 68 |
+
.attr("transform", `translate(${margin.left},${margin.top})`);
|
| 69 |
+
|
| 70 |
+
// 确保在创建网格线之前定义渐变
|
| 71 |
+
const defs = svg.append("defs");
|
| 72 |
+
|
| 73 |
+
// 创建一个唯一的ID,避免冲突
|
| 74 |
+
const gradientIdSuffix = chartUtils.random.id(jsonData, "multiple-line-03-grid");
|
| 75 |
+
const horizontalGradientId = `horizontal-grid-gradient-${gradientIdSuffix}`;
|
| 76 |
+
const verticalGradientId = `vertical-grid-gradient-${gradientIdSuffix}`;
|
| 77 |
+
|
| 78 |
+
// 添加水平网格线渐变
|
| 79 |
+
const horizontalGridGradient = defs.append("linearGradient")
|
| 80 |
+
.attr("id", horizontalGradientId)
|
| 81 |
+
.attr("x1", "0%")
|
| 82 |
+
.attr("y1", "0%")
|
| 83 |
+
.attr("x2", "100%")
|
| 84 |
+
.attr("y2", "0%");
|
| 85 |
+
|
| 86 |
+
horizontalGridGradient.append("stop")
|
| 87 |
+
.attr("offset", "0%")
|
| 88 |
+
.attr("stop-color", "#6bc7c5")
|
| 89 |
+
.attr("stop-opacity", 0.2);
|
| 90 |
+
|
| 91 |
+
horizontalGridGradient.append("stop")
|
| 92 |
+
.attr("offset", "100%")
|
| 93 |
+
.attr("stop-color", "#6bc7c5")
|
| 94 |
+
.attr("stop-opacity", 0.05);
|
| 95 |
+
|
| 96 |
+
// 添加垂直网格线渐变
|
| 97 |
+
const verticalGridGradient = defs.append("linearGradient")
|
| 98 |
+
.attr("id", verticalGradientId)
|
| 99 |
+
.attr("x1", "0%")
|
| 100 |
+
.attr("y1", "100%")
|
| 101 |
+
.attr("x2", "0%")
|
| 102 |
+
.attr("y2", "0%");
|
| 103 |
+
|
| 104 |
+
verticalGridGradient.append("stop")
|
| 105 |
+
.attr("offset", "0%")
|
| 106 |
+
.attr("stop-color", "#e8f6fa")
|
| 107 |
+
.attr("stop-opacity", 0.3);
|
| 108 |
+
|
| 109 |
+
verticalGridGradient.append("stop")
|
| 110 |
+
.attr("offset", "100%")
|
| 111 |
+
.attr("stop-color", "#e8f6fa")
|
| 112 |
+
.attr("stop-opacity", 0.1);
|
| 113 |
+
|
| 114 |
+
// 创建比例尺 - 修改为时间比例尺
|
| 115 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
|
| 116 |
+
|
| 117 |
+
// 修改Y轴比例尺,支持负值,并确保最大刻度超过数据最大值
|
| 118 |
+
const yScale = d3.scaleLinear()
|
| 119 |
+
.domain([
|
| 120 |
+
Math.min(0, d3.min(chartData, d => +d[yField]) * 1.1), // 取最小值和0中的较小者
|
| 121 |
+
d3.max(chartData, d => +d[yField]) * 1.3 // 将最大值扩大30%
|
| 122 |
+
])
|
| 123 |
+
.range([innerHeight, 0]);
|
| 124 |
+
|
| 125 |
+
// 获取颜色
|
| 126 |
+
const getColor = (group) => {
|
| 127 |
+
return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
// 添加网格线 - 使用半透明白色
|
| 131 |
+
// 首先获取Y轴的最大刻度值
|
| 132 |
+
const yTicks = yScale.ticks(6); // 增加刻度数量,确保有足够的刻度
|
| 133 |
+
const maxYTick = yTicks[yTicks.length - 1];
|
| 134 |
+
const maxYPos = yScale(maxYTick);
|
| 135 |
+
|
| 136 |
+
// 绘制水平网格线 - 向左延伸
|
| 137 |
+
const gridExtension = 20; // 网格线向左延伸的距离
|
| 138 |
+
|
| 139 |
+
const horizontalGradient = defs.append("linearGradient")
|
| 140 |
+
.attr("id", "horizontal-grid-gradient")
|
| 141 |
+
.attr("x1", "0%")
|
| 142 |
+
.attr("y1", "0%")
|
| 143 |
+
.attr("x2", "100%")
|
| 144 |
+
.attr("y2", "0%");
|
| 145 |
+
|
| 146 |
+
horizontalGradient.append("stop")
|
| 147 |
+
.attr("offset", "0%")
|
| 148 |
+
.attr("stop-color", "#6bc7c5")
|
| 149 |
+
.attr("stop-opacity", 1);
|
| 150 |
+
|
| 151 |
+
horizontalGradient.append("stop")
|
| 152 |
+
.attr("offset", "100%")
|
| 153 |
+
.attr("stop-color", "#6bc7c5")
|
| 154 |
+
.attr("stop-opacity", 0.2);
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
// 过滤Y轴刻度,移除最小的刻度(通常是0或负值)
|
| 158 |
+
const filteredYTicks = yTicks.filter(d => d > yTicks[0]);
|
| 159 |
+
|
| 160 |
+
// 创建水平网格渐变矩形,不包括最下方的刻度
|
| 161 |
+
g.selectAll("rect.grid-rect-y")
|
| 162 |
+
.data(filteredYTicks)
|
| 163 |
+
.enter()
|
| 164 |
+
.append("rect")
|
| 165 |
+
.attr("class", "grid-rect-y")
|
| 166 |
+
.attr("class", "background")
|
| 167 |
+
.attr("x", -gridExtension)
|
| 168 |
+
.attr("y", d => yScale(d) - 0.5) // 减去0.5使线条居中
|
| 169 |
+
.attr("width", innerWidth + gridExtension)
|
| 170 |
+
.attr("height", 1)
|
| 171 |
+
.style("fill", `url(#horizontal-grid-gradient)`);
|
| 172 |
+
|
| 173 |
+
const verticalGradient = defs.append("linearGradient")
|
| 174 |
+
.attr("id", "vertical-grid-gradient")
|
| 175 |
+
.attr("x1", "0%")
|
| 176 |
+
.attr("y1", "0%")
|
| 177 |
+
.attr("x2", "0%")
|
| 178 |
+
.attr("y2", "100%");
|
| 179 |
+
|
| 180 |
+
verticalGradient.append("stop")
|
| 181 |
+
.attr("offset", "0%")
|
| 182 |
+
.attr("stop-color", "#6bc7c5")
|
| 183 |
+
.attr("stop-opacity", 0);
|
| 184 |
+
|
| 185 |
+
verticalGradient.append("stop")
|
| 186 |
+
.attr("offset", "100%")
|
| 187 |
+
.attr("stop-color", "#6bc7c5")
|
| 188 |
+
.attr("stop-opacity", 0.4);
|
| 189 |
+
// 绘制垂直网格线
|
| 190 |
+
g.selectAll("rect.grid-rect-x")
|
| 191 |
+
.data(xTicks)
|
| 192 |
+
.enter()
|
| 193 |
+
.append("rect")
|
| 194 |
+
.attr("class", "grid-rect-x")
|
| 195 |
+
.attr("class", "background")
|
| 196 |
+
.attr("x", d => xScale(d) - 0.5)
|
| 197 |
+
.attr("y", 0)
|
| 198 |
+
.attr("width", 1)
|
| 199 |
+
.attr("height", innerHeight + 10)
|
| 200 |
+
.style("fill", `url(#vertical-grid-gradient)`);
|
| 201 |
+
|
| 202 |
+
// 为每个组添加线条渐变
|
| 203 |
+
groups.forEach(group => {
|
| 204 |
+
const gradientId = `line-gradient-${group.replace(/\s+/g, '-').toLowerCase()}`;
|
| 205 |
+
const baseColor = getColor(group);
|
| 206 |
+
|
| 207 |
+
const gradient = defs.append("linearGradient")
|
| 208 |
+
.attr("id", gradientId)
|
| 209 |
+
.attr("x1", "0%")
|
| 210 |
+
.attr("y1", "0%")
|
| 211 |
+
.attr("x2", "0%")
|
| 212 |
+
.attr("y2", "100%");
|
| 213 |
+
|
| 214 |
+
gradient.append("stop")
|
| 215 |
+
.attr("offset", "0%")
|
| 216 |
+
.attr("stop-color", baseColor)
|
| 217 |
+
.attr("stop-opacity", 0.3);
|
| 218 |
+
|
| 219 |
+
gradient.append("stop")
|
| 220 |
+
.attr("offset", "100%")
|
| 221 |
+
.attr("stop-color", baseColor)
|
| 222 |
+
.attr("stop-opacity", 0);
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
const line = d3.line()
|
| 226 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 227 |
+
.y(d => yScale(+d[yField]))
|
| 228 |
+
|
| 229 |
+
// 首先收集所有点信息,并分为起点和终点两组
|
| 230 |
+
const startPoints = [];
|
| 231 |
+
const endPoints = [];
|
| 232 |
+
|
| 233 |
+
groups.forEach(group => {
|
| 234 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 235 |
+
|
| 236 |
+
// 绘制线条
|
| 237 |
+
g.append("path")
|
| 238 |
+
.datum(groupData)
|
| 239 |
+
.attr("class", "line")
|
| 240 |
+
.attr("fill", "none")
|
| 241 |
+
.attr("stroke", getColor(group))
|
| 242 |
+
.attr("stroke-width", 4)
|
| 243 |
+
.attr("d", line);
|
| 244 |
+
|
| 245 |
+
// 添加起点和终点的圆点
|
| 246 |
+
const firstPoint = groupData[0];
|
| 247 |
+
const lastPoint = groupData[groupData.length - 1];
|
| 248 |
+
|
| 249 |
+
// 处理起点
|
| 250 |
+
const startX = xScale(chartUtils.format.parseDate(firstPoint[xField]));
|
| 251 |
+
const startY = yScale(+firstPoint[yField]);
|
| 252 |
+
|
| 253 |
+
// 添加圆点
|
| 254 |
+
g.append("circle")
|
| 255 |
+
.attr("cx", startX)
|
| 256 |
+
.attr("cy", startY)
|
| 257 |
+
.attr("r", 4)
|
| 258 |
+
.attr("fill", getColor(group));
|
| 259 |
+
|
| 260 |
+
// 收集起点信息
|
| 261 |
+
startPoints.push({
|
| 262 |
+
x: startX,
|
| 263 |
+
y: startY,
|
| 264 |
+
value: chartUtils.format.integer(firstPoint[yField]).text,
|
| 265 |
+
color: getColor(group),
|
| 266 |
+
group: group
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
// 处理终点
|
| 270 |
+
const endX = xScale(chartUtils.format.parseDate(lastPoint[xField]));
|
| 271 |
+
const endY = yScale(+lastPoint[yField]);
|
| 272 |
+
|
| 273 |
+
// 添加圆点
|
| 274 |
+
g.append("circle")
|
| 275 |
+
.attr("cx", endX)
|
| 276 |
+
.attr("cy", endY)
|
| 277 |
+
.attr("r", 4)
|
| 278 |
+
.attr("fill", getColor(group));
|
| 279 |
+
|
| 280 |
+
// 收集终点信息
|
| 281 |
+
endPoints.push({
|
| 282 |
+
x: endX,
|
| 283 |
+
y: endY,
|
| 284 |
+
value: chartUtils.format.integer(lastPoint[yField]).text,
|
| 285 |
+
color: getColor(group),
|
| 286 |
+
group: group
|
| 287 |
+
});
|
| 288 |
+
});
|
| 289 |
+
|
| 290 |
+
// 绘制X轴
|
| 291 |
+
const xAxis = g.append("g")
|
| 292 |
+
.attr("transform", `translate(0,${innerHeight})`)
|
| 293 |
+
.call(d3.axisBottom(xScale)
|
| 294 |
+
.tickFormat(xFormat) // 使用相同的刻度数量
|
| 295 |
+
);
|
| 296 |
+
|
| 297 |
+
// 设置X轴样式,并下移文本
|
| 298 |
+
xAxis.selectAll("text")
|
| 299 |
+
.style("font-family", typography.label.font_family)
|
| 300 |
+
.style("font-size", typography.label.font_size)
|
| 301 |
+
.style("fill", "#e8f6fa")
|
| 302 |
+
.attr("dy", "1.5em"); // 下移文本
|
| 303 |
+
|
| 304 |
+
// 移除X轴线和刻度
|
| 305 |
+
xAxis.select(".domain").remove();
|
| 306 |
+
xAxis.selectAll(".tick line").remove();
|
| 307 |
+
|
| 308 |
+
// 在X轴刻度下方添加一条水平线
|
| 309 |
+
g.append("line")
|
| 310 |
+
.attr("x1", -20)
|
| 311 |
+
.attr("y1", innerHeight + 40) // 放在X轴刻度文本下方
|
| 312 |
+
.attr("x2", innerWidth + 20)
|
| 313 |
+
.attr("y2", innerHeight + 40) // 保持水平
|
| 314 |
+
.attr("stroke", "#e8f6fa") // 与X轴文本相同的颜色
|
| 315 |
+
.attr("stroke-width", 1);
|
| 316 |
+
|
| 317 |
+
// 绘制Y轴 - 移除B后缀,并调整刻度位置
|
| 318 |
+
const yAxis = g.append("g")
|
| 319 |
+
.call(d3.axisLeft(yScale)
|
| 320 |
+
.tickValues(filteredYTicks) // 使用过滤后的刻度
|
| 321 |
+
.tickSize(0) // 移除刻度线
|
| 322 |
+
);
|
| 323 |
+
|
| 324 |
+
// 移除Y轴线
|
| 325 |
+
yAxis.select(".domain").remove();
|
| 326 |
+
yAxis.selectAll(".tick line").remove();
|
| 327 |
+
|
| 328 |
+
// 手动添加Y轴刻度文本,放在延伸的网格线上方
|
| 329 |
+
yAxis.selectAll(".tick text")
|
| 330 |
+
.attr("x", -gridExtension - 5) // 放在延伸的网格线上方,留出一点间距
|
| 331 |
+
.style("font-family", typography.label.font_family)
|
| 332 |
+
.style("font-size", typography.label.font_size)
|
| 333 |
+
.style("fill", "#6bc7c5") // 白色
|
| 334 |
+
.style("text-anchor", "end")
|
| 335 |
+
.text(d => d);
|
| 336 |
+
|
| 337 |
+
// 添加Y轴编码描述
|
| 338 |
+
// 获取Y轴字段名称
|
| 339 |
+
const yAxisLabel = yColumn.description;
|
| 340 |
+
|
| 341 |
+
// 计算Y轴标签位置 - 在最大刻度下方
|
| 342 |
+
const yAxisLabelX = -margin.left / 2 + 5; // 在Y轴中间位置
|
| 343 |
+
const yAxisLabelY = yScale(yTicks[yTicks.length - 1]) - 30; // 最大刻度上方一点
|
| 344 |
+
|
| 345 |
+
// 添加Y轴描述文本
|
| 346 |
+
const words = yAxisLabel.split(' ');
|
| 347 |
+
let lines = [];
|
| 348 |
+
let currentLine = words[0];
|
| 349 |
+
|
| 350 |
+
// 计算每行文本
|
| 351 |
+
for(let i = 1; i < words.length; i++) {
|
| 352 |
+
const testLine = currentLine + ' ' + words[i];
|
| 353 |
+
const testWidth = chartUtils.text.measure(null, testLine, { fontSize: typography.label.font_size }).width;
|
| 354 |
+
|
| 355 |
+
if(testWidth <= 200) {
|
| 356 |
+
currentLine = testLine;
|
| 357 |
+
} else {
|
| 358 |
+
lines.push(currentLine);
|
| 359 |
+
currentLine = words[i];
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
lines.push(currentLine);
|
| 363 |
+
|
| 364 |
+
// 绘制多行文本
|
| 365 |
+
lines.forEach((line, i) => {
|
| 366 |
+
g.append("text")
|
| 367 |
+
.attr("class", "y-axis-label")
|
| 368 |
+
.attr("x", yAxisLabelX)
|
| 369 |
+
.attr("y", yAxisLabelY + i * 20)
|
| 370 |
+
.attr("text-anchor", "start")
|
| 371 |
+
.style("font-family", typography.label.font_family)
|
| 372 |
+
.style("font-size", typography.label.font_size)
|
| 373 |
+
.style("fill", "#6bc7c5")
|
| 374 |
+
.text(line);
|
| 375 |
+
});
|
| 376 |
+
|
| 377 |
+
// 动态规划标签放置算法
|
| 378 |
+
function placeLabelsDP(points, avoidYPositions = []) {
|
| 379 |
+
// 每个格点的高度(像素)
|
| 380 |
+
const GRID_SIZE = 3;
|
| 381 |
+
// 圆点周围的保护区域(格点数)
|
| 382 |
+
const PROTECTION_RADIUS = 3;
|
| 383 |
+
// 标签高度(格点数)
|
| 384 |
+
const LABEL_HEIGHT = 5;
|
| 385 |
+
|
| 386 |
+
// 离散化Y坐标,创建格点系统
|
| 387 |
+
const minY = 0;
|
| 388 |
+
const maxY = innerHeight;
|
| 389 |
+
const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
|
| 390 |
+
|
| 391 |
+
// 按照Y坐标排序点
|
| 392 |
+
points.sort((a, b) => a.y - b.y);
|
| 393 |
+
|
| 394 |
+
// 创建格点占用标记
|
| 395 |
+
const occupied = new Array(gridCount).fill(false);
|
| 396 |
+
|
| 397 |
+
// 标记圆点周围的保护区域为已占用
|
| 398 |
+
points.forEach(point => {
|
| 399 |
+
const gridY = Math.floor(point.y / GRID_SIZE);
|
| 400 |
+
for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
|
| 401 |
+
occupied[i] = true;
|
| 402 |
+
}
|
| 403 |
+
});
|
| 404 |
+
|
| 405 |
+
// 标记需要避开的Y轴刻度位置为已占用
|
| 406 |
+
avoidYPositions.forEach(yPos => {
|
| 407 |
+
const gridY = Math.floor(yPos / GRID_SIZE);
|
| 408 |
+
// 为每个刻度值添加一些保护区域
|
| 409 |
+
for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
|
| 410 |
+
occupied[i] = true;
|
| 411 |
+
}
|
| 412 |
+
});
|
| 413 |
+
|
| 414 |
+
// 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
|
| 415 |
+
const n = points.length;
|
| 416 |
+
const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
|
| 417 |
+
const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
|
| 418 |
+
|
| 419 |
+
// 初始条件:第一个点的标签放置
|
| 420 |
+
const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
|
| 421 |
+
|
| 422 |
+
// 尝试将第一个点的标签放在可行的位置
|
| 423 |
+
for (let j = 0; j < gridCount; j++) {
|
| 424 |
+
// 检查是否可行(不在保护区域内且有足够空间放置标签)
|
| 425 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 426 |
+
// 检查标签是否会与其他标签重叠
|
| 427 |
+
let canPlace = true;
|
| 428 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 429 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 430 |
+
canPlace = false;
|
| 431 |
+
break;
|
| 432 |
+
}
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
// 确保第一个标签不超过第二个圆点位置(如果有第二个圆点)
|
| 436 |
+
if (n > 1) {
|
| 437 |
+
const nextPointGridY = Math.floor(points[1].y / GRID_SIZE);
|
| 438 |
+
if (j > nextPointGridY) {
|
| 439 |
+
canPlace = false; // 不允许标签越过下一个圆点
|
| 440 |
+
}
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
if (canPlace) {
|
| 444 |
+
// 计算代价:与圆点位置的距离
|
| 445 |
+
const cost = Math.abs(j - firstPointGridY);
|
| 446 |
+
dp[0][j] = cost;
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
// 填充dp表
|
| 452 |
+
for (let i = 1; i < n; i++) {
|
| 453 |
+
const pointGridY = Math.floor(points[i].y / GRID_SIZE);
|
| 454 |
+
|
| 455 |
+
for (let j = 0; j < gridCount; j++) {
|
| 456 |
+
// 检查当前位置是否可行
|
| 457 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 458 |
+
// 检查标签是否会与其他标签重叠
|
| 459 |
+
let canPlace = true;
|
| 460 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 461 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 462 |
+
canPlace = false;
|
| 463 |
+
break;
|
| 464 |
+
}
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
// 确保当前标签不超过下一个圆点位置(如果有下一个圆点)
|
| 468 |
+
if (i < n - 1) {
|
| 469 |
+
const nextPointGridY = Math.floor(points[i+1].y / GRID_SIZE);
|
| 470 |
+
if (j > nextPointGridY) {
|
| 471 |
+
canPlace = false; // 不允许标签越过下一个圆点
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
// 确保当前标签至少超过上一个圆点位置
|
| 476 |
+
if (i > 0) {
|
| 477 |
+
const prevPointGridY = Math.floor(points[i-1].y / GRID_SIZE);
|
| 478 |
+
if (j < prevPointGridY) {
|
| 479 |
+
canPlace = false; // 不允许标签越过上一个圆点
|
| 480 |
+
}
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
if (canPlace) {
|
| 484 |
+
// 根据上一个点的标签位置计算当前最小代价
|
| 485 |
+
for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
|
| 486 |
+
if (dp[i-1][k] !== Infinity) {
|
| 487 |
+
|
| 488 |
+
// 当前标签的代价
|
| 489 |
+
const curCost = Math.abs(j - pointGridY);
|
| 490 |
+
const totalCost = dp[i-1][k] + curCost;
|
| 491 |
+
|
| 492 |
+
if (totalCost < dp[i][j]) {
|
| 493 |
+
dp[i][j] = totalCost;
|
| 494 |
+
prev[i][j] = k; // 记录前驱
|
| 495 |
+
}
|
| 496 |
+
}
|
| 497 |
+
}
|
| 498 |
+
}
|
| 499 |
+
}
|
| 500 |
+
}
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
// 找出最后一个点的最优放置位置
|
| 504 |
+
let minCost = Infinity;
|
| 505 |
+
let bestPos = -1;
|
| 506 |
+
|
| 507 |
+
for (let j = 0; j < gridCount; j++) {
|
| 508 |
+
if (dp[n-1][j] < minCost) {
|
| 509 |
+
minCost = dp[n-1][j];
|
| 510 |
+
bestPos = j;
|
| 511 |
+
}
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
// 回溯构建结果
|
| 515 |
+
const labelPositions = [];
|
| 516 |
+
if (bestPos !== -1) {
|
| 517 |
+
// 从后向前回溯
|
| 518 |
+
let pos = bestPos;
|
| 519 |
+
|
| 520 |
+
for (let i = n - 1; i >= 0; i--) {
|
| 521 |
+
labelPositions.unshift({
|
| 522 |
+
point: points[i],
|
| 523 |
+
labelY: pos * GRID_SIZE
|
| 524 |
+
});
|
| 525 |
+
|
| 526 |
+
pos = prev[i][pos];
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
} else {
|
| 530 |
+
// 无可行解,退化为简单方案
|
| 531 |
+
let lastY = 0;
|
| 532 |
+
for (let i = 0; i < n; i++) {
|
| 533 |
+
const point = points[i];
|
| 534 |
+
// 确保标签不超过下一个圆点的位置
|
| 535 |
+
let maxY = innerHeight;
|
| 536 |
+
if (i < n - 1) {
|
| 537 |
+
maxY = points[i+1].y;
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
|
| 541 |
+
|
| 542 |
+
labelPositions.push({
|
| 543 |
+
point: point,
|
| 544 |
+
labelY: labelY
|
| 545 |
+
});
|
| 546 |
+
|
| 547 |
+
lastY = labelY;
|
| 548 |
+
}
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
return labelPositions;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
// 分别对起点和终点应用动态规划
|
| 555 |
+
// 获取Y轴刻度位置用于避开
|
| 556 |
+
const yTickPositions = filteredYTicks.map(tick => yScale(tick));
|
| 557 |
+
const startLabelPositions = placeLabelsDP(startPoints, yTickPositions); // 起点需要避开Y轴刻度
|
| 558 |
+
const endLabelPositions = placeLabelsDP(endPoints, []); // 终点不需要避开Y轴刻度
|
| 559 |
+
|
| 560 |
+
// 分别处理起点和终点标签
|
| 561 |
+
startLabelPositions.forEach(placement => {
|
| 562 |
+
const {point, labelY} = placement;
|
| 563 |
+
|
| 564 |
+
// 添加起点标签,向左偏移10px
|
| 565 |
+
g.append("text")
|
| 566 |
+
.attr("x", point.x - 20)
|
| 567 |
+
.attr("y", labelY)
|
| 568 |
+
.attr("text-anchor", "middle")
|
| 569 |
+
.attr("dominant-baseline", "hanging")
|
| 570 |
+
.style("font-family", typography.label.font_family)
|
| 571 |
+
.style("font-size", "18px")
|
| 572 |
+
.style("font-weight", "bold")
|
| 573 |
+
.style("fill", point.color)
|
| 574 |
+
.text(point.value);
|
| 575 |
+
});
|
| 576 |
+
|
| 577 |
+
endLabelPositions.forEach(placement => {
|
| 578 |
+
const {point, labelY} = placement;
|
| 579 |
+
|
| 580 |
+
// 添加终点标签,向右偏移10px
|
| 581 |
+
g.append("text")
|
| 582 |
+
.attr("x", point.x + 10)
|
| 583 |
+
.attr("y", labelY)
|
| 584 |
+
.attr("text-anchor", "start")
|
| 585 |
+
.attr("dominant-baseline", "hanging")
|
| 586 |
+
.style("font-family", typography.label.font_family)
|
| 587 |
+
.style("font-size", "18px")
|
| 588 |
+
.style("font-weight", "bold")
|
| 589 |
+
.style("fill", point.color)
|
| 590 |
+
.text(point.value + " " + point.group);
|
| 591 |
+
});
|
| 592 |
+
return svg.node();
|
| 593 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_06.js
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Line Graph",
|
| 5 |
+
"chart_name": "multiple_line_graph_06",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 30], [0, "inf"], [2, 8]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "dark",
|
| 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_dark || {};
|
| 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 groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 46 |
+
|
| 47 |
+
// 设置尺寸和边距
|
| 48 |
+
const width = variables.width;
|
| 49 |
+
const height = variables.height;
|
| 50 |
+
const margin = { top: 20, right: 60, bottom: 80, left: 40 };
|
| 51 |
+
|
| 52 |
+
// 创建SVG
|
| 53 |
+
const svg = d3.select(containerSelector)
|
| 54 |
+
.append("svg")
|
| 55 |
+
.attr("width", "100%")
|
| 56 |
+
.attr("height", height)
|
| 57 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 58 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 59 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 60 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 61 |
+
|
| 62 |
+
// 创建图表区域
|
| 63 |
+
const chartWidth = width - margin.left - margin.right;
|
| 64 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 65 |
+
|
| 66 |
+
const g = svg.append("g")
|
| 67 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 68 |
+
|
| 69 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
|
| 70 |
+
|
| 71 |
+
// 确定全局y轴范围
|
| 72 |
+
const yMin = Math.min(0, d3.min(chartData, d => +d[yField]) * 1.4);
|
| 73 |
+
const yMax = d3.max(chartData, d => +d[yField]) * 1.1;
|
| 74 |
+
|
| 75 |
+
// 创建y轴比例尺
|
| 76 |
+
const yScale = d3.scaleLinear()
|
| 77 |
+
.domain([yMin, yMax])
|
| 78 |
+
.range([chartHeight, 0]);
|
| 79 |
+
|
| 80 |
+
// 添加Y轴线(虚线)
|
| 81 |
+
g.append("line")
|
| 82 |
+
.attr("x1", 0)
|
| 83 |
+
.attr("y1", 0)
|
| 84 |
+
.attr("x2", 0)
|
| 85 |
+
.attr("y2", chartHeight)
|
| 86 |
+
.attr("stroke", "#9badd3")
|
| 87 |
+
.attr("stroke-width", 1)
|
| 88 |
+
.attr("stroke-dasharray", "2,2");
|
| 89 |
+
|
| 90 |
+
// 添加X轴线(虚线)
|
| 91 |
+
g.append("line")
|
| 92 |
+
.attr("x1", 0)
|
| 93 |
+
.attr("y1", chartHeight)
|
| 94 |
+
.attr("x2", chartWidth)
|
| 95 |
+
.attr("y2", chartHeight)
|
| 96 |
+
.attr("stroke", "#9badd3")
|
| 97 |
+
.attr("stroke-width", 1)
|
| 98 |
+
.attr("stroke-dasharray", "2,2");
|
| 99 |
+
|
| 100 |
+
// 添加y轴刻度和标签
|
| 101 |
+
const yTicks = yScale.ticks(5); // 根据实际数据自动生成刻度
|
| 102 |
+
|
| 103 |
+
// 添加y轴刻度和标签
|
| 104 |
+
yTicks.forEach(tick => {
|
| 105 |
+
g.append("text")
|
| 106 |
+
.attr("x", -5)
|
| 107 |
+
.attr("y", yScale(tick))
|
| 108 |
+
.attr("text-anchor", "end")
|
| 109 |
+
.attr("dominant-baseline", "middle")
|
| 110 |
+
.style("font-family", "Arial")
|
| 111 |
+
.style("font-size", "16px")
|
| 112 |
+
.style("fill", "#ffffff")
|
| 113 |
+
.text(tick);
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
// 添加x轴刻度和标签
|
| 117 |
+
xTicks.forEach((tick, i) => {
|
| 118 |
+
// 添加主刻度线,跳过第一个刻度
|
| 119 |
+
if(i !== 0) {
|
| 120 |
+
g.append("line")
|
| 121 |
+
.attr("x1", xScale(tick))
|
| 122 |
+
.attr("y1", chartHeight - 2)
|
| 123 |
+
.attr("x2", xScale(tick))
|
| 124 |
+
.attr("y2", chartHeight + 2)
|
| 125 |
+
.attr("stroke", "#ffffff")
|
| 126 |
+
.attr("stroke-width", 1);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// 添加刻度标签
|
| 130 |
+
g.append("text")
|
| 131 |
+
.attr("x", xScale(tick))
|
| 132 |
+
.attr("y", chartHeight + 20)
|
| 133 |
+
.attr("text-anchor", i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle")
|
| 134 |
+
.style("font-family", "Arial")
|
| 135 |
+
.style("font-size", "16px")
|
| 136 |
+
.style("fill", "#ffffff")
|
| 137 |
+
.text(xFormat(tick));
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
// 创建线条生成器
|
| 141 |
+
const line = d3.line()
|
| 142 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 143 |
+
.y(d => yScale(+d[yField]))
|
| 144 |
+
.curve(d3.curveLinear); // 使用普通折线
|
| 145 |
+
|
| 146 |
+
// 绘制每个组的线条和收集标签点
|
| 147 |
+
const labelPoints = [];
|
| 148 |
+
|
| 149 |
+
groups.forEach(group => {
|
| 150 |
+
// 过滤当前组的数据
|
| 151 |
+
let groupData = chartData.filter(d => d[groupField] === group);
|
| 152 |
+
const color = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 153 |
+
|
| 154 |
+
// 确保第一个点从Y轴开始
|
| 155 |
+
const firstDataDate = chartUtils.format.parseDate(groupData[0][xField]);
|
| 156 |
+
const xMin = new Date(xScale.domain()[0]);
|
| 157 |
+
// if (firstDataDate.getTime() > xMin.getTime()) {
|
| 158 |
+
// // 添加Y轴起点(使用第一个数据点的Y值)
|
| 159 |
+
// const firstPoint = {
|
| 160 |
+
// [xField]: xMin.getFullYear().toString(),
|
| 161 |
+
// [yField]: groupData[0][yField],
|
| 162 |
+
// [groupField]: group
|
| 163 |
+
// };
|
| 164 |
+
// groupData = [firstPoint, ...groupData];
|
| 165 |
+
// }
|
| 166 |
+
|
| 167 |
+
// 确保最后一个点精确对应最大X值
|
| 168 |
+
const lastDataDate = chartUtils.format.parseDate(groupData[groupData.length - 1][xField]);
|
| 169 |
+
const xMax = new Date(xScale.domain()[1]);
|
| 170 |
+
if (lastDataDate.getTime() < xMax.getTime()) {
|
| 171 |
+
// 添加最后一个点(使用最后一个数据点的Y值)
|
| 172 |
+
const lastPoint = {
|
| 173 |
+
[xField]: xMax.getFullYear().toString(),
|
| 174 |
+
[yField]: groupData[groupData.length - 1][yField],
|
| 175 |
+
[groupField]: group
|
| 176 |
+
};
|
| 177 |
+
groupData = [...groupData, lastPoint];
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// 绘制线条
|
| 181 |
+
g.append("path")
|
| 182 |
+
.datum(groupData)
|
| 183 |
+
.attr("fill", "none")
|
| 184 |
+
.attr("stroke", color)
|
| 185 |
+
.attr("stroke-width", 4)
|
| 186 |
+
.attr("d", line);
|
| 187 |
+
|
| 188 |
+
// 收集最终值标签点
|
| 189 |
+
if (groupData.length > 0) {
|
| 190 |
+
const lastPoint = groupData[groupData.length - 1];
|
| 191 |
+
|
| 192 |
+
labelPoints.push({
|
| 193 |
+
x: chartWidth,
|
| 194 |
+
y: yScale(+lastPoint[yField]),
|
| 195 |
+
value: chartUtils.format.integer(lastPoint[yField]).text,
|
| 196 |
+
color: color,
|
| 197 |
+
group: group
|
| 198 |
+
});
|
| 199 |
+
}
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
// 动态规划标签放置算法
|
| 203 |
+
function placeLabelsDP(points, avoidYPositions = []) {
|
| 204 |
+
// 每个格点的高度(像素)
|
| 205 |
+
const GRID_SIZE = 3;
|
| 206 |
+
// 圆点周围的保护区域(格点数)
|
| 207 |
+
const PROTECTION_RADIUS = 3;
|
| 208 |
+
// 标签高度(格点数)
|
| 209 |
+
const LABEL_HEIGHT = 10;
|
| 210 |
+
|
| 211 |
+
// 离散化Y坐标,创建格点系统
|
| 212 |
+
const minY = 0;
|
| 213 |
+
const maxY = innerHeight;
|
| 214 |
+
const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
|
| 215 |
+
|
| 216 |
+
// 按照Y坐标排序点
|
| 217 |
+
points.sort((a, b) => a.y - b.y);
|
| 218 |
+
|
| 219 |
+
// 创建格点占用标记
|
| 220 |
+
const occupied = new Array(gridCount).fill(false);
|
| 221 |
+
|
| 222 |
+
// 标记圆点周围的保护区域为已占用
|
| 223 |
+
// points.forEach(point => {
|
| 224 |
+
// const gridY = Math.floor(point.y / GRID_SIZE);
|
| 225 |
+
// for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
|
| 226 |
+
// occupied[i] = true;
|
| 227 |
+
// }
|
| 228 |
+
// });
|
| 229 |
+
|
| 230 |
+
// 标记需要避开的Y轴刻度位置为已占用
|
| 231 |
+
avoidYPositions.forEach(yPos => {
|
| 232 |
+
const gridY = Math.floor(yPos / GRID_SIZE);
|
| 233 |
+
// 为每个刻度值添加一些保护区域
|
| 234 |
+
for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
|
| 235 |
+
occupied[i] = true;
|
| 236 |
+
}
|
| 237 |
+
});
|
| 238 |
+
|
| 239 |
+
// 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
|
| 240 |
+
const n = points.length;
|
| 241 |
+
const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
|
| 242 |
+
const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
|
| 243 |
+
|
| 244 |
+
// 初始条件:第一个点的标签放置
|
| 245 |
+
const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
|
| 246 |
+
|
| 247 |
+
// 尝试将第一个点的标签放在可行的位置
|
| 248 |
+
for (let j = 0; j < gridCount; j++) {
|
| 249 |
+
// 检查是否可行(不在保护区域内且有足够空间放置标签)
|
| 250 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 251 |
+
// 检查标签是否会与其他标签重叠
|
| 252 |
+
let canPlace = true;
|
| 253 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 254 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 255 |
+
canPlace = false;
|
| 256 |
+
break;
|
| 257 |
+
}
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
// 确保第一个标签不超过第二个圆点位置(如果有第二个圆点)
|
| 261 |
+
if (n > 1) {
|
| 262 |
+
const nextPointGridY = Math.floor(points[1].y / GRID_SIZE);
|
| 263 |
+
if (j > nextPointGridY) {
|
| 264 |
+
canPlace = false; // 不允许标签越过下一个圆点
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
if (canPlace) {
|
| 269 |
+
// 计算代价:与圆点位置的距离
|
| 270 |
+
const cost = Math.abs(j - firstPointGridY);
|
| 271 |
+
dp[0][j] = cost * cost;
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// 填充dp表
|
| 277 |
+
for (let i = 1; i < n; i++) {
|
| 278 |
+
const pointGridY = Math.floor(points[i].y / GRID_SIZE);
|
| 279 |
+
|
| 280 |
+
for (let j = 0; j < gridCount; j++) {
|
| 281 |
+
// 检查当前位置是否可行
|
| 282 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 283 |
+
// 检查标签是否会与其他标签重叠
|
| 284 |
+
let canPlace = true;
|
| 285 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 286 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 287 |
+
canPlace = false;
|
| 288 |
+
break;
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
// 确保当前标签不超过下一个圆点位置(如果有下一个圆点)
|
| 293 |
+
if (i < n - 1) {
|
| 294 |
+
const nextPointGridY = Math.floor(points[i+1].y / GRID_SIZE);
|
| 295 |
+
if (j > nextPointGridY) {
|
| 296 |
+
canPlace = false; // 不允许标签越过下一个圆点
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
// 确保当前标签至少超过上一个圆点位置
|
| 301 |
+
if (i > 0) {
|
| 302 |
+
const prevPointGridY = Math.floor(points[i-1].y / GRID_SIZE);
|
| 303 |
+
if (j < prevPointGridY) {
|
| 304 |
+
canPlace = false; // 不允许标签越过上一个圆点
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
if (canPlace) {
|
| 309 |
+
// 根据上一个点的标签位置计算当前最小代价
|
| 310 |
+
for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
|
| 311 |
+
if (dp[i-1][k] !== Infinity) {
|
| 312 |
+
|
| 313 |
+
// 当前标签的代价
|
| 314 |
+
const curCost = Math.abs(j - pointGridY);
|
| 315 |
+
const totalCost = dp[i-1][k] + curCost * curCost;
|
| 316 |
+
|
| 317 |
+
if (totalCost < dp[i][j]) {
|
| 318 |
+
dp[i][j] = totalCost;
|
| 319 |
+
prev[i][j] = k; // 记录前驱
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// 找出最后一个点的最优放置位置
|
| 329 |
+
let minCost = Infinity;
|
| 330 |
+
let bestPos = -1;
|
| 331 |
+
|
| 332 |
+
for (let j = 0; j < gridCount; j++) {
|
| 333 |
+
if (dp[n-1][j] < minCost) {
|
| 334 |
+
minCost = dp[n-1][j];
|
| 335 |
+
bestPos = j;
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
// 回溯构建结果
|
| 340 |
+
const labelPositions = [];
|
| 341 |
+
if (bestPos !== -1) {
|
| 342 |
+
// 从后向前回溯
|
| 343 |
+
let pos = bestPos;
|
| 344 |
+
|
| 345 |
+
for (let i = n - 1; i >= 0; i--) {
|
| 346 |
+
labelPositions.unshift({
|
| 347 |
+
point: points[i],
|
| 348 |
+
labelY: pos * GRID_SIZE
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
pos = prev[i][pos];
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
} else {
|
| 355 |
+
// 无可行解,退化为简单方案
|
| 356 |
+
let lastY = 0;
|
| 357 |
+
for (let i = 0; i < n; i++) {
|
| 358 |
+
const point = points[i];
|
| 359 |
+
// 确保标签不超过下一个圆点的位置
|
| 360 |
+
let maxY = innerHeight;
|
| 361 |
+
if (i < n - 1) {
|
| 362 |
+
maxY = points[i+1].y;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
|
| 366 |
+
|
| 367 |
+
labelPositions.push({
|
| 368 |
+
point: point,
|
| 369 |
+
labelY: labelY
|
| 370 |
+
});
|
| 371 |
+
|
| 372 |
+
lastY = labelY;
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
return labelPositions;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
// 使用动态规划算法放置标签
|
| 380 |
+
const labelPositions = placeLabelsDP(labelPoints, []);
|
| 381 |
+
|
| 382 |
+
// 绘制标签
|
| 383 |
+
labelPositions.forEach(pos => {
|
| 384 |
+
g.append("text")
|
| 385 |
+
.attr("x", chartWidth + 5)
|
| 386 |
+
.attr("y", pos.labelY)
|
| 387 |
+
.attr("text-anchor", "start")
|
| 388 |
+
.attr("dominant-baseline", "middle")
|
| 389 |
+
.style("font-family", "Arial")
|
| 390 |
+
.style("font-size", "18px")
|
| 391 |
+
.style("font-weight", "bold")
|
| 392 |
+
.style("fill", pos.point.color)
|
| 393 |
+
.text(pos.point.value);
|
| 394 |
+
});
|
| 395 |
+
|
| 396 |
+
// 添加图例(自适应大小)
|
| 397 |
+
// 计算图例项
|
| 398 |
+
const legendItems = groups;
|
| 399 |
+
const legendPadding = 10;
|
| 400 |
+
const legendItemHeight = 20;
|
| 401 |
+
const legendItemSpacing = 0;
|
| 402 |
+
const legendTextOffset = 10;
|
| 403 |
+
const fontSize = 18;
|
| 404 |
+
|
| 405 |
+
// 计算图例的总高度
|
| 406 |
+
const legendHeight = legendItems.length * legendItemHeight + (legendItems.length - 1) * legendItemSpacing + 2 * legendPadding;
|
| 407 |
+
|
| 408 |
+
// 计算最长文本的宽度
|
| 409 |
+
const maxTextWidth = d3.max(legendItems, item => chartUtils.text.measure(svg, item, { fontFamily: "Arial", fontSize }).width) || 0;
|
| 410 |
+
|
| 411 |
+
// 计算图例的总宽度
|
| 412 |
+
const legendLineWidth = 30;
|
| 413 |
+
const legendWidth = legendLineWidth + legendTextOffset + maxTextWidth + 2 * legendPadding;
|
| 414 |
+
|
| 415 |
+
// 计算图例的最佳位置(避免与数据线交叉)
|
| 416 |
+
function findBestLegendPosition(legendWidth, legendHeight) {
|
| 417 |
+
// 定义可能的位置(右上、右下、左上、左下)
|
| 418 |
+
const positions = [
|
| 419 |
+
{ name: "rightTop", x: width - margin.right - legendWidth, y: margin.top + 10 },
|
| 420 |
+
{ name: "rightBottom", x: width - margin.right - legendWidth, y: height - margin.bottom - legendHeight },
|
| 421 |
+
{ name: "leftTop", x: margin.left + 10, y: margin.top + 10 },
|
| 422 |
+
{ name: "leftBottom", x: margin.left + 10, y: height - margin.bottom - legendHeight }
|
| 423 |
+
];
|
| 424 |
+
|
| 425 |
+
// 创建临时图例用于碰撞检测
|
| 426 |
+
const tempLegend = svg.append("rect")
|
| 427 |
+
.attr("width", legendWidth)
|
| 428 |
+
.attr("height", legendHeight)
|
| 429 |
+
.attr("fill", "none")
|
| 430 |
+
.attr("stroke", "none")
|
| 431 |
+
.style("visibility", "hidden");
|
| 432 |
+
|
| 433 |
+
// 计算每个位置与数据线的交叉程度
|
| 434 |
+
const intersections = positions.map(pos => {
|
| 435 |
+
tempLegend.attr("x", pos.x).attr("y", pos.y);
|
| 436 |
+
let intersectionCount = 0;
|
| 437 |
+
|
| 438 |
+
// 检查每条线与图例的交叉
|
| 439 |
+
groups.forEach(group => {
|
| 440 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 441 |
+
|
| 442 |
+
// 简化:检查每个数据点是否在图例区域内
|
| 443 |
+
for (let i = 0; i < groupData.length; i++) {
|
| 444 |
+
const x = margin.left + xScale(chartUtils.format.parseDate(groupData[i][xField]));
|
| 445 |
+
const y = margin.top + yScale(groupData[i][yField]);
|
| 446 |
+
|
| 447 |
+
if (x >= pos.x && x <= pos.x + legendWidth &&
|
| 448 |
+
y >= pos.y && y <= pos.y + legendHeight) {
|
| 449 |
+
intersectionCount++;
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
// 也检查线段是否穿过图例
|
| 453 |
+
if (i < groupData.length - 1) {
|
| 454 |
+
const nextX = margin.left + xScale(chartUtils.format.parseDate(groupData[i+1][xField]));
|
| 455 |
+
const nextY = margin.top + yScale(groupData[i+1][yField]);
|
| 456 |
+
|
| 457 |
+
// 简化的线段与矩形相交检测
|
| 458 |
+
if (lineIntersectsRectangle(
|
| 459 |
+
x, y, nextX, nextY,
|
| 460 |
+
pos.x, pos.y, pos.x + legendWidth, pos.y + legendHeight
|
| 461 |
+
)) {
|
| 462 |
+
intersectionCount++;
|
| 463 |
+
}
|
| 464 |
+
}
|
| 465 |
+
}
|
| 466 |
+
});
|
| 467 |
+
|
| 468 |
+
return { position: pos, count: intersectionCount };
|
| 469 |
+
});
|
| 470 |
+
|
| 471 |
+
// 移除临时图例
|
| 472 |
+
tempLegend.remove();
|
| 473 |
+
|
| 474 |
+
// 找出交叉最少的位置
|
| 475 |
+
intersections.sort((a, b) => a.count - b.count);
|
| 476 |
+
return intersections[0].position;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
// 辅助函数:检测线段是否与矩形相交
|
| 480 |
+
function lineIntersectsRectangle(x1, y1, x2, y2, rectX, rectY, rectX2, rectY2) {
|
| 481 |
+
// 检查线段的两个端点是否都在矩形的同一侧
|
| 482 |
+
if ((x1 <= rectX && x2 <= rectX) || // 线段在矩形左侧
|
| 483 |
+
(x1 >= rectX2 && x2 >= rectX2) || // 线段在矩形右侧
|
| 484 |
+
(y1 <= rectY && y2 <= rectY) || // 线段在矩形上方
|
| 485 |
+
(y1 >= rectY2 && y2 >= rectY2)) { // 线段在矩形下方
|
| 486 |
+
return false;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
// 简化的相交检测
|
| 490 |
+
// 如果线段的斜率使其穿过矩形,则认为相交
|
| 491 |
+
if (x1 !== x2) { // 非垂直线
|
| 492 |
+
const slope = (y2 - y1) / (x2 - x1);
|
| 493 |
+
const yAtRectX = y1 + slope * (rectX - x1);
|
| 494 |
+
const yAtRectX2 = y1 + slope * (rectX2 - x1);
|
| 495 |
+
|
| 496 |
+
if ((yAtRectX >= rectY && yAtRectX <= rectY2) ||
|
| 497 |
+
(yAtRectX2 >= rectY && yAtRectX2 <= rectY2)) {
|
| 498 |
+
return true;
|
| 499 |
+
}
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
if (y1 !== y2) { // 非水平线
|
| 503 |
+
const inverseSlope = (x2 - x1) / (y2 - y1);
|
| 504 |
+
const xAtRectY = x1 + inverseSlope * (rectY - y1);
|
| 505 |
+
const xAtRectY2 = x1 + inverseSlope * (rectY2 - y1);
|
| 506 |
+
|
| 507 |
+
if ((xAtRectY >= rectX && xAtRectY <= rectX2) ||
|
| 508 |
+
(xAtRectY2 >= rectX && xAtRectY2 <= rectX2)) {
|
| 509 |
+
return true;
|
| 510 |
+
}
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
return false;
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
// 找出最佳位置
|
| 517 |
+
const bestPosition = findBestLegendPosition(legendWidth, legendHeight);
|
| 518 |
+
const legendX = bestPosition.x;
|
| 519 |
+
const legendY = bestPosition.y;
|
| 520 |
+
|
| 521 |
+
const legend = svg.append("g")
|
| 522 |
+
.attr("transform", `translate(${legendX}, ${legendY})`);
|
| 523 |
+
legend.append("rect")
|
| 524 |
+
.attr("width", legendWidth)
|
| 525 |
+
.attr("height", legendHeight)
|
| 526 |
+
.attr("fill", "none")
|
| 527 |
+
.attr("stroke", "#d1ddff")
|
| 528 |
+
.attr("stroke-width", 1)
|
| 529 |
+
.attr("stroke-dasharray", "4,4");
|
| 530 |
+
chartUtils.legend.draw(legend, legendItems, {
|
| 531 |
+
color: item => colorResolver.field(item, 0, { fallbackKey: "primary" }).value,
|
| 532 |
+
colorResolver,
|
| 533 |
+
colors,
|
| 534 |
+
}, {
|
| 535 |
+
x: legendPadding,
|
| 536 |
+
y: legendPadding,
|
| 537 |
+
direction: "vertical",
|
| 538 |
+
markerShape: "line",
|
| 539 |
+
markerSize: legendLineWidth,
|
| 540 |
+
strokeWidth: 5,
|
| 541 |
+
labelGap: legendTextOffset,
|
| 542 |
+
rowGap: legendItemSpacing,
|
| 543 |
+
itemHeight: legendItemHeight,
|
| 544 |
+
fontSize: fontSize,
|
| 545 |
+
fontFamily: "Arial",
|
| 546 |
+
textColor: colorResolver.text({ fallback: "#333333" }).value,
|
| 547 |
+
});
|
| 548 |
+
|
| 549 |
+
return svg.node();
|
| 550 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_07.js
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Line Graph",
|
| 5 |
+
"chart_name": "multiple_line_graph_07",
|
| 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 |
+
// 提取数据
|
| 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_dark || {};
|
| 32 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 33 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 34 |
+
const images = jsonData.images || {};
|
| 35 |
+
// 清空容器
|
| 36 |
+
d3.select(containerSelector).html("");
|
| 37 |
+
|
| 38 |
+
// 设置尺寸和边距 - 右侧留出更多空间用于标签
|
| 39 |
+
const width = variables.width;
|
| 40 |
+
const height = variables.height;
|
| 41 |
+
const margin = { top: 40, right: 180, bottom: 60, left: 80 };
|
| 42 |
+
const innerWidth = width - margin.left - margin.right;
|
| 43 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 44 |
+
|
| 45 |
+
// 获取字段名
|
| 46 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 47 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 48 |
+
const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
|
| 49 |
+
const yLabel = yColumn.rawLabel || yField;
|
| 50 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 51 |
+
|
| 52 |
+
// 获取唯一的组值和X值
|
| 53 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 54 |
+
const xValues = [...new Set(chartData.map(d => d[xField]))]
|
| 55 |
+
.sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
|
| 56 |
+
|
| 57 |
+
// 创建比例尺 - 修改为时间比例尺
|
| 58 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
|
| 59 |
+
|
| 60 |
+
// 创建SVG
|
| 61 |
+
const svg = d3.select(containerSelector)
|
| 62 |
+
.append("svg")
|
| 63 |
+
.attr("width", "100%")
|
| 64 |
+
.attr("height", height)
|
| 65 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 66 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 67 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 68 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 69 |
+
|
| 70 |
+
// 创建图表组
|
| 71 |
+
const g = svg.append("g")
|
| 72 |
+
.attr("transform", `translate(${margin.left},${margin.top})`);
|
| 73 |
+
|
| 74 |
+
// 修改Y轴比例尺,支持负值,并确保最大刻度超过数据最大值
|
| 75 |
+
const yScale = d3.scaleLinear()
|
| 76 |
+
.domain([
|
| 77 |
+
Math.min(0, d3.min(chartData, d => +d[yField]) * 1.1), // 取最小值和0中的较小者
|
| 78 |
+
d3.max(chartData, d => +d[yField]) * 1.3 // 将最大值扩大30%
|
| 79 |
+
])
|
| 80 |
+
.range([innerHeight, 0]);
|
| 81 |
+
|
| 82 |
+
// 获取颜色
|
| 83 |
+
const getColor = (group) => {
|
| 84 |
+
return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
// 添加网格线 - 使用半透明白色
|
| 88 |
+
// 首先获取Y轴的最大刻度值
|
| 89 |
+
const yTicks = yScale.ticks(6); // 增加刻度数量,确保有足够的刻度
|
| 90 |
+
const maxYTick = yTicks[yTicks.length - 1];
|
| 91 |
+
const maxYPos = yScale(maxYTick);
|
| 92 |
+
|
| 93 |
+
// 绘制水平网格线 - 向左延伸
|
| 94 |
+
const gridExtension = 5; // 网格线向左延伸的距离
|
| 95 |
+
|
| 96 |
+
// 过滤Y轴刻度,移除最小的刻度(通常是0或负值)
|
| 97 |
+
const filteredYTicks = yTicks.filter(d => d > yTicks[0]);
|
| 98 |
+
|
| 99 |
+
// 创建水平网格线,不包括最下方的刻度
|
| 100 |
+
g.selectAll("rect.grid-rect-y")
|
| 101 |
+
.data(filteredYTicks)
|
| 102 |
+
.enter()
|
| 103 |
+
.append("rect")
|
| 104 |
+
.attr("class", "grid-rect-y")
|
| 105 |
+
.attr("class", "background")
|
| 106 |
+
.attr("x", -gridExtension - 30)
|
| 107 |
+
.attr("y", d => yScale(d) - 0.5) // 减去0.5使线条居中
|
| 108 |
+
.attr("width", innerWidth + gridExtension)
|
| 109 |
+
.attr("height", 1)
|
| 110 |
+
.style("fill", "#3f3e40");
|
| 111 |
+
|
| 112 |
+
// 绘制垂直网格线
|
| 113 |
+
g.selectAll("rect.grid-rect-x")
|
| 114 |
+
.data(xTicks)
|
| 115 |
+
.enter()
|
| 116 |
+
.append("rect")
|
| 117 |
+
.attr("class", "grid-rect-x")
|
| 118 |
+
.attr("class", "background")
|
| 119 |
+
.attr("x", d => xScale(d) - 0.5)
|
| 120 |
+
.attr("y", 0)
|
| 121 |
+
.attr("width", 1)
|
| 122 |
+
.attr("height", innerHeight)
|
| 123 |
+
.style("fill", "#3f3e40");
|
| 124 |
+
|
| 125 |
+
const line = d3.line()
|
| 126 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 127 |
+
.y(d => yScale(+d[yField]))
|
| 128 |
+
|
| 129 |
+
// 首先收集所有点信息,并分为起点和终点两组
|
| 130 |
+
const startPoints = [];
|
| 131 |
+
const endPoints = [];
|
| 132 |
+
|
| 133 |
+
let endX = 0;
|
| 134 |
+
// 获取最后一个时间点对应的x坐标
|
| 135 |
+
const lastXValue = xValues[xValues.length - 1];
|
| 136 |
+
endX = xScale(chartUtils.format.parseDate(lastXValue));
|
| 137 |
+
|
| 138 |
+
// 创建渐变定义
|
| 139 |
+
const defs = svg.append("defs");
|
| 140 |
+
|
| 141 |
+
// 添加Y轴线 - 与X轴线样式一致
|
| 142 |
+
g.append("line")
|
| 143 |
+
.attr("x1", 0)
|
| 144 |
+
.attr("y1", 0)
|
| 145 |
+
.attr("x2", 0)
|
| 146 |
+
.attr("y2", innerHeight)
|
| 147 |
+
.attr("stroke", "#e8f6fa")
|
| 148 |
+
.attr("stroke-width", 1);
|
| 149 |
+
|
| 150 |
+
// 添加最后一个年份的垂直线 - 与X轴线样式一致
|
| 151 |
+
g.append("line")
|
| 152 |
+
.attr("x1", endX)
|
| 153 |
+
.attr("y1", 0)
|
| 154 |
+
.attr("x2", endX)
|
| 155 |
+
.attr("y2", innerHeight)
|
| 156 |
+
.attr("stroke", "#e8f6fa")
|
| 157 |
+
.attr("stroke-width", 1);
|
| 158 |
+
|
| 159 |
+
// 找出结束值最高的组
|
| 160 |
+
let highestEndGroup = null;
|
| 161 |
+
let highestEndValue = -Infinity;
|
| 162 |
+
|
| 163 |
+
groups.forEach(group => {
|
| 164 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 165 |
+
const lastPoint = groupData[groupData.length - 1];
|
| 166 |
+
|
| 167 |
+
if (+lastPoint[yField] > highestEndValue) {
|
| 168 |
+
highestEndValue = lastPoint[yField];
|
| 169 |
+
highestEndGroup = group;
|
| 170 |
+
}
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
// 为最高值组创建渐变 - 使用更多的小段和插值
|
| 174 |
+
const gradientId = "area-gradient";
|
| 175 |
+
|
| 176 |
+
// 获取最高组的数据
|
| 177 |
+
const highestGroupData = chartData.filter(d => d[groupField] === highestEndGroup);
|
| 178 |
+
|
| 179 |
+
// 计算插值点,使渐变更加平滑 - 直接对x坐标进行插值
|
| 180 |
+
const interpolatedData = [];
|
| 181 |
+
const interpolationCount = 10; // 增加插值点数量,使效果更平滑
|
| 182 |
+
|
| 183 |
+
for (let i = 0; i < highestGroupData.length - 1; i++) {
|
| 184 |
+
const current = highestGroupData[i];
|
| 185 |
+
const next = highestGroupData[i + 1];
|
| 186 |
+
|
| 187 |
+
// 计算当前点和下一个点的x坐标
|
| 188 |
+
const currentX = xScale(chartUtils.format.parseDate(current[xField]));
|
| 189 |
+
const nextX = xScale(chartUtils.format.parseDate(next[xField]));
|
| 190 |
+
|
| 191 |
+
// 计算当前点和下一个点的y值
|
| 192 |
+
const currentY = current[yField];
|
| 193 |
+
const nextY = next[yField];
|
| 194 |
+
|
| 195 |
+
// 添加当前点
|
| 196 |
+
interpolatedData.push({
|
| 197 |
+
x: currentX,
|
| 198 |
+
y: currentY,
|
| 199 |
+
original: current
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
// 在当前点和下一个点之间插入额外的点
|
| 203 |
+
for (let j = 1; j <= interpolationCount; j++) {
|
| 204 |
+
const ratio = j / (interpolationCount + 1);
|
| 205 |
+
|
| 206 |
+
// 直接对x坐标和y值进行线性插值
|
| 207 |
+
const interpolatedX = currentX * (1 - ratio) + nextX * ratio;
|
| 208 |
+
const interpolatedY = currentY * (1 - ratio) + nextY * ratio;
|
| 209 |
+
|
| 210 |
+
interpolatedData.push({
|
| 211 |
+
x: interpolatedX,
|
| 212 |
+
y: interpolatedY,
|
| 213 |
+
// 保存一个引用,用于获取其他属性
|
| 214 |
+
original: current
|
| 215 |
+
});
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// 添加最后一个点
|
| 220 |
+
interpolatedData.push({
|
| 221 |
+
x: xScale(chartUtils.format.parseDate(highestGroupData[highestGroupData.length - 1][xField])),
|
| 222 |
+
y: highestGroupData[highestGroupData.length - 1][yField],
|
| 223 |
+
original: highestGroupData[highestGroupData.length - 1]
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
// 为每个插值点创建一个渐变
|
| 227 |
+
interpolatedData.forEach((d, i) => {
|
| 228 |
+
const pointGradientId = `${gradientId}-${i}`;
|
| 229 |
+
const pointGradient = defs.append("linearGradient")
|
| 230 |
+
.attr("id", pointGradientId)
|
| 231 |
+
.attr("x1", "0%")
|
| 232 |
+
.attr("y1", "0%")
|
| 233 |
+
.attr("x2", "0%")
|
| 234 |
+
.attr("y2", "100%")
|
| 235 |
+
.attr("gradientUnits", "userSpaceOnUse")
|
| 236 |
+
.attr("gradientTransform", `translate(0, ${yScale(d.y)})`);
|
| 237 |
+
|
| 238 |
+
// 添加渐变色标 - 使用更多的色标点
|
| 239 |
+
pointGradient.append("stop")
|
| 240 |
+
.attr("offset", "0%")
|
| 241 |
+
.attr("stop-color", getColor(highestEndGroup))
|
| 242 |
+
.attr("stop-opacity", 0.7);
|
| 243 |
+
|
| 244 |
+
pointGradient.append("stop")
|
| 245 |
+
.attr("offset", "20%")
|
| 246 |
+
.attr("stop-color", getColor(highestEndGroup))
|
| 247 |
+
.attr("stop-opacity", 0.5);
|
| 248 |
+
|
| 249 |
+
pointGradient.append("stop")
|
| 250 |
+
.attr("offset", "40%")
|
| 251 |
+
.attr("stop-color", getColor(highestEndGroup))
|
| 252 |
+
.attr("stop-opacity", 0.3);
|
| 253 |
+
|
| 254 |
+
pointGradient.append("stop")
|
| 255 |
+
.attr("offset", "60%")
|
| 256 |
+
.attr("stop-color", getColor(highestEndGroup))
|
| 257 |
+
.attr("stop-opacity", 0.2);
|
| 258 |
+
|
| 259 |
+
pointGradient.append("stop")
|
| 260 |
+
.attr("offset", "80%")
|
| 261 |
+
.attr("stop-color", getColor(highestEndGroup))
|
| 262 |
+
.attr("stop-opacity", 0.1);
|
| 263 |
+
|
| 264 |
+
pointGradient.append("stop")
|
| 265 |
+
.attr("offset", "100%")
|
| 266 |
+
.attr("stop-color", getColor(highestEndGroup))
|
| 267 |
+
.attr("stop-opacity", 0);
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
groups.forEach(group => {
|
| 271 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 272 |
+
|
| 273 |
+
// 如果是最高值组,添加面积渐变
|
| 274 |
+
if (group === highestEndGroup) {
|
| 275 |
+
// 创建自定义面积生成器,直接使用x坐标
|
| 276 |
+
const customArea = (segment) => {
|
| 277 |
+
const [p1, p2] = segment;
|
| 278 |
+
return `
|
| 279 |
+
M ${p1.x},${yScale(p1.y)}
|
| 280 |
+
L ${p2.x},${yScale(p2.y)}
|
| 281 |
+
L ${p2.x},${innerHeight}
|
| 282 |
+
L ${p1.x},${innerHeight}
|
| 283 |
+
Z
|
| 284 |
+
`;
|
| 285 |
+
};
|
| 286 |
+
|
| 287 |
+
// 添加面积 - 使用插值后的数据点
|
| 288 |
+
for (let i = 0; i < interpolatedData.length - 1; i++) {
|
| 289 |
+
const segmentData = [interpolatedData[i], interpolatedData[i+1]];
|
| 290 |
+
const segmentGradientId = `${gradientId}-${i}`;
|
| 291 |
+
|
| 292 |
+
g.append("path")
|
| 293 |
+
.attr("class", "area-segment")
|
| 294 |
+
.attr("fill", `url(#${segmentGradientId})`)
|
| 295 |
+
.attr("d", customArea(segmentData))
|
| 296 |
+
.attr("opacity", 0.8);
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
// 绘制线条 - 添加黑色描边
|
| 301 |
+
// 先绘制黑色描边
|
| 302 |
+
g.append("path")
|
| 303 |
+
.datum(groupData)
|
| 304 |
+
.attr("class", "line-stroke")
|
| 305 |
+
.attr("fill", "none")
|
| 306 |
+
.attr("stroke", "#333333")
|
| 307 |
+
.attr("stroke-width", 8) // 比实际线条宽一些
|
| 308 |
+
.attr("d", line);
|
| 309 |
+
|
| 310 |
+
// 再绘制彩色线条
|
| 311 |
+
g.append("path")
|
| 312 |
+
.datum(groupData)
|
| 313 |
+
.attr("class", "line")
|
| 314 |
+
.attr("fill", "none")
|
| 315 |
+
.attr("stroke", getColor(group))
|
| 316 |
+
.attr("stroke-width", 4)
|
| 317 |
+
.attr("d", line);
|
| 318 |
+
|
| 319 |
+
// 添加起点和终点的圆点
|
| 320 |
+
const firstPoint = groupData[0];
|
| 321 |
+
const lastPoint = groupData[groupData.length - 1];
|
| 322 |
+
|
| 323 |
+
// 处理起点 - 添加黑色描边
|
| 324 |
+
const startX = xScale(chartUtils.format.parseDate(firstPoint[xField]));
|
| 325 |
+
const startY = yScale(+firstPoint[yField]);
|
| 326 |
+
|
| 327 |
+
// 添加圆点描边
|
| 328 |
+
g.append("circle")
|
| 329 |
+
.attr("cx", startX)
|
| 330 |
+
.attr("cy", startY)
|
| 331 |
+
.attr("r", 6) // 比实际圆点大一些
|
| 332 |
+
.attr("fill", "#333333");
|
| 333 |
+
|
| 334 |
+
// 添加圆点
|
| 335 |
+
g.append("circle")
|
| 336 |
+
.attr("cx", startX)
|
| 337 |
+
.attr("cy", startY)
|
| 338 |
+
.attr("r", 4)
|
| 339 |
+
.attr("fill", getColor(group));
|
| 340 |
+
|
| 341 |
+
// 收集起点信息
|
| 342 |
+
startPoints.push({
|
| 343 |
+
x: startX,
|
| 344 |
+
y: startY,
|
| 345 |
+
value: chartUtils.format.integer(firstPoint[yField]).text,
|
| 346 |
+
color: getColor(group),
|
| 347 |
+
group: group
|
| 348 |
+
});
|
| 349 |
+
|
| 350 |
+
// 处理终点 - 添加黑色描边
|
| 351 |
+
endX = xScale(chartUtils.format.parseDate(lastPoint[xField]));
|
| 352 |
+
const endY = yScale(+lastPoint[yField]);
|
| 353 |
+
|
| 354 |
+
// 添加圆点描边
|
| 355 |
+
g.append("circle")
|
| 356 |
+
.attr("cx", endX)
|
| 357 |
+
.attr("cy", endY)
|
| 358 |
+
.attr("r", 6) // 比实际圆点大一些
|
| 359 |
+
.attr("fill", "#333333");
|
| 360 |
+
|
| 361 |
+
// 添加圆点
|
| 362 |
+
g.append("circle")
|
| 363 |
+
.attr("cx", endX)
|
| 364 |
+
.attr("cy", endY)
|
| 365 |
+
.attr("r", 4)
|
| 366 |
+
.attr("fill", getColor(group));
|
| 367 |
+
|
| 368 |
+
// 收集终点信息
|
| 369 |
+
endPoints.push({
|
| 370 |
+
x: endX,
|
| 371 |
+
y: endY,
|
| 372 |
+
value: chartUtils.format.integer(lastPoint[yField]).text,
|
| 373 |
+
color: getColor(group),
|
| 374 |
+
group: group
|
| 375 |
+
});
|
| 376 |
+
|
| 377 |
+
});
|
| 378 |
+
|
| 379 |
+
// 绘制X轴 - 使用均匀分布的刻度
|
| 380 |
+
const xAxis = g.append("g")
|
| 381 |
+
.attr("transform", `translate(0,${innerHeight})`)
|
| 382 |
+
.call(d3.axisBottom(xScale)
|
| 383 |
+
.tickFormat(xFormat) // 使用自定义刻度值
|
| 384 |
+
);
|
| 385 |
+
|
| 386 |
+
// 设置X轴样式,并下移文本
|
| 387 |
+
xAxis.selectAll("text")
|
| 388 |
+
.style("font-family", typography.label.font_family)
|
| 389 |
+
.style("font-size", "12px")
|
| 390 |
+
.style("fill", "#ffffff")
|
| 391 |
+
.attr("dy", "0.7em"); // 下移文本
|
| 392 |
+
|
| 393 |
+
// 移除X轴线和刻度
|
| 394 |
+
xAxis.select(".domain").remove();
|
| 395 |
+
xAxis.selectAll(".tick line").remove();
|
| 396 |
+
|
| 397 |
+
// 在X轴刻度下方添加一条水平线
|
| 398 |
+
g.append("line")
|
| 399 |
+
.attr("x1", -20)
|
| 400 |
+
.attr("y1", innerHeight) // 放在X轴刻度文本下方
|
| 401 |
+
.attr("x2", innerWidth + 20)
|
| 402 |
+
.attr("y2", innerHeight) // 保持水平
|
| 403 |
+
.attr("stroke", "#e8f6fa") // 与X轴文本相同的颜色
|
| 404 |
+
.attr("stroke-width", 1);
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
// 绘制Y轴 - 移除B后缀,并调整刻度位置
|
| 408 |
+
const yAxis = g.append("g")
|
| 409 |
+
.call(d3.axisLeft(yScale)
|
| 410 |
+
.tickValues(filteredYTicks) // 使用过滤后的刻度
|
| 411 |
+
.tickSize(0) // 移除刻度线
|
| 412 |
+
);
|
| 413 |
+
|
| 414 |
+
// 移除Y轴线
|
| 415 |
+
yAxis.select(".domain").remove();
|
| 416 |
+
yAxis.selectAll(".tick line").remove();
|
| 417 |
+
|
| 418 |
+
// 手动添加Y轴刻度文本,放在延伸的网格线上方
|
| 419 |
+
yAxis.selectAll(".tick text")
|
| 420 |
+
.attr("x", -gridExtension - 5) // 放在延伸的网格线上方,留出一点间距
|
| 421 |
+
.attr("dy", -5)
|
| 422 |
+
.style("font-family", typography.label.font_family)
|
| 423 |
+
.style("font-size", "12px")
|
| 424 |
+
.style("fill", "#ffffff") // 白色
|
| 425 |
+
.style("text-anchor", "end")
|
| 426 |
+
.text(d => d);
|
| 427 |
+
|
| 428 |
+
// 添加Y轴标题 - 旋转90度
|
| 429 |
+
g.append("text")
|
| 430 |
+
.attr("transform", "rotate(-90)")
|
| 431 |
+
.attr("y", -margin.left + 20) // 位置调整
|
| 432 |
+
.attr("x", -innerHeight / 2) // 居中
|
| 433 |
+
.attr("dy", "1em")
|
| 434 |
+
.style("text-anchor", "middle")
|
| 435 |
+
.style("font-family", typography.label.font_family)
|
| 436 |
+
.style("font-size", "12px")
|
| 437 |
+
.style("fill", "#ffffff")
|
| 438 |
+
.text(yLabel); // 使用标签或字段名
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
// 动态规划标签放置算法
|
| 443 |
+
function placeLabelsDP(points, avoidYPositions = []) {
|
| 444 |
+
// 每个格点的高度(像素)
|
| 445 |
+
const GRID_SIZE = 3;
|
| 446 |
+
// 圆点周围的保护区域(格点数)
|
| 447 |
+
const PROTECTION_RADIUS = 3;
|
| 448 |
+
// 标签高度(格点数)
|
| 449 |
+
const LABEL_HEIGHT = 5;
|
| 450 |
+
|
| 451 |
+
// 离散化Y坐标,创建格点系统
|
| 452 |
+
const minY = 0;
|
| 453 |
+
const maxY = innerHeight;
|
| 454 |
+
const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
|
| 455 |
+
|
| 456 |
+
// 按照Y坐标排序点
|
| 457 |
+
points.sort((a, b) => a.y - b.y);
|
| 458 |
+
|
| 459 |
+
// 创建格点占用标记
|
| 460 |
+
const occupied = new Array(gridCount).fill(false);
|
| 461 |
+
|
| 462 |
+
// 标记圆点周围的保护区域为已占用
|
| 463 |
+
points.forEach(point => {
|
| 464 |
+
const gridY = Math.floor(point.y / GRID_SIZE);
|
| 465 |
+
for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
|
| 466 |
+
occupied[i] = true;
|
| 467 |
+
}
|
| 468 |
+
});
|
| 469 |
+
|
| 470 |
+
// 标记需要避开的Y轴刻度位置为已占用
|
| 471 |
+
avoidYPositions.forEach(yPos => {
|
| 472 |
+
const gridY = Math.floor(yPos / GRID_SIZE);
|
| 473 |
+
// 为每个刻度值添加一些保护区域
|
| 474 |
+
for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
|
| 475 |
+
occupied[i] = true;
|
| 476 |
+
}
|
| 477 |
+
});
|
| 478 |
+
|
| 479 |
+
// 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
|
| 480 |
+
const n = points.length;
|
| 481 |
+
const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
|
| 482 |
+
const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
|
| 483 |
+
|
| 484 |
+
// 初始条件:第一个点的标签放置
|
| 485 |
+
const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
|
| 486 |
+
|
| 487 |
+
// 尝试将第一个点的标签放在可行的位置
|
| 488 |
+
for (let j = 0; j < gridCount; j++) {
|
| 489 |
+
// 检查是否可行(不在保护区域内且有足够空间放置标签)
|
| 490 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 491 |
+
// 检查标签是否会与其他标签重叠
|
| 492 |
+
let canPlace = true;
|
| 493 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 494 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 495 |
+
canPlace = false;
|
| 496 |
+
break;
|
| 497 |
+
}
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
// 确保第一个标签不超过第二个圆点位置(如果有第二个圆点)
|
| 501 |
+
if (n > 1) {
|
| 502 |
+
const nextPointGridY = Math.floor(points[1].y / GRID_SIZE);
|
| 503 |
+
if (j > nextPointGridY) {
|
| 504 |
+
canPlace = false; // 不允许标签越过下一个圆点
|
| 505 |
+
}
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
if (canPlace) {
|
| 509 |
+
// 计算代价:与圆点位置的距离
|
| 510 |
+
const cost = Math.abs(j - firstPointGridY);
|
| 511 |
+
dp[0][j] = cost;
|
| 512 |
+
}
|
| 513 |
+
}
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
// 填充dp表
|
| 517 |
+
for (let i = 1; i < n; i++) {
|
| 518 |
+
const pointGridY = Math.floor(points[i].y / GRID_SIZE);
|
| 519 |
+
|
| 520 |
+
for (let j = 0; j < gridCount; j++) {
|
| 521 |
+
// 检查当前位置是否可行
|
| 522 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 523 |
+
// 检查标签是否会与其他标签重叠
|
| 524 |
+
let canPlace = true;
|
| 525 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 526 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 527 |
+
canPlace = false;
|
| 528 |
+
break;
|
| 529 |
+
}
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
// 确保当前标签不超过下一个圆点位置(如果有下一个圆点)
|
| 533 |
+
if (i < n - 1) {
|
| 534 |
+
const nextPointGridY = Math.floor(points[i+1].y / GRID_SIZE);
|
| 535 |
+
if (j > nextPointGridY) {
|
| 536 |
+
canPlace = false; // 不允许标签越过下一个圆点
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
// 确保当前标签至少超过上一个圆点位置
|
| 541 |
+
if (i > 0) {
|
| 542 |
+
const prevPointGridY = Math.floor(points[i-1].y / GRID_SIZE);
|
| 543 |
+
if (j < prevPointGridY) {
|
| 544 |
+
canPlace = false; // 不允许标签越过上一个圆点
|
| 545 |
+
}
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
if (canPlace) {
|
| 549 |
+
// 根据上一个点的标签位置计算当前最小代价
|
| 550 |
+
for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
|
| 551 |
+
if (dp[i-1][k] !== Infinity) {
|
| 552 |
+
|
| 553 |
+
// 当前标签的代价
|
| 554 |
+
const curCost = Math.abs(j - pointGridY);
|
| 555 |
+
const totalCost = dp[i-1][k] + curCost;
|
| 556 |
+
|
| 557 |
+
if (totalCost < dp[i][j]) {
|
| 558 |
+
dp[i][j] = totalCost;
|
| 559 |
+
prev[i][j] = k; // 记录前驱
|
| 560 |
+
}
|
| 561 |
+
}
|
| 562 |
+
}
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
}
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
// 找出最后一个点的最优放置位置
|
| 569 |
+
let minCost = Infinity;
|
| 570 |
+
let bestPos = -1;
|
| 571 |
+
|
| 572 |
+
for (let j = 0; j < gridCount; j++) {
|
| 573 |
+
if (dp[n-1][j] < minCost) {
|
| 574 |
+
minCost = dp[n-1][j];
|
| 575 |
+
bestPos = j;
|
| 576 |
+
}
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
// 回溯构建结果
|
| 580 |
+
const labelPositions = [];
|
| 581 |
+
if (bestPos !== -1) {
|
| 582 |
+
// 从后向前回溯
|
| 583 |
+
let pos = bestPos;
|
| 584 |
+
|
| 585 |
+
for (let i = n - 1; i >= 0; i--) {
|
| 586 |
+
labelPositions.unshift({
|
| 587 |
+
point: points[i],
|
| 588 |
+
labelY: pos * GRID_SIZE
|
| 589 |
+
});
|
| 590 |
+
|
| 591 |
+
pos = prev[i][pos];
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
} else {
|
| 595 |
+
// 无可行解,退化为简单方案
|
| 596 |
+
let lastY = 0;
|
| 597 |
+
for (let i = 0; i < n; i++) {
|
| 598 |
+
const point = points[i];
|
| 599 |
+
|
| 600 |
+
const labelY = Math.max(point.y, lastY + 45);
|
| 601 |
+
|
| 602 |
+
labelPositions.push({
|
| 603 |
+
point: point,
|
| 604 |
+
labelY: labelY
|
| 605 |
+
});
|
| 606 |
+
|
| 607 |
+
lastY = labelY;
|
| 608 |
+
}
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
return labelPositions;
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
// 分别对起点和终点应用动态规划
|
| 615 |
+
// 获取Y轴刻度位置用于避开
|
| 616 |
+
const endLabelPositions = placeLabelsDP(endPoints, []); // 终点不需要避开Y轴刻度
|
| 617 |
+
|
| 618 |
+
// 为每个组创建一个图像图标
|
| 619 |
+
endLabelPositions.forEach(placement => {
|
| 620 |
+
const {point, labelY} = placement;
|
| 621 |
+
|
| 622 |
+
// 如果是最高组,特殊处理其标签位置
|
| 623 |
+
if (point.group === highestEndGroup) {
|
| 624 |
+
// 创建10px间隔的网格系统
|
| 625 |
+
const gridSize = 10;
|
| 626 |
+
const gridWidth = Math.ceil(innerWidth / gridSize);
|
| 627 |
+
const gridHeight = Math.ceil(innerHeight / gridSize);
|
| 628 |
+
const grid = Array(gridHeight).fill().map(() => Array(gridWidth).fill(false));
|
| 629 |
+
|
| 630 |
+
// 标记所有折线占用的网格
|
| 631 |
+
groups.forEach(group => {
|
| 632 |
+
if (group !== highestEndGroup) { // 不标记最高组自己的折线
|
| 633 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 634 |
+
|
| 635 |
+
// 标记折线路径
|
| 636 |
+
for (let i = 0; i < groupData.length - 1; i++) {
|
| 637 |
+
const current = groupData[i];
|
| 638 |
+
const next = groupData[i + 1];
|
| 639 |
+
|
| 640 |
+
const x1 = xScale(chartUtils.format.parseDate(current[xField]));
|
| 641 |
+
const y1 = yScale(current[yField]);
|
| 642 |
+
const x2 = xScale(chartUtils.format.parseDate(next[xField]));
|
| 643 |
+
const y2 = yScale(next[yField]);
|
| 644 |
+
|
| 645 |
+
// 使用线性插值标记路径上的点
|
| 646 |
+
const steps = Math.max(
|
| 647 |
+
Math.ceil(Math.abs(x2 - x1) / gridSize),
|
| 648 |
+
Math.ceil(Math.abs(y2 - y1) / gridSize)
|
| 649 |
+
);
|
| 650 |
+
|
| 651 |
+
for (let step = 0; step <= steps; step++) {
|
| 652 |
+
const ratio = step / steps;
|
| 653 |
+
const x = x1 + (x2 - x1) * ratio;
|
| 654 |
+
const y = y1 + (y2 - y1) * ratio;
|
| 655 |
+
|
| 656 |
+
// 标记周围的网格为占用
|
| 657 |
+
const gridX = Math.floor(x / gridSize);
|
| 658 |
+
const gridY = Math.floor(y / gridSize);
|
| 659 |
+
grid[gridY][gridX] = true;
|
| 660 |
+
|
| 661 |
+
// // 添加保护区域
|
| 662 |
+
// for (let dy = -2; dy <= 2; dy++) {
|
| 663 |
+
// for (let dx = -2; dx <= 2; dx++) {
|
| 664 |
+
// const nx = gridX + dx;
|
| 665 |
+
// const ny = gridY + dy;
|
| 666 |
+
// if (nx >= 0 && nx < gridWidth && ny >= 0 && ny < gridHeight) {
|
| 667 |
+
// grid[ny][nx] = true; // 标记为占用
|
| 668 |
+
// }
|
| 669 |
+
// }
|
| 670 |
+
// }
|
| 671 |
+
}
|
| 672 |
+
}
|
| 673 |
+
}
|
| 674 |
+
});
|
| 675 |
+
|
| 676 |
+
// 标记最高组折线上方的区域为占用
|
| 677 |
+
const highestGroupData = chartData.filter(d => d[groupField] === highestEndGroup);
|
| 678 |
+
for (let i = 0; i < highestGroupData.length - 1; i++) {
|
| 679 |
+
const current = highestGroupData[i];
|
| 680 |
+
const next = highestGroupData[i + 1];
|
| 681 |
+
|
| 682 |
+
const x1 = xScale(chartUtils.format.parseDate(current[xField]));
|
| 683 |
+
const y1 = yScale(current[yField]);
|
| 684 |
+
const x2 = xScale(chartUtils.format.parseDate(next[xField]));
|
| 685 |
+
const y2 = yScale(next[yField]);
|
| 686 |
+
|
| 687 |
+
// 使用线性插值标记路径上的点
|
| 688 |
+
const steps = Math.max(
|
| 689 |
+
Math.ceil(Math.abs(x2 - x1) / gridSize),
|
| 690 |
+
Math.ceil(Math.abs(y2 - y1) / gridSize)
|
| 691 |
+
);
|
| 692 |
+
|
| 693 |
+
for (let step = 0; step <= steps; step++) {
|
| 694 |
+
const ratio = step / steps;
|
| 695 |
+
const x = x1 + (x2 - x1) * ratio;
|
| 696 |
+
const y = y1 + (y2 - y1) * ratio;
|
| 697 |
+
|
| 698 |
+
// 标记当前点上方的所有网格为占用
|
| 699 |
+
const gridX = Math.floor(x / gridSize);
|
| 700 |
+
const minY = 0;
|
| 701 |
+
const maxY = Math.ceil(y / gridSize);
|
| 702 |
+
|
| 703 |
+
for (let gridY = minY; gridY <= maxY - 2; gridY++) {
|
| 704 |
+
if (gridX >= 0 && gridX < gridWidth && gridY >= 0 && gridY < gridHeight) {
|
| 705 |
+
grid[gridY][gridX] = true;
|
| 706 |
+
}
|
| 707 |
+
}
|
| 708 |
+
}
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
// 找到最高组折线下方的空位
|
| 712 |
+
|
| 713 |
+
// 计算标签尺寸(网格单位)
|
| 714 |
+
const labelWidth = 4 + Math.ceil(chartUtils.text.measure(null, point.group, { fontSize: 22 }).width / gridSize); // 根据实际文本宽度计算网格单位
|
| 715 |
+
const labelHeight = 4; // 估计标签高度
|
| 716 |
+
|
| 717 |
+
// 找到合适的位置
|
| 718 |
+
let bestX = -1, bestY = -1;
|
| 719 |
+
let bestScore = -Infinity;
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
// 在折线下方搜索空位
|
| 723 |
+
for (let gridY = 0; gridY < gridHeight - labelHeight; gridY++) {
|
| 724 |
+
for (let gridX = 0; gridX < gridWidth - labelWidth; gridX++) {
|
| 725 |
+
// 检查这个区域是否有足够的空间
|
| 726 |
+
let hasSpace = true;
|
| 727 |
+
for (let dy = 0; dy < labelHeight; dy++) {
|
| 728 |
+
for (let dx = 0; dx < labelWidth; dx++) {
|
| 729 |
+
if (grid[gridY + dy][gridX + dx]) {
|
| 730 |
+
hasSpace = false;
|
| 731 |
+
break;
|
| 732 |
+
}
|
| 733 |
+
}
|
| 734 |
+
if (!hasSpace) break;
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
if (hasSpace) {
|
| 738 |
+
// 优先选择靠上且靠中间的位置
|
| 739 |
+
const centerX = gridWidth / 2;
|
| 740 |
+
const distanceFromCenter = Math.abs(gridX + labelWidth / 2 - centerX);
|
| 741 |
+
const score = -gridY * 0.01 - 2 * distanceFromCenter; // 越靠上gridY越小,越靠近中间distanceFromCenter越小,分数越高
|
| 742 |
+
|
| 743 |
+
if (score > bestScore) {
|
| 744 |
+
bestScore = score;
|
| 745 |
+
bestX = gridX;
|
| 746 |
+
bestY = gridY;
|
| 747 |
+
}
|
| 748 |
+
}
|
| 749 |
+
}
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
// 可视化网格占用情况
|
| 753 |
+
const debugGrid = false; // 设置为true开启可视化,false关闭
|
| 754 |
+
|
| 755 |
+
if (debugGrid) {
|
| 756 |
+
// 创建一个单独的组用于网格可视化
|
| 757 |
+
const gridDebugGroup = g.append("g")
|
| 758 |
+
.attr("class", "grid-debug");
|
| 759 |
+
|
| 760 |
+
// 绘制所有网格单元
|
| 761 |
+
for (let gridY = 0; gridY < gridHeight; gridY++) {
|
| 762 |
+
for (let gridX = 0; gridX < gridWidth; gridX++) {
|
| 763 |
+
// 计算实际坐标
|
| 764 |
+
const x = gridX * gridSize;
|
| 765 |
+
const y = gridY * gridSize;
|
| 766 |
+
|
| 767 |
+
// 绘制网格单元
|
| 768 |
+
gridDebugGroup.append("rect")
|
| 769 |
+
.attr("x", x)
|
| 770 |
+
.attr("y", y)
|
| 771 |
+
.attr("width", gridSize)
|
| 772 |
+
.attr("height", gridSize)
|
| 773 |
+
.attr("fill", grid[gridY][gridX] ? "rgba(255, 0, 0, 0.2)" : "rgba(0, 255, 0, 0.05)")
|
| 774 |
+
.attr("stroke", "rgba(255, 255, 255, 0.1)")
|
| 775 |
+
.attr("stroke-width", 0.5);
|
| 776 |
+
}
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
// 标记最佳位置
|
| 780 |
+
if (bestX >= 0 && bestY >= 0) {
|
| 781 |
+
gridDebugGroup.append("rect")
|
| 782 |
+
.attr("x", bestX * gridSize)
|
| 783 |
+
.attr("y", bestY * gridSize)
|
| 784 |
+
.attr("width", gridSize)
|
| 785 |
+
.attr("height", gridSize)
|
| 786 |
+
.attr("fill", "rgba(0, 0, 255, 0.5)")
|
| 787 |
+
.attr("stroke", "white")
|
| 788 |
+
.attr("stroke-width", 1);
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
// 如果找到了合适的位置
|
| 794 |
+
if (bestX >= 0 && bestY >= 0) {
|
| 795 |
+
// 创建标签组 - 只用于图标和组名
|
| 796 |
+
const iconGroup = g.append("g")
|
| 797 |
+
.attr("transform", `translate(${bestX * gridSize}, ${bestY * gridSize})`);
|
| 798 |
+
|
| 799 |
+
// 添加图像图标
|
| 800 |
+
if (images && images.field[point.group]) {
|
| 801 |
+
iconGroup.append("image")
|
| 802 |
+
.attr("x", 0)
|
| 803 |
+
.attr("y", 0)
|
| 804 |
+
.attr("width", 40)
|
| 805 |
+
.attr("height", 40)
|
| 806 |
+
.attr("xlink:href", images.field[point.group]);
|
| 807 |
+
} else {
|
| 808 |
+
iconGroup.append("circle")
|
| 809 |
+
.attr("cx", 10)
|
| 810 |
+
.attr("cy", 10)
|
| 811 |
+
.attr("r", 5)
|
| 812 |
+
.attr("fill", point.color);
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
// 添加组名
|
| 816 |
+
iconGroup.append("text")
|
| 817 |
+
.attr("x", 40)
|
| 818 |
+
.attr("y", 25)
|
| 819 |
+
.attr("dominant-baseline", "middle")
|
| 820 |
+
.style("font-family", typography.label.font_family)
|
| 821 |
+
.style("font-size", "22px")
|
| 822 |
+
.style("font-weight", "bold")
|
| 823 |
+
.style("fill", "#f7f7fc")
|
| 824 |
+
.text(point.group);
|
| 825 |
+
|
| 826 |
+
// 创建常规标签组 - 只用于标签值
|
| 827 |
+
// 但位置在右侧,与其他标签一致
|
| 828 |
+
const valueGroup = g.append("g")
|
| 829 |
+
.attr("transform", `translate(${point.x + 15}, ${labelY})`);
|
| 830 |
+
|
| 831 |
+
// 只添加标签值
|
| 832 |
+
valueGroup.append("text")
|
| 833 |
+
.attr("x", 0)
|
| 834 |
+
.attr("y", -10)
|
| 835 |
+
.attr("dominant-baseline", "middle")
|
| 836 |
+
.style("font-family", typography.label.font_family)
|
| 837 |
+
.style("font-size", "24px")
|
| 838 |
+
.style("font-weight", "bold")
|
| 839 |
+
.style("fill", point.color)
|
| 840 |
+
.text(point.value);
|
| 841 |
+
|
| 842 |
+
// 跳过常规标签创建
|
| 843 |
+
return;
|
| 844 |
+
}
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
// 常规标签创建(非最高组或未找到合适位置的最高组)
|
| 848 |
+
// 创建标签组
|
| 849 |
+
const labelGroup = g.append("g")
|
| 850 |
+
.attr("transform", `translate(${point.x + 15}, ${labelY})`);
|
| 851 |
+
|
| 852 |
+
// 第一行:图像图标 + 组名(较小字体)
|
| 853 |
+
// 添加图像图标
|
| 854 |
+
if (images && images.field[point.group]) {
|
| 855 |
+
// 使用图像作为图标
|
| 856 |
+
labelGroup.append("image")
|
| 857 |
+
.attr("x", -10) // 调整位置,使图标居中
|
| 858 |
+
.attr("y", -10) // 调整位置,使图标居中
|
| 859 |
+
.attr("width", 20)
|
| 860 |
+
.attr("height", 20)
|
| 861 |
+
.attr("xlink:href", images.field[point.group]);
|
| 862 |
+
} else {
|
| 863 |
+
// 如果没有图像,使用彩色圆点作为备选
|
| 864 |
+
labelGroup.append("circle")
|
| 865 |
+
.attr("cx", 0)
|
| 866 |
+
.attr("cy", 0)
|
| 867 |
+
.attr("r", 3)
|
| 868 |
+
.attr("fill", point.color);
|
| 869 |
+
}
|
| 870 |
+
|
| 871 |
+
// 添加组名(较小字体)
|
| 872 |
+
labelGroup.append("text")
|
| 873 |
+
.attr("x", 15) // 增加与图标的距离
|
| 874 |
+
.attr("y", 0)
|
| 875 |
+
.attr("dominant-baseline", "middle")
|
| 876 |
+
.style("font-family", typography.label.font_family)
|
| 877 |
+
.style("font-size", "14px") // 较小字体
|
| 878 |
+
.style("font-weight", "normal") // 普通粗细
|
| 879 |
+
.style("fill", point.color) // 使用组的颜色
|
| 880 |
+
.text(point.group);
|
| 881 |
+
|
| 882 |
+
// 第二行:标签值(较大字体)
|
| 883 |
+
labelGroup.append("text")
|
| 884 |
+
.attr("x", 0)
|
| 885 |
+
.attr("y", 14) // 下移到第二行
|
| 886 |
+
.attr("dominant-baseline", "hanging")
|
| 887 |
+
.style("font-family", typography.label.font_family)
|
| 888 |
+
.style("font-size", "18px") // 较大字体
|
| 889 |
+
.style("font-weight", "bold") // 加粗
|
| 890 |
+
.style("fill", point.color) // 使用组的颜色
|
| 891 |
+
.text(point.value);
|
| 892 |
+
});
|
| 893 |
+
return svg.node();
|
| 894 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_08.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Line Graph",
|
| 5 |
+
"chart_name": "multiple_line_graph_08",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 30], [0, "inf"], [2, 7]],
|
| 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": "dark",
|
| 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_dark || {};
|
| 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 width = variables.width;
|
| 41 |
+
const height = variables.height;
|
| 42 |
+
const margin = { top: 40, right: 60, bottom: 60, left: 0 }; // 左侧边距设为0
|
| 43 |
+
const innerWidth = width - margin.left - margin.right;
|
| 44 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 45 |
+
|
| 46 |
+
// 获取字段名
|
| 47 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 48 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 49 |
+
const yColumn = chartUtils.schema.column(dataColumns, 1, { fallbackKey: yField });
|
| 50 |
+
const yLabel = yColumn.rawLabel || yField;
|
| 51 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 52 |
+
|
| 53 |
+
// 获取唯一的组值
|
| 54 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 55 |
+
|
| 56 |
+
// 创建SVG
|
| 57 |
+
const svg = d3.select(containerSelector)
|
| 58 |
+
.append("svg")
|
| 59 |
+
.attr("width", "100%")
|
| 60 |
+
.attr("height", height)
|
| 61 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 62 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 63 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 64 |
+
|
| 65 |
+
// 创建图表区域
|
| 66 |
+
const g = svg.append("g")
|
| 67 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 68 |
+
|
| 69 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
|
| 70 |
+
|
| 71 |
+
// const yMin = Math.min(0, d3.min(chartData, d => +d[yField]) * 1.1);
|
| 72 |
+
// const yMax = d3.max(chartData, d => +d[yField]) * 1.1;
|
| 73 |
+
const yMin = d3.min(chartData, d => +d[yField]);
|
| 74 |
+
const yMax = d3.max(chartData, d => +d[yField]);
|
| 75 |
+
const yRange = yMax - yMin;
|
| 76 |
+
const yMinScale = yMin - yRange * 0.2;
|
| 77 |
+
const yMaxScale = yMax + yRange * 0.2;
|
| 78 |
+
const yScale = d3.scaleLinear()
|
| 79 |
+
.domain([yMinScale, yMaxScale])
|
| 80 |
+
.range([innerHeight, 0]);
|
| 81 |
+
|
| 82 |
+
// 使用数据JSON中的颜色
|
| 83 |
+
// 获取颜色
|
| 84 |
+
const getColor = (group) => {
|
| 85 |
+
return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
// 创建线条生成器 - 改为折线
|
| 89 |
+
const line = d3.line()
|
| 90 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 91 |
+
.y(d => yScale(+d[yField]))
|
| 92 |
+
.curve(d3.curveLinear); // 使用折线而不是曲线
|
| 93 |
+
|
| 94 |
+
// 按组分组数据
|
| 95 |
+
const groupedData = groups.map(group => {
|
| 96 |
+
return {
|
| 97 |
+
group: group,
|
| 98 |
+
values: chartData.filter(d => d[groupField] === group),
|
| 99 |
+
color: getColor(group) // 获取该组的颜色
|
| 100 |
+
};
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
// 绘制线条
|
| 104 |
+
groupedData.forEach(gd => {
|
| 105 |
+
// 添加阴影效果
|
| 106 |
+
// 首先创建一个滤镜
|
| 107 |
+
const filterId = `shadow-${gd.group.replace(/\s+/g, '-')}`;
|
| 108 |
+
const filter = svg.append("defs")
|
| 109 |
+
.append("filter")
|
| 110 |
+
.attr("id", filterId)
|
| 111 |
+
.attr("x", "-20%")
|
| 112 |
+
.attr("y", "-20%")
|
| 113 |
+
.attr("width", "140%")
|
| 114 |
+
.attr("height", "140%");
|
| 115 |
+
|
| 116 |
+
// 添加高斯模糊
|
| 117 |
+
filter.append("feGaussianBlur")
|
| 118 |
+
.attr("in", "SourceAlpha")
|
| 119 |
+
.attr("stdDeviation", 1)
|
| 120 |
+
.attr("result", "blur");
|
| 121 |
+
|
| 122 |
+
// 添加偏移
|
| 123 |
+
filter.append("feOffset")
|
| 124 |
+
.attr("in", "blur")
|
| 125 |
+
.attr("dx", 0)
|
| 126 |
+
.attr("dy", 1)
|
| 127 |
+
.attr("result", "offsetBlur");
|
| 128 |
+
|
| 129 |
+
// 添加颜色 - 改为黑色
|
| 130 |
+
filter.append("feFlood")
|
| 131 |
+
.attr("flood-color", "black") // 使用黑色而不是线条颜色
|
| 132 |
+
.attr("flood-opacity", 0.3)
|
| 133 |
+
.attr("result", "coloredBlur");
|
| 134 |
+
|
| 135 |
+
// 将颜色应用到偏移的模糊上
|
| 136 |
+
filter.append("feComposite")
|
| 137 |
+
.attr("in", "coloredBlur")
|
| 138 |
+
.attr("in2", "offsetBlur")
|
| 139 |
+
.attr("operator", "in")
|
| 140 |
+
.attr("result", "coloredBlurOffset");
|
| 141 |
+
|
| 142 |
+
// 合并原始图形和阴影
|
| 143 |
+
const feMerge = filter.append("feMerge");
|
| 144 |
+
feMerge.append("feMergeNode")
|
| 145 |
+
.attr("in", "coloredBlurOffset");
|
| 146 |
+
feMerge.append("feMergeNode")
|
| 147 |
+
.attr("in", "SourceGraphic");
|
| 148 |
+
|
| 149 |
+
// 绘制线条 - 添加阴影
|
| 150 |
+
g.append("path")
|
| 151 |
+
.datum(gd.values)
|
| 152 |
+
.attr("fill", "none")
|
| 153 |
+
.attr("stroke", gd.color)
|
| 154 |
+
.attr("stroke-width", 3)
|
| 155 |
+
.attr("d", line)
|
| 156 |
+
.attr("filter", `url(#${filterId})`);
|
| 157 |
+
|
| 158 |
+
// 获取最后一个数据点
|
| 159 |
+
const lastPoint = gd.values[gd.values.length - 1];
|
| 160 |
+
const x = xScale(chartUtils.format.parseDate(lastPoint[xField]));
|
| 161 |
+
const y = yScale(+lastPoint[yField]);
|
| 162 |
+
|
| 163 |
+
const valueText = chartUtils.format.fixed(+lastPoint[yField]).text;
|
| 164 |
+
const valueTextWidth = chartUtils.text.measure(null, valueText, { fontSize: "12px" }).width;
|
| 165 |
+
|
| 166 |
+
// 添加圆角矩形作为结束标记
|
| 167 |
+
const rectWidth = valueTextWidth + 10;
|
| 168 |
+
const rectHeight = 12;
|
| 169 |
+
const rectX = x - 6;
|
| 170 |
+
const rectY = y - rectHeight / 2;
|
| 171 |
+
|
| 172 |
+
// 添加圆角矩形背景
|
| 173 |
+
g.append("rect")
|
| 174 |
+
.attr("x", rectX)
|
| 175 |
+
.attr("y", rectY)
|
| 176 |
+
.attr("width", rectWidth)
|
| 177 |
+
.attr("height", rectHeight)
|
| 178 |
+
.attr("rx", 6)
|
| 179 |
+
.attr("ry", 6)
|
| 180 |
+
.attr("fill", gd.color); // 使用获取的颜色
|
| 181 |
+
|
| 182 |
+
// 添加数值文本(在矩形内)
|
| 183 |
+
g.append("text")
|
| 184 |
+
.attr("x", rectX + rectWidth / 2)
|
| 185 |
+
.attr("y", rectY + rectHeight / 2 + 1)
|
| 186 |
+
.attr("text-anchor", "middle")
|
| 187 |
+
.attr("dominant-baseline", "middle")
|
| 188 |
+
.style("font-family", "Arial")
|
| 189 |
+
.style("font-size", "12px")
|
| 190 |
+
.style("font-weight", "bold")
|
| 191 |
+
.style("fill", "white")
|
| 192 |
+
.text(chartUtils.format.fixed(+lastPoint[yField]).text);
|
| 193 |
+
|
| 194 |
+
// 添加国家名称
|
| 195 |
+
g.append("text")
|
| 196 |
+
.attr("x", rectX + rectWidth + 10)
|
| 197 |
+
.attr("y", rectY + rectHeight / 2)
|
| 198 |
+
.attr("text-anchor", "start")
|
| 199 |
+
.attr("dominant-baseline", "middle")
|
| 200 |
+
.style("font-family", "Arial")
|
| 201 |
+
.style("font-size", "14px")
|
| 202 |
+
.style("font-weight", "bold")
|
| 203 |
+
.style("fill", gd.color)
|
| 204 |
+
.text(gd.group);
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
// 找到结束时最高的数据点
|
| 208 |
+
let highestEndGroup = null;
|
| 209 |
+
let highestEndValue = -Infinity;
|
| 210 |
+
let highestEndX = 0;
|
| 211 |
+
let highestEndY = 0;
|
| 212 |
+
let highestEndRect = null;
|
| 213 |
+
|
| 214 |
+
groupedData.forEach(gd => {
|
| 215 |
+
const lastPoint = gd.values[gd.values.length - 1];
|
| 216 |
+
const value = lastPoint[yField];
|
| 217 |
+
|
| 218 |
+
if (value > highestEndValue) {
|
| 219 |
+
highestEndValue = value;
|
| 220 |
+
highestEndGroup = gd.group;
|
| 221 |
+
highestEndX = xScale(chartUtils.format.parseDate(lastPoint[xField]));
|
| 222 |
+
highestEndY = yScale(value);
|
| 223 |
+
|
| 224 |
+
// 保存矩形位置信息,用于后续添加标签
|
| 225 |
+
highestEndRect = {
|
| 226 |
+
x: highestEndX - 6,
|
| 227 |
+
y: highestEndY - 6,
|
| 228 |
+
width: 40,
|
| 229 |
+
height: 12
|
| 230 |
+
};
|
| 231 |
+
}
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
// 如果找到了最高点,添加Y轴encoding名称和指向三角形
|
| 235 |
+
if (highestEndRect) {
|
| 236 |
+
// 获取Y轴字段的显示名称
|
| 237 |
+
const yFieldName = chartUtils.schema.channelByKey(jsonData, yField, { fallbackKey: yField }).displayName || yField;
|
| 238 |
+
|
| 239 |
+
// 判断最高点的趋势
|
| 240 |
+
const highestGroup = groupedData.find(gd => gd.group === highestEndGroup);
|
| 241 |
+
const lastIndex = highestGroup.values.length - 1;
|
| 242 |
+
const lastValue = highestGroup.values[lastIndex][yField];
|
| 243 |
+
const prevValue = highestGroup.values[lastIndex - 1][yField];
|
| 244 |
+
const isIncreasing = lastValue > prevValue;
|
| 245 |
+
|
| 246 |
+
// 根据趋势决定标签位置
|
| 247 |
+
const labelBaseY = highestEndRect.y - 20
|
| 248 |
+
|
| 249 |
+
// Y轴encoding名称的位置
|
| 250 |
+
const encodingLabelY = labelBaseY;
|
| 251 |
+
|
| 252 |
+
// 添加Y轴encoding名称
|
| 253 |
+
g.append("text")
|
| 254 |
+
.attr("x", highestEndRect.x + highestEndRect.width / 2)
|
| 255 |
+
.attr("y", encodingLabelY)
|
| 256 |
+
.attr("text-anchor", "middle")
|
| 257 |
+
.attr("dominant-baseline", "middle")
|
| 258 |
+
.style("font-family", "Arial")
|
| 259 |
+
.style("font-size", "12px")
|
| 260 |
+
.style("font-weight", "bold")
|
| 261 |
+
.style("fill", "white")
|
| 262 |
+
.text(yLabel);
|
| 263 |
+
|
| 264 |
+
// 添加指向三角形
|
| 265 |
+
const triangleSize = 6;
|
| 266 |
+
const triangleX = highestEndRect.x + highestEndRect.width / 2;
|
| 267 |
+
const triangleY = encodingLabelY + 15;
|
| 268 |
+
|
| 269 |
+
g.append("path")
|
| 270 |
+
.attr("d", `M${triangleX},${triangleY} L${triangleX + triangleSize},${triangleY - triangleSize} L${triangleX - triangleSize},${triangleY - triangleSize} Z`)
|
| 271 |
+
.attr("fill", "white");
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// 添加X轴 - 放在中间位置
|
| 275 |
+
const xAxisY = innerHeight / 2; // 将X轴放在中间
|
| 276 |
+
|
| 277 |
+
// 添加X轴线 - 使用点点虚线,延伸到整个SVG两侧
|
| 278 |
+
g.append("line")
|
| 279 |
+
.attr("x1", -margin.left) // 延伸到左侧边缘
|
| 280 |
+
.attr("y1", xAxisY)
|
| 281 |
+
.attr("x2", innerWidth + margin.right) // 延伸到右侧边缘
|
| 282 |
+
.attr("y2", xAxisY)
|
| 283 |
+
.attr("stroke", "#666")
|
| 284 |
+
.attr("stroke-width", 1)
|
| 285 |
+
.attr("opacity", 0.5)
|
| 286 |
+
.attr("stroke-dasharray", "1,1");
|
| 287 |
+
|
| 288 |
+
// 添加刻度和标签
|
| 289 |
+
xTicks.forEach(tick => {
|
| 290 |
+
const x = xScale(tick);
|
| 291 |
+
|
| 292 |
+
// 添加圆角矩形背景
|
| 293 |
+
g.append("rect")
|
| 294 |
+
.attr("x", x - 15)
|
| 295 |
+
.attr("y", xAxisY - 6)
|
| 296 |
+
.attr("width", 30)
|
| 297 |
+
.attr("height", 12)
|
| 298 |
+
.attr("rx", 6)
|
| 299 |
+
.attr("ry", 6)
|
| 300 |
+
.attr("fill", "#fde7e5");
|
| 301 |
+
|
| 302 |
+
// 添加年份文本
|
| 303 |
+
g.append("text")
|
| 304 |
+
.attr("x", x)
|
| 305 |
+
.attr("y", xAxisY + 4)
|
| 306 |
+
.attr("text-anchor", "middle")
|
| 307 |
+
.style("font-family", "Arial")
|
| 308 |
+
.style("font-size", "11px")
|
| 309 |
+
.style("font-weight", "bold")
|
| 310 |
+
.style("fill", "#333")
|
| 311 |
+
.text(xFormat(tick));
|
| 312 |
+
});
|
| 313 |
+
|
| 314 |
+
// 添加水平网格线 - 实线,延伸到整个SVG两侧
|
| 315 |
+
g.selectAll("grid-line")
|
| 316 |
+
.data(yScale.ticks(5))
|
| 317 |
+
.enter()
|
| 318 |
+
.append("line")
|
| 319 |
+
.attr("x1", -margin.left) // 延伸到左侧边缘
|
| 320 |
+
.attr("y1", d => yScale(d))
|
| 321 |
+
.attr("x2", innerWidth + margin.right) // 延伸到右侧边缘
|
| 322 |
+
.attr("y2", d => yScale(d))
|
| 323 |
+
.attr("stroke", "rgba(255, 255, 255, 0.2)")
|
| 324 |
+
.attr("stroke-width", 1)
|
| 325 |
+
.attr("stroke-dasharray", "none")
|
| 326 |
+
.attr("class", "background"); // 使用实线
|
| 327 |
+
|
| 328 |
+
// 添加Y轴刻度值 - 调整位置
|
| 329 |
+
g.selectAll(".y-tick")
|
| 330 |
+
.data(yScale.ticks(5))
|
| 331 |
+
.enter()
|
| 332 |
+
.append("text")
|
| 333 |
+
.attr("x", 10) // 正值,使文本位于图表内部
|
| 334 |
+
.attr("y", d => yScale(d) + 15)
|
| 335 |
+
.attr("text-anchor", "start") // 改为左对齐
|
| 336 |
+
.attr("dominant-baseline", "middle")
|
| 337 |
+
.style("font-family", "Arial")
|
| 338 |
+
.style("font-size", "12px")
|
| 339 |
+
.style("fill", "white")
|
| 340 |
+
.text(d => d);
|
| 341 |
+
|
| 342 |
+
return svg.node();
|
| 343 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_09.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Line Graph",
|
| 5 |
+
"chart_name": "multiple_line_graph_09",
|
| 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/line_graph/multiple_line_graph_10.js
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Line Graph",
|
| 5 |
+
"chart_name": "multiple_line_graph_10",
|
| 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 |
+
const legendSize = chartUtils.legend.draw(g, groups, {
|
| 269 |
+
fontSize: 14,
|
| 270 |
+
fontWeight: "bold",
|
| 271 |
+
maxWidth: chartWidth,
|
| 272 |
+
markerShape: "line",
|
| 273 |
+
markerSize: 10,
|
| 274 |
+
strokeWidth: 4,
|
| 275 |
+
colorScale,
|
| 276 |
+
});
|
| 277 |
+
|
| 278 |
+
const maxYTickPosition = yScale(yTicks[yTicks.length - 1]);
|
| 279 |
+
|
| 280 |
+
legendSize.group.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
|
| 281 |
+
|
| 282 |
+
return svg.node();
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
// 动态规划标签放置算法 - 添加g参数
|
| 286 |
+
function placeLabelsDP(points, avoidYPositions = [], debugName = "", chartHeight, chartWidth, g) {
|
| 287 |
+
// 每个格点的高度(像素)
|
| 288 |
+
const GRID_SIZE = 3;
|
| 289 |
+
// 圆点周围的保护区域(格点数)
|
| 290 |
+
const PROTECTION_RADIUS = 3;
|
| 291 |
+
// 标签高度(格点数)
|
| 292 |
+
const LABEL_HEIGHT = 10;
|
| 293 |
+
|
| 294 |
+
// 离散化Y坐标,创建格点系统
|
| 295 |
+
const minY = 0;
|
| 296 |
+
const maxY = chartHeight; // 使用传入的chartHeight
|
| 297 |
+
const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
|
| 298 |
+
|
| 299 |
+
// 按照Y坐标排序点
|
| 300 |
+
points.sort((a, b) => a.y - b.y);
|
| 301 |
+
|
| 302 |
+
// 创建格点占用标记
|
| 303 |
+
const occupied = new Array(gridCount).fill(false);
|
| 304 |
+
// 存储占用原因,用于可视化
|
| 305 |
+
const occupiedReason = new Array(gridCount).fill("");
|
| 306 |
+
|
| 307 |
+
// 标记圆点周围的保护区域为已占用
|
| 308 |
+
points.forEach((point, idx) => {
|
| 309 |
+
const gridY = Math.floor(point.y / GRID_SIZE) - 3;
|
| 310 |
+
for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
|
| 311 |
+
occupied[i] = true;
|
| 312 |
+
occupiedReason[i] = `数据点${idx + 1}保护区`;
|
| 313 |
+
}
|
| 314 |
+
});
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
// 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
|
| 318 |
+
const n = points.length;
|
| 319 |
+
const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
|
| 320 |
+
const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
|
| 321 |
+
|
| 322 |
+
// 初始条件:第一个点的标签放置
|
| 323 |
+
const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
|
| 324 |
+
|
| 325 |
+
// 尝试将第一个点的标签放在可行的位置
|
| 326 |
+
for (let j = 0; j < gridCount; j++) {
|
| 327 |
+
// 检查是否可行(不在保护区域内且有足够空间放置标签)
|
| 328 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 329 |
+
// 检查标签是否会与其他标签重叠
|
| 330 |
+
let canPlace = true;
|
| 331 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 332 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 333 |
+
canPlace = false;
|
| 334 |
+
break;
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
if (canPlace) {
|
| 339 |
+
// 计算代价:与圆点位置的距离
|
| 340 |
+
const cost = Math.abs(j - firstPointGridY);
|
| 341 |
+
dp[0][j] = cost;
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
// 填充dp表
|
| 347 |
+
for (let i = 1; i < n; i++) {
|
| 348 |
+
const pointGridY = Math.floor(points[i].y / GRID_SIZE);
|
| 349 |
+
|
| 350 |
+
for (let j = 0; j < gridCount; j++) {
|
| 351 |
+
// 检查当前位置是否可行
|
| 352 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 353 |
+
// 检查标签是否会与其他标签重叠
|
| 354 |
+
let canPlace = true;
|
| 355 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 356 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 357 |
+
canPlace = false;
|
| 358 |
+
break;
|
| 359 |
+
}
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
if (canPlace) {
|
| 363 |
+
// 根据上一个点的标签位置计算当前最小代价
|
| 364 |
+
for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
|
| 365 |
+
if (dp[i-1][k] !== Infinity) {
|
| 366 |
+
|
| 367 |
+
// 当前标签的代价
|
| 368 |
+
const curCost = Math.abs(j - pointGridY);
|
| 369 |
+
const totalCost = dp[i-1][k] + curCost;
|
| 370 |
+
|
| 371 |
+
if (totalCost < dp[i][j]) {
|
| 372 |
+
dp[i][j] = totalCost;
|
| 373 |
+
prev[i][j] = k; // 记录前驱
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
// 找出最后一个点的最优放置位置
|
| 383 |
+
let minCost = Infinity;
|
| 384 |
+
let bestPos = -1;
|
| 385 |
+
|
| 386 |
+
for (let j = 0; j < gridCount; j++) {
|
| 387 |
+
if (dp[n-1][j] < minCost) {
|
| 388 |
+
minCost = dp[n-1][j];
|
| 389 |
+
bestPos = j;
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
// 存储最终选择的标签位置(格点索引)
|
| 394 |
+
const selectedGridPositions = [];
|
| 395 |
+
|
| 396 |
+
// 回溯构建结果
|
| 397 |
+
const labelPositions = [];
|
| 398 |
+
if (bestPos !== -1) {
|
| 399 |
+
// 从后向前回溯
|
| 400 |
+
let pos = bestPos;
|
| 401 |
+
|
| 402 |
+
for (let i = n - 1; i >= 0; i--) {
|
| 403 |
+
labelPositions.unshift({
|
| 404 |
+
point: points[i],
|
| 405 |
+
labelY: pos * GRID_SIZE
|
| 406 |
+
});
|
| 407 |
+
|
| 408 |
+
// 记录选择的位置
|
| 409 |
+
selectedGridPositions.unshift(pos);
|
| 410 |
+
|
| 411 |
+
pos = prev[i][pos];
|
| 412 |
+
}
|
| 413 |
+
} else {
|
| 414 |
+
// 无可行解,退化为简单方案处理
|
| 415 |
+
let lastY = 0;
|
| 416 |
+
for (let i = 0; i < n; i++) {
|
| 417 |
+
const point = points[i];
|
| 418 |
+
// 确保标签不超过下一个圆点的位置
|
| 419 |
+
let maxY = chartHeight;
|
| 420 |
+
if (i < n - 1) {
|
| 421 |
+
maxY = points[i+1].y;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
|
| 425 |
+
|
| 426 |
+
labelPositions.push({
|
| 427 |
+
point: point,
|
| 428 |
+
labelY: labelY
|
| 429 |
+
});
|
| 430 |
+
|
| 431 |
+
lastY = labelY;
|
| 432 |
+
}
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
const debug = false;
|
| 436 |
+
|
| 437 |
+
// 添加格子系统可视化
|
| 438 |
+
if (debug && points.length > 0) {
|
| 439 |
+
// 确定可视化的位置 - 使用数据点的X位置
|
| 440 |
+
const mainPointX = points.length > 0 ? points[0].x : 0; // 使用该组第一个点的X位置
|
| 441 |
+
const visX = mainPointX;
|
| 442 |
+
const visY = 10; // 从图表顶部开始
|
| 443 |
+
const visWidth = 50; // 减小宽度以免干扰图表
|
| 444 |
+
|
| 445 |
+
// 创建一个组用于添加可视化元素,并在需要时应用剪切
|
| 446 |
+
const visGroup = g.append("g");
|
| 447 |
+
|
| 448 |
+
// 限制可视化高度,避免太长
|
| 449 |
+
const maxVisHeight = chartHeight - 20;
|
| 450 |
+
const visibleGridCount = Math.min(gridCount, Math.floor(maxVisHeight / GRID_SIZE));
|
| 451 |
+
|
| 452 |
+
// 添加半透明背景
|
| 453 |
+
visGroup.append("rect")
|
| 454 |
+
.attr("x", visX - 5)
|
| 455 |
+
.attr("y", visY - 15)
|
| 456 |
+
.attr("width", visWidth + 10)
|
| 457 |
+
.attr("height", visibleGridCount * GRID_SIZE + 50)
|
| 458 |
+
.attr("fill", "#fff")
|
| 459 |
+
.attr("opacity", 0.8)
|
| 460 |
+
.attr("rx", 5);
|
| 461 |
+
|
| 462 |
+
// 添加标题
|
| 463 |
+
visGroup.append("text")
|
| 464 |
+
.attr("x", visX + visWidth/2)
|
| 465 |
+
.attr("y", visY - 5)
|
| 466 |
+
.attr("text-anchor", "middle")
|
| 467 |
+
.attr("fill", "#333")
|
| 468 |
+
.style("font-size", "10px")
|
| 469 |
+
.text(debugName + "标签位置");
|
| 470 |
+
|
| 471 |
+
// 绘制每个格子
|
| 472 |
+
for (let i = 0; i < visibleGridCount; i++) {
|
| 473 |
+
// 计算格子的颜色
|
| 474 |
+
let fillColor = "#ddd"; // 默认颜色 - 可用格子
|
| 475 |
+
|
| 476 |
+
if (occupied[i]) {
|
| 477 |
+
fillColor = "#ffcccc"; // 不可用格子 - 红色
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
// 标记最终选择的位置
|
| 481 |
+
const isSelected = selectedGridPositions.some(pos => {
|
| 482 |
+
// 检查格子是否在标签范围内
|
| 483 |
+
for (let j = 0; j < LABEL_HEIGHT; j++) {
|
| 484 |
+
if (pos + j === i) return true;
|
| 485 |
+
}
|
| 486 |
+
return false;
|
| 487 |
+
});
|
| 488 |
+
|
| 489 |
+
if (isSelected) {
|
| 490 |
+
fillColor = "#99ff99"; // 选中的格子 - 绿色
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
// 绘制格子
|
| 494 |
+
visGroup.append("rect")
|
| 495 |
+
.attr("x", visX)
|
| 496 |
+
.attr("y", visY + i * GRID_SIZE)
|
| 497 |
+
.attr("width", visWidth)
|
| 498 |
+
.attr("height", GRID_SIZE)
|
| 499 |
+
.attr("fill", fillColor)
|
| 500 |
+
.attr("stroke", "#ccc")
|
| 501 |
+
.attr("stroke-width", 0.2);
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
// 添加数据点位置标记 - 只显示该组的点
|
| 505 |
+
points.forEach((point, idx) => {
|
| 506 |
+
const gridY = Math.floor(point.y / GRID_SIZE);
|
| 507 |
+
|
| 508 |
+
// 仅当点位置在可视范围内时添加
|
| 509 |
+
if (gridY < visibleGridCount) {
|
| 510 |
+
// 绘制数据点位置线
|
| 511 |
+
visGroup.append("line")
|
| 512 |
+
.attr("x1", visX - 3)
|
| 513 |
+
.attr("y1", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 514 |
+
.attr("x2", visX + visWidth)
|
| 515 |
+
.attr("y2", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 516 |
+
.attr("stroke", point.color)
|
| 517 |
+
.attr("stroke-width", 1)
|
| 518 |
+
.attr("stroke-dasharray", "2,1");
|
| 519 |
+
|
| 520 |
+
// 添加简短的数据点标识
|
| 521 |
+
visGroup.append("text")
|
| 522 |
+
.attr("x", visX - 5)
|
| 523 |
+
.attr("y", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 524 |
+
.attr("text-anchor", "end")
|
| 525 |
+
.attr("dominant-baseline", "middle")
|
| 526 |
+
.attr("fill", point.color)
|
| 527 |
+
.style("font-size", "8px")
|
| 528 |
+
.text("点" + (idx + 1));
|
| 529 |
+
}
|
| 530 |
+
});
|
| 531 |
+
|
| 532 |
+
// 在底部添加简化的图例
|
| 533 |
+
const legendY = visY + visibleGridCount * GRID_SIZE + 5;
|
| 534 |
+
const legendItems = [
|
| 535 |
+
{ label: "可用", color: "#ddd" },
|
| 536 |
+
{ label: "占用", color: "#ffcccc" },
|
| 537 |
+
{ label: "选中", color: "#99ff99" }
|
| 538 |
+
];
|
| 539 |
+
|
| 540 |
+
legendItems.forEach((item, idx) => {
|
| 541 |
+
// 水平放置图例项
|
| 542 |
+
const itemX = visX + idx * (visWidth / 3);
|
| 543 |
+
|
| 544 |
+
visGroup.append("rect")
|
| 545 |
+
.attr("x", itemX)
|
| 546 |
+
.attr("y", legendY)
|
| 547 |
+
.attr("width", 6)
|
| 548 |
+
.attr("height", 6)
|
| 549 |
+
.attr("fill", item.color);
|
| 550 |
+
|
| 551 |
+
visGroup.append("text")
|
| 552 |
+
.attr("x", itemX + 8)
|
| 553 |
+
.attr("y", legendY + 3)
|
| 554 |
+
.attr("text-anchor", "start")
|
| 555 |
+
.attr("dominant-baseline", "middle")
|
| 556 |
+
.attr("fill", "#333")
|
| 557 |
+
.style("font-size", "6px")
|
| 558 |
+
.text(item.label);
|
| 559 |
+
});
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
return labelPositions;
|
| 563 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_11.js
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Line Graph",
|
| 5 |
+
"chart_name": "multiple_line_graph_11",
|
| 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.max(0, yMin - yPadding); // 确保下限不小于0
|
| 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 |
+
const legendSize = chartUtils.legend.draw(g, groups, {
|
| 269 |
+
fontSize: 14,
|
| 270 |
+
fontWeight: "bold",
|
| 271 |
+
maxWidth: chartWidth,
|
| 272 |
+
markerShape: "line",
|
| 273 |
+
markerSize: 10,
|
| 274 |
+
strokeWidth: 4,
|
| 275 |
+
colorScale,
|
| 276 |
+
});
|
| 277 |
+
|
| 278 |
+
const maxYTickPosition = yScale(yTicks[yTicks.length - 1]);
|
| 279 |
+
|
| 280 |
+
legendSize.group.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
|
| 281 |
+
|
| 282 |
+
return svg.node();
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
// 动态规划标签放置算法 - 添加g参数
|
| 286 |
+
function placeLabelsDP(points, avoidYPositions = [], debugName = "", chartHeight, chartWidth, g) {
|
| 287 |
+
// 每个格点的高度(像素)
|
| 288 |
+
const GRID_SIZE = 3;
|
| 289 |
+
// 圆点周围的保护区域(格点数)
|
| 290 |
+
const PROTECTION_RADIUS = 3;
|
| 291 |
+
// 标签高度(格点数)
|
| 292 |
+
const LABEL_HEIGHT = 10;
|
| 293 |
+
|
| 294 |
+
// 离散化Y坐标,创建格点系统
|
| 295 |
+
const minY = 0;
|
| 296 |
+
const maxY = chartHeight; // 使用传入的chartHeight
|
| 297 |
+
const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
|
| 298 |
+
|
| 299 |
+
// 按照Y坐标排序点
|
| 300 |
+
points.sort((a, b) => a.y - b.y);
|
| 301 |
+
|
| 302 |
+
// 创建格点占用标记
|
| 303 |
+
const occupied = new Array(gridCount).fill(false);
|
| 304 |
+
// 存储占用原因,用于可视化
|
| 305 |
+
const occupiedReason = new Array(gridCount).fill("");
|
| 306 |
+
|
| 307 |
+
// 标记圆点周围的保护区域为已占用
|
| 308 |
+
points.forEach((point, idx) => {
|
| 309 |
+
const gridY = Math.floor(point.y / GRID_SIZE) - 3;
|
| 310 |
+
for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
|
| 311 |
+
occupied[i] = true;
|
| 312 |
+
occupiedReason[i] = `数据点${idx + 1}保护区`;
|
| 313 |
+
}
|
| 314 |
+
});
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
// 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
|
| 318 |
+
const n = points.length;
|
| 319 |
+
const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
|
| 320 |
+
const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
|
| 321 |
+
|
| 322 |
+
// 初始条件:第一个点的标签放置
|
| 323 |
+
const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
|
| 324 |
+
|
| 325 |
+
// 尝试将第一个点的标签放在可行的位置
|
| 326 |
+
for (let j = 0; j < gridCount; j++) {
|
| 327 |
+
// 检查是否可行(不在保护区域内且有足够空间放置标签)
|
| 328 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 329 |
+
// 检查标签是否会与其他标签重叠
|
| 330 |
+
let canPlace = true;
|
| 331 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 332 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 333 |
+
canPlace = false;
|
| 334 |
+
break;
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
if (canPlace) {
|
| 339 |
+
// 计算代价:与圆点位置的距离
|
| 340 |
+
const cost = Math.abs(j - firstPointGridY);
|
| 341 |
+
dp[0][j] = cost;
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
// 填充dp表
|
| 347 |
+
for (let i = 1; i < n; i++) {
|
| 348 |
+
const pointGridY = Math.floor(points[i].y / GRID_SIZE);
|
| 349 |
+
|
| 350 |
+
for (let j = 0; j < gridCount; j++) {
|
| 351 |
+
// 检查当前位置是否可行
|
| 352 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 353 |
+
// 检查标签是否会与其他标签重叠
|
| 354 |
+
let canPlace = true;
|
| 355 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 356 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 357 |
+
canPlace = false;
|
| 358 |
+
break;
|
| 359 |
+
}
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
if (canPlace) {
|
| 363 |
+
// 根据上一个点的标签位置计算当前最小代价
|
| 364 |
+
for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
|
| 365 |
+
if (dp[i-1][k] !== Infinity) {
|
| 366 |
+
|
| 367 |
+
// 当前标签的代价
|
| 368 |
+
const curCost = Math.abs(j - pointGridY);
|
| 369 |
+
const totalCost = dp[i-1][k] + curCost;
|
| 370 |
+
|
| 371 |
+
if (totalCost < dp[i][j]) {
|
| 372 |
+
dp[i][j] = totalCost;
|
| 373 |
+
prev[i][j] = k; // 记录前驱
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
// 找出最后一个点的最优放置位置
|
| 383 |
+
let minCost = Infinity;
|
| 384 |
+
let bestPos = -1;
|
| 385 |
+
|
| 386 |
+
for (let j = 0; j < gridCount; j++) {
|
| 387 |
+
if (dp[n-1][j] < minCost) {
|
| 388 |
+
minCost = dp[n-1][j];
|
| 389 |
+
bestPos = j;
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
// 存储最终选择的标签位置(格点索引)
|
| 394 |
+
const selectedGridPositions = [];
|
| 395 |
+
|
| 396 |
+
// 回溯构建结果
|
| 397 |
+
const labelPositions = [];
|
| 398 |
+
if (bestPos !== -1) {
|
| 399 |
+
// 从后向前回溯
|
| 400 |
+
let pos = bestPos;
|
| 401 |
+
|
| 402 |
+
for (let i = n - 1; i >= 0; i--) {
|
| 403 |
+
labelPositions.unshift({
|
| 404 |
+
point: points[i],
|
| 405 |
+
labelY: pos * GRID_SIZE
|
| 406 |
+
});
|
| 407 |
+
|
| 408 |
+
// 记录选择的位置
|
| 409 |
+
selectedGridPositions.unshift(pos);
|
| 410 |
+
|
| 411 |
+
pos = prev[i][pos];
|
| 412 |
+
}
|
| 413 |
+
} else {
|
| 414 |
+
// 无可行解,退化为简单方案处理
|
| 415 |
+
let lastY = 0;
|
| 416 |
+
for (let i = 0; i < n; i++) {
|
| 417 |
+
const point = points[i];
|
| 418 |
+
// 确保标签���超过下一个圆点的位置
|
| 419 |
+
let maxY = chartHeight;
|
| 420 |
+
if (i < n - 1) {
|
| 421 |
+
maxY = points[i+1].y;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
|
| 425 |
+
|
| 426 |
+
labelPositions.push({
|
| 427 |
+
point: point,
|
| 428 |
+
labelY: labelY
|
| 429 |
+
});
|
| 430 |
+
|
| 431 |
+
lastY = labelY;
|
| 432 |
+
}
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
const debug = false;
|
| 436 |
+
|
| 437 |
+
// 添加格子系统可视化
|
| 438 |
+
if (debug && points.length > 0) {
|
| 439 |
+
// 确定可视化的位置 - 使用数据点的X位置
|
| 440 |
+
const mainPointX = points.length > 0 ? points[0].x : 0; // 使用该组第一个点的X位置
|
| 441 |
+
const visX = mainPointX;
|
| 442 |
+
const visY = 10; // 从图表顶部开始
|
| 443 |
+
const visWidth = 50; // 减小宽度以免干扰图表
|
| 444 |
+
|
| 445 |
+
// 创建一个组用于添加可视化元素,并在需要时应用剪切
|
| 446 |
+
const visGroup = g.append("g");
|
| 447 |
+
|
| 448 |
+
// 限制可视化高度,避免太长
|
| 449 |
+
const maxVisHeight = chartHeight - 20;
|
| 450 |
+
const visibleGridCount = Math.min(gridCount, Math.floor(maxVisHeight / GRID_SIZE));
|
| 451 |
+
|
| 452 |
+
// 添加半透明背景
|
| 453 |
+
visGroup.append("rect")
|
| 454 |
+
.attr("x", visX - 5)
|
| 455 |
+
.attr("y", visY - 15)
|
| 456 |
+
.attr("width", visWidth + 10)
|
| 457 |
+
.attr("height", visibleGridCount * GRID_SIZE + 50)
|
| 458 |
+
.attr("fill", "#fff")
|
| 459 |
+
.attr("opacity", 0.8)
|
| 460 |
+
.attr("rx", 5);
|
| 461 |
+
|
| 462 |
+
// 添加标题
|
| 463 |
+
visGroup.append("text")
|
| 464 |
+
.attr("x", visX + visWidth/2)
|
| 465 |
+
.attr("y", visY - 5)
|
| 466 |
+
.attr("text-anchor", "middle")
|
| 467 |
+
.attr("fill", "#333")
|
| 468 |
+
.style("font-size", "10px")
|
| 469 |
+
.text(debugName + "标签位置");
|
| 470 |
+
|
| 471 |
+
// 绘制每个格子
|
| 472 |
+
for (let i = 0; i < visibleGridCount; i++) {
|
| 473 |
+
// 计算格子的颜色
|
| 474 |
+
let fillColor = "#ddd"; // 默认颜色 - 可用格子
|
| 475 |
+
|
| 476 |
+
if (occupied[i]) {
|
| 477 |
+
fillColor = "#ffcccc"; // 不可用格子 - 红色
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
// 标记最终选择的位置
|
| 481 |
+
const isSelected = selectedGridPositions.some(pos => {
|
| 482 |
+
// 检查格子是否在标签范围内
|
| 483 |
+
for (let j = 0; j < LABEL_HEIGHT; j++) {
|
| 484 |
+
if (pos + j === i) return true;
|
| 485 |
+
}
|
| 486 |
+
return false;
|
| 487 |
+
});
|
| 488 |
+
|
| 489 |
+
if (isSelected) {
|
| 490 |
+
fillColor = "#99ff99"; // 选中的格子 - 绿色
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
// 绘制格子
|
| 494 |
+
visGroup.append("rect")
|
| 495 |
+
.attr("x", visX)
|
| 496 |
+
.attr("y", visY + i * GRID_SIZE)
|
| 497 |
+
.attr("width", visWidth)
|
| 498 |
+
.attr("height", GRID_SIZE)
|
| 499 |
+
.attr("fill", fillColor)
|
| 500 |
+
.attr("stroke", "#ccc")
|
| 501 |
+
.attr("stroke-width", 0.2);
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
// 添加数据点位置标记 - 只显示该组的点
|
| 505 |
+
points.forEach((point, idx) => {
|
| 506 |
+
const gridY = Math.floor(point.y / GRID_SIZE);
|
| 507 |
+
|
| 508 |
+
// 仅当点位置在可视范围内时添加
|
| 509 |
+
if (gridY < visibleGridCount) {
|
| 510 |
+
// 绘制数据点位置线
|
| 511 |
+
visGroup.append("line")
|
| 512 |
+
.attr("x1", visX - 3)
|
| 513 |
+
.attr("y1", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 514 |
+
.attr("x2", visX + visWidth)
|
| 515 |
+
.attr("y2", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 516 |
+
.attr("stroke", point.color)
|
| 517 |
+
.attr("stroke-width", 1)
|
| 518 |
+
.attr("stroke-dasharray", "2,1");
|
| 519 |
+
|
| 520 |
+
// 添加简短的数据点标识
|
| 521 |
+
visGroup.append("text")
|
| 522 |
+
.attr("x", visX - 5)
|
| 523 |
+
.attr("y", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 524 |
+
.attr("text-anchor", "end")
|
| 525 |
+
.attr("dominant-baseline", "middle")
|
| 526 |
+
.attr("fill", point.color)
|
| 527 |
+
.style("font-size", "8px")
|
| 528 |
+
.text("点" + (idx + 1));
|
| 529 |
+
}
|
| 530 |
+
});
|
| 531 |
+
|
| 532 |
+
// 在底部添加简化的图例
|
| 533 |
+
const legendY = visY + visibleGridCount * GRID_SIZE + 5;
|
| 534 |
+
const legendItems = [
|
| 535 |
+
{ label: "可用", color: "#ddd" },
|
| 536 |
+
{ label: "占用", color: "#ffcccc" },
|
| 537 |
+
{ label: "选中", color: "#99ff99" }
|
| 538 |
+
];
|
| 539 |
+
|
| 540 |
+
legendItems.forEach((item, idx) => {
|
| 541 |
+
// 水平放置图例项
|
| 542 |
+
const itemX = visX + idx * (visWidth / 3);
|
| 543 |
+
|
| 544 |
+
visGroup.append("rect")
|
| 545 |
+
.attr("x", itemX)
|
| 546 |
+
.attr("y", legendY)
|
| 547 |
+
.attr("width", 6)
|
| 548 |
+
.attr("height", 6)
|
| 549 |
+
.attr("fill", item.color);
|
| 550 |
+
|
| 551 |
+
visGroup.append("text")
|
| 552 |
+
.attr("x", itemX + 8)
|
| 553 |
+
.attr("y", legendY + 3)
|
| 554 |
+
.attr("text-anchor", "start")
|
| 555 |
+
.attr("dominant-baseline", "middle")
|
| 556 |
+
.attr("fill", "#333")
|
| 557 |
+
.style("font-size", "6px")
|
| 558 |
+
.text(item.label);
|
| 559 |
+
});
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
return labelPositions;
|
| 563 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/multiple_line_graph_12.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Line Graph",
|
| 5 |
+
"chart_name": "multiple_line_graph_12",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 30], [0, "inf"], [2, 2]],
|
| 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 |
+
"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: 100, right: 30, bottom: 100, 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 |
+
// 按组分组数据
|
| 75 |
+
const groupedData = d3.group(chartData, d => d[groupField]);
|
| 76 |
+
|
| 77 |
+
// 计算每个组的平均值,找出最高和最低的组
|
| 78 |
+
const groupAverages = new Map();
|
| 79 |
+
|
| 80 |
+
groupedData.forEach((values, group) => {
|
| 81 |
+
const sum = values.reduce((acc, d) => acc + +d[yField], 0);
|
| 82 |
+
const avg = sum / values.length;
|
| 83 |
+
groupAverages.set(group, avg);
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
// 找出平均值最高和最低的组
|
| 87 |
+
let highestGroup = null;
|
| 88 |
+
let lowestGroup = null;
|
| 89 |
+
let highestAvg = -Infinity;
|
| 90 |
+
let lowestAvg = Infinity;
|
| 91 |
+
|
| 92 |
+
groupAverages.forEach((avg, group) => {
|
| 93 |
+
if (avg > highestAvg) {
|
| 94 |
+
highestAvg = avg;
|
| 95 |
+
highestGroup = group;
|
| 96 |
+
}
|
| 97 |
+
if (avg < lowestAvg) {
|
| 98 |
+
lowestAvg = avg;
|
| 99 |
+
lowestGroup = group;
|
| 100 |
+
}
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
// 只保留最高和最低的两个组
|
| 104 |
+
const selectedGroups = [highestGroup, lowestGroup];
|
| 105 |
+
|
| 106 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, chartWidth);
|
| 107 |
+
|
| 108 |
+
// 创建y轴比例尺 - 使用数据的实际范围
|
| 109 |
+
const yMin = d3.min(chartData, d => +d[yField]);
|
| 110 |
+
const yMax = d3.max(chartData, d => +d[yField]);
|
| 111 |
+
|
| 112 |
+
// 为了美观,稍微扩展Y轴范围
|
| 113 |
+
const yPadding = (yMax - yMin) * 0.3;
|
| 114 |
+
const yDomainMax = yMax + yPadding;
|
| 115 |
+
const yDomainMin = Math.max(0, yMin - yPadding); // 确保下限不小于0
|
| 116 |
+
|
| 117 |
+
const yScale = d3.scaleLinear()
|
| 118 |
+
.domain([yDomainMin, yDomainMax])
|
| 119 |
+
.range([chartHeight, 0]);
|
| 120 |
+
|
| 121 |
+
// 创建颜色比例尺
|
| 122 |
+
const colorScale = colorResolver.scale(groups, { palette: "category10" });
|
| 123 |
+
|
| 124 |
+
// 获取实际的Y轴刻度 - 减少刻度数量
|
| 125 |
+
const yTicks = yScale.ticks(5); // 保持5个刻度
|
| 126 |
+
const maxYTick = yTicks[yTicks.length - 1]; // 最大的Y轴刻度值
|
| 127 |
+
|
| 128 |
+
// 计算最大Y刻度的位置
|
| 129 |
+
const maxYTickPosition = yScale(maxYTick);
|
| 130 |
+
|
| 131 |
+
// 添加图例 - 整体居中,放在最大Y轴刻度上方
|
| 132 |
+
const legendY = maxYTickPosition - 60; // 最大Y轴刻度上方20像素
|
| 133 |
+
|
| 134 |
+
// 计算比值圆形的Y位置
|
| 135 |
+
const ratioCircleY = maxYTickPosition - 30;
|
| 136 |
+
|
| 137 |
+
// 添加条纹背景 - 使用更合适的时间间隔
|
| 138 |
+
|
| 139 |
+
// 为每个X轴刻度创建条纹背景,使条纹以刻度为中心
|
| 140 |
+
// 条纹背景要覆盖到圆形区域
|
| 141 |
+
for (let i = 0; i < xTicks.length - 1; i++) {
|
| 142 |
+
// 获取相邻两个刻度
|
| 143 |
+
const currentTick = xTicks[i];
|
| 144 |
+
const nextTick = xTicks[i + 1];
|
| 145 |
+
|
| 146 |
+
// 计算当前���度和下一个刻度的位置
|
| 147 |
+
const x1 = xScale(currentTick);
|
| 148 |
+
const x2 = xScale(nextTick);
|
| 149 |
+
|
| 150 |
+
// 每隔一个刻度添加浅色背景
|
| 151 |
+
if (i % 2 === 0) {
|
| 152 |
+
g.append("rect")
|
| 153 |
+
.attr("x", x1)
|
| 154 |
+
.attr("y", legendY + 10) // 从legend下方20像素开始
|
| 155 |
+
.attr("width", x2 - x1)
|
| 156 |
+
.attr("height", chartHeight + xAxisTextHeight - (legendY + 10)) // 高度需要减去legend的位置
|
| 157 |
+
.attr("fill", "#ececec")
|
| 158 |
+
.attr("class", "background")
|
| 159 |
+
.attr("opacity", 0.8);
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// 将条纹背景移到最底层
|
| 164 |
+
g.selectAll("rect").lower();
|
| 165 |
+
|
| 166 |
+
// 添加水平网格线
|
| 167 |
+
yTicks.forEach(tick => {
|
| 168 |
+
g.append("line")
|
| 169 |
+
.attr("x1", 0)
|
| 170 |
+
.attr("y1", yScale(tick))
|
| 171 |
+
.attr("x2", chartWidth)
|
| 172 |
+
.attr("y2", yScale(tick))
|
| 173 |
+
.attr("stroke", "#e0e0e0")
|
| 174 |
+
.attr("stroke-width", 1)
|
| 175 |
+
.attr("class", "background")
|
| 176 |
+
.attr("stroke-dasharray", "2,2");
|
| 177 |
+
});
|
| 178 |
+
|
| 179 |
+
// 定义线条粗细
|
| 180 |
+
const lineWidth = 4;
|
| 181 |
+
|
| 182 |
+
// 创建线条生成器
|
| 183 |
+
const line = d3.line()
|
| 184 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 185 |
+
.y(d => yScale(+d[yField]))
|
| 186 |
+
.curve(d3.curveLinear);
|
| 187 |
+
|
| 188 |
+
// 为每个X刻度创建插值函数,计算比值
|
| 189 |
+
const highValues = groupedData.get(highestGroup);
|
| 190 |
+
const lowValues = groupedData.get(lowestGroup);
|
| 191 |
+
|
| 192 |
+
// 确保数据按日期排序
|
| 193 |
+
highValues.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 194 |
+
lowValues.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 195 |
+
|
| 196 |
+
// 创建插值函数
|
| 197 |
+
const highInterpolator = d3.scaleTime()
|
| 198 |
+
.domain(highValues.map(d => chartUtils.format.parseDate(d[xField])))
|
| 199 |
+
.range(highValues.map(d => +d[yField]))
|
| 200 |
+
.clamp(true);
|
| 201 |
+
|
| 202 |
+
const lowInterpolator = d3.scaleTime()
|
| 203 |
+
.domain(lowValues.map(d => chartUtils.format.parseDate(d[xField])))
|
| 204 |
+
.range(lowValues.map(d => +d[yField]))
|
| 205 |
+
.clamp(true);
|
| 206 |
+
|
| 207 |
+
// 计算每个X刻度的比值(转为百分比)
|
| 208 |
+
const ratios = xTicks.map(tick => {
|
| 209 |
+
const highVal = highInterpolator(tick);
|
| 210 |
+
const lowVal = lowInterpolator(tick);
|
| 211 |
+
return {
|
| 212 |
+
date: tick,
|
| 213 |
+
ratio: (lowVal / highVal) * 100 // 转为百分比
|
| 214 |
+
};
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
// 找出最大和最小的百分比值
|
| 218 |
+
const minRatio = d3.min(ratios, d => d.ratio);
|
| 219 |
+
const maxRatio = d3.max(ratios, d => d.ratio);
|
| 220 |
+
|
| 221 |
+
// 创建圆形大小的比例尺
|
| 222 |
+
const radiusScale = d3.scaleLinear()
|
| 223 |
+
.domain([minRatio, maxRatio])
|
| 224 |
+
.range([12, 20]); // 最小半径10,最大半径20
|
| 225 |
+
|
| 226 |
+
// 获取两个组的颜色
|
| 227 |
+
const highColor = colorScale(highestGroup);
|
| 228 |
+
const lowColor = colorScale(lowestGroup);
|
| 229 |
+
|
| 230 |
+
// 判断哪个颜色更浅
|
| 231 |
+
const highColorRGB = d3.rgb(highColor);
|
| 232 |
+
const lowColorRGB = d3.rgb(lowColor);
|
| 233 |
+
|
| 234 |
+
// 计算颜色的亮度(简单方法:R+G+B的总和)
|
| 235 |
+
const highBrightness = highColorRGB.r + highColorRGB.g + highColorRGB.b;
|
| 236 |
+
const lowBrightness = lowColorRGB.r + lowColorRGB.g + lowColorRGB.b;
|
| 237 |
+
|
| 238 |
+
// 确定浅色和深色
|
| 239 |
+
let lightColor, darkColor;
|
| 240 |
+
if (highBrightness >= lowBrightness) {
|
| 241 |
+
lightColor = highColorRGB;
|
| 242 |
+
darkColor = lowColorRGB;
|
| 243 |
+
} else {
|
| 244 |
+
lightColor = lowColorRGB;
|
| 245 |
+
darkColor = highColorRGB;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// 计算圆的颜色:浅色变得更浅
|
| 249 |
+
const circleR = Math.min(255, lightColor.r + (255 - lightColor.r) * 0.7);
|
| 250 |
+
const circleG = Math.min(255, lightColor.g + (255 - lightColor.g) * 0.7);
|
| 251 |
+
const circleB = Math.min(255, lightColor.b + (255 - lightColor.b) * 0.7);
|
| 252 |
+
|
| 253 |
+
const circleColor = d3.rgb(circleR, circleG, circleB);
|
| 254 |
+
|
| 255 |
+
// 绘制只选中的两个组的线条
|
| 256 |
+
selectedGroups.forEach(group => {
|
| 257 |
+
const values = groupedData.get(group);
|
| 258 |
+
// 确保数据按日期排序
|
| 259 |
+
values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 260 |
+
|
| 261 |
+
const color = colorScale(group);
|
| 262 |
+
|
| 263 |
+
// 绘制线条
|
| 264 |
+
g.append("path")
|
| 265 |
+
.datum(values)
|
| 266 |
+
.attr("fill", "none")
|
| 267 |
+
.attr("stroke", color)
|
| 268 |
+
.attr("stroke-width", lineWidth)
|
| 269 |
+
.attr("d", line);
|
| 270 |
+
|
| 271 |
+
// 添加数据点 - 根据是否为起止点使用不同样式
|
| 272 |
+
values.forEach((d, i) => {
|
| 273 |
+
const isEndpoint = i === 0 || i === values.length - 1;
|
| 274 |
+
|
| 275 |
+
if (isEndpoint) {
|
| 276 |
+
// 起止点:白色填充,带有颜色描边
|
| 277 |
+
g.append("circle")
|
| 278 |
+
.attr("cx", xScale(chartUtils.format.parseDate(d[xField])))
|
| 279 |
+
.attr("cy", yScale(+d[yField]))
|
| 280 |
+
.attr("r", lineWidth * 1.2)
|
| 281 |
+
.attr("fill", "#fff")
|
| 282 |
+
.attr("stroke", color)
|
| 283 |
+
.attr("stroke-width", lineWidth);
|
| 284 |
+
} else {
|
| 285 |
+
// 中间点:实心颜色填充,无描边
|
| 286 |
+
g.append("circle")
|
| 287 |
+
.attr("cx", xScale(chartUtils.format.parseDate(d[xField])))
|
| 288 |
+
.attr("cy", yScale(+d[yField]))
|
| 289 |
+
.attr("r", lineWidth)
|
| 290 |
+
.attr("fill", color)
|
| 291 |
+
.attr("stroke", "none");
|
| 292 |
+
}
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
// 添加起点和终点标注
|
| 296 |
+
const firstPoint = values[0];
|
| 297 |
+
const lastPoint = values[values.length - 1];
|
| 298 |
+
|
| 299 |
+
// 添加起点标注
|
| 300 |
+
addDataLabel(firstPoint, true);
|
| 301 |
+
|
| 302 |
+
// 添加终点标注
|
| 303 |
+
addDataLabel(lastPoint, false);
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
// 添加X轴文本 - 放置在条纹背景的中间
|
| 307 |
+
for (let i = 0; i < xTicks.length - 1; i++) {
|
| 308 |
+
// 获取相邻两个刻度
|
| 309 |
+
const currentTick = xTicks[i];
|
| 310 |
+
const nextTick = xTicks[i + 1];
|
| 311 |
+
|
| 312 |
+
// 计算当前刻度和下一个刻度的位置
|
| 313 |
+
const x1 = xScale(currentTick);
|
| 314 |
+
const x2 = xScale(nextTick);
|
| 315 |
+
|
| 316 |
+
// 计算中点位置
|
| 317 |
+
const midX = (x1 + x2) / 2;
|
| 318 |
+
|
| 319 |
+
g.append("text")
|
| 320 |
+
.attr("x", midX)
|
| 321 |
+
.attr("y", chartHeight + 20)
|
| 322 |
+
.attr("text-anchor", "middle")
|
| 323 |
+
.attr("fill", "#666")
|
| 324 |
+
.style("font-size", "12px")
|
| 325 |
+
.text(xFormat(currentTick));
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
// 添加Y轴文本
|
| 330 |
+
yTicks.forEach(tick => {
|
| 331 |
+
g.append("text")
|
| 332 |
+
.attr("x", -10)
|
| 333 |
+
.attr("y", yScale(tick))
|
| 334 |
+
.attr("text-anchor", "end")
|
| 335 |
+
.attr("dominant-baseline", "middle")
|
| 336 |
+
.attr("fill", "#666")
|
| 337 |
+
.style("font-size", "12px")
|
| 338 |
+
.text(chartUtils.format.fixed(tick).text);
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
+
// 添加图例 - 整体居中,放在最大Y轴刻度上方
|
| 342 |
+
const legendGroup = g.append("g")
|
| 343 |
+
.attr("transform", `translate(0, -50)`);
|
| 344 |
+
|
| 345 |
+
const group_names = [lowestGroup, highestGroup, `${lowestGroup}/${highestGroup}`];
|
| 346 |
+
const colors_legend = {
|
| 347 |
+
field: {
|
| 348 |
+
[lowestGroup]: lowColor,
|
| 349 |
+
[highestGroup]: highColor,
|
| 350 |
+
[`${lowestGroup}/${highestGroup}`]: circleColor,
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
const legendSize = chartUtils.legend.draw(legendGroup, group_names, colors_legend, {
|
| 355 |
+
x: 0,
|
| 356 |
+
y: 0,
|
| 357 |
+
fontSize: 14,
|
| 358 |
+
fontWeight: "bold",
|
| 359 |
+
align: "left",
|
| 360 |
+
maxWidth: chartWidth,
|
| 361 |
+
shape: "circle",
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
// 居中legend
|
| 365 |
+
legendGroup.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${-50 - legendSize.height/2})`);
|
| 366 |
+
|
| 367 |
+
// 添加每个X刻度的比值,使用圆形背景
|
| 368 |
+
ratios.forEach((ratio, i) => {
|
| 369 |
+
if (i === 0) {
|
| 370 |
+
return;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
// 跳过无效比值
|
| 374 |
+
if (ratio.invalid) {
|
| 375 |
+
return;
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
const x = xScale(ratio.date) - (xScale(ratio.date) - xScale(ratios[i-1].date)) / 2;
|
| 379 |
+
const y = ratioCircleY;
|
| 380 |
+
const radius = radiusScale(ratio.ratio);
|
| 381 |
+
|
| 382 |
+
// 添加圆形背景
|
| 383 |
+
g.append("circle")
|
| 384 |
+
.attr("cx", x)
|
| 385 |
+
.attr("cy", y)
|
| 386 |
+
.attr("r", radius)
|
| 387 |
+
.attr("fill", circleColor.toString());
|
| 388 |
+
|
| 389 |
+
// 添加百分比文本
|
| 390 |
+
g.append("text")
|
| 391 |
+
.attr("x", x)
|
| 392 |
+
.attr("y", y)
|
| 393 |
+
.attr("text-anchor", "middle")
|
| 394 |
+
.attr("dominant-baseline", "middle")
|
| 395 |
+
.attr("fill", "#333")
|
| 396 |
+
.style("font-size", "12px")
|
| 397 |
+
.text(chartUtils.format.percent(ratio.ratio, { digits: 0 }).text);
|
| 398 |
+
});
|
| 399 |
+
|
| 400 |
+
// 添加数据标注函数 - 根据数据点的实际值决定标签位置
|
| 401 |
+
function addDataLabel(point, isStart) {
|
| 402 |
+
const x = xScale(chartUtils.format.parseDate(point[xField]));
|
| 403 |
+
const y = yScale(+point[yField]);
|
| 404 |
+
|
| 405 |
+
// 获取点所属的组
|
| 406 |
+
const group = point[groupField];
|
| 407 |
+
const color = colorScale(group);
|
| 408 |
+
|
| 409 |
+
// 计算标签文本
|
| 410 |
+
const labelText = chartUtils.format.fixed(+point[yField], 0).text;
|
| 411 |
+
|
| 412 |
+
// 计算标签宽度和高度
|
| 413 |
+
const labelWidth = chartUtils.text.measure(null, labelText, { fontSize: 12 }).width + 16;
|
| 414 |
+
const labelHeight = 24;
|
| 415 |
+
|
| 416 |
+
// 获取另一组在同一时间点的值
|
| 417 |
+
const otherGroup = group === highestGroup ? lowestGroup : highestGroup;
|
| 418 |
+
const otherGroupData = groupedData.get(otherGroup);
|
| 419 |
+
|
| 420 |
+
// 找到同一时间点的另一组数据
|
| 421 |
+
const otherPoint = otherGroupData.find(d => d[xField] === point[xField]);
|
| 422 |
+
|
| 423 |
+
// 判断当前点的值是否大于另一组同时间点的值
|
| 424 |
+
const isHigherValue = otherPoint ? +point[yField] > +otherPoint[yField] : true;
|
| 425 |
+
|
| 426 |
+
// 根据值的大小决定标签位置
|
| 427 |
+
const labelY = isHigherValue ? y - 30 : y + 30;
|
| 428 |
+
|
| 429 |
+
// 添加圆角矩形背景
|
| 430 |
+
g.append("rect")
|
| 431 |
+
.attr("x", x - labelWidth / 2)
|
| 432 |
+
.attr("y", labelY - labelHeight / 2)
|
| 433 |
+
.attr("width", labelWidth)
|
| 434 |
+
.attr("height", labelHeight)
|
| 435 |
+
.attr("rx", 4)
|
| 436 |
+
.attr("ry", 4)
|
| 437 |
+
.attr("fill", color);
|
| 438 |
+
|
| 439 |
+
// 添加三角形 - 方向根据标签位置决定
|
| 440 |
+
const triangleSize = 8;
|
| 441 |
+
if (isHigherValue) {
|
| 442 |
+
// 值较高:向下的三角形
|
| 443 |
+
g.append("path")
|
| 444 |
+
.attr("d", `M${x-triangleSize/2},${labelY+labelHeight/2} L${x+triangleSize/2},${labelY+labelHeight/2} L${x},${labelY+labelHeight/2+triangleSize} Z`)
|
| 445 |
+
.attr("fill", color);
|
| 446 |
+
} else {
|
| 447 |
+
// 值较低:向上的三角形
|
| 448 |
+
g.append("path")
|
| 449 |
+
.attr("d", `M${x-triangleSize/2},${labelY-labelHeight/2} L${x+triangleSize/2},${labelY-labelHeight/2} L${x},${labelY-labelHeight/2-triangleSize} Z`)
|
| 450 |
+
.attr("fill", color);
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
// 添加文本 - 白色粗体
|
| 454 |
+
g.append("text")
|
| 455 |
+
.attr("x", x)
|
| 456 |
+
.attr("y", labelY)
|
| 457 |
+
.attr("text-anchor", "middle")
|
| 458 |
+
.attr("dominant-baseline", "middle")
|
| 459 |
+
.attr("fill", "#fff") // 白色文本
|
| 460 |
+
.attr("font-weight", "bold") // 粗体
|
| 461 |
+
.style("font-size", "12px")
|
| 462 |
+
.text(labelText);
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
return svg.node();
|
| 466 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/multiple_spline_graph_01.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Spline Graph",
|
| 5 |
+
"chart_name": "multiple_spline_graph_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 30], ["-inf", "inf"], [2, 8]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 400,
|
| 15 |
+
"min_width": 600,
|
| 16 |
+
"background": "dark",
|
| 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_dark || {};
|
| 32 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 33 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 34 |
+
const images = jsonData.images || {};
|
| 35 |
+
// 清空容器
|
| 36 |
+
d3.select(containerSelector).html("");
|
| 37 |
+
|
| 38 |
+
// 设置尺寸和边距 - 右侧留出更多空间用于标签
|
| 39 |
+
const width = variables.width;
|
| 40 |
+
const height = variables.height;
|
| 41 |
+
const margin = { top: 40, right: 180, bottom: 60, left: 80 };
|
| 42 |
+
const innerWidth = width - margin.left - margin.right;
|
| 43 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 44 |
+
|
| 45 |
+
// 获取字段名
|
| 46 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 47 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 48 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 49 |
+
|
| 50 |
+
// 获取唯一的组值和X值
|
| 51 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 52 |
+
const xValues = [...new Set(chartData.map(d => d[xField]))]
|
| 53 |
+
.sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
|
| 54 |
+
|
| 55 |
+
// 创建SVG
|
| 56 |
+
const svg = d3.select(containerSelector)
|
| 57 |
+
.append("svg")
|
| 58 |
+
.attr("width", "100%")
|
| 59 |
+
.attr("height", height)
|
| 60 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 61 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 62 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 63 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 64 |
+
|
| 65 |
+
// 创建图表组
|
| 66 |
+
const g = svg.append("g")
|
| 67 |
+
.attr("transform", `translate(${margin.left},${margin.top})`);
|
| 68 |
+
|
| 69 |
+
// 添加渐变效果
|
| 70 |
+
const defs = svg.append("defs");
|
| 71 |
+
|
| 72 |
+
// 添加网格背景渐变
|
| 73 |
+
const gridGradientId = "grid-background-gradient";
|
| 74 |
+
const gridGradient = defs.append("linearGradient")
|
| 75 |
+
.attr("class", "background")
|
| 76 |
+
.attr("id", gridGradientId)
|
| 77 |
+
.attr("x1", "0%")
|
| 78 |
+
.attr("y1", "0%")
|
| 79 |
+
.attr("x2", "0%")
|
| 80 |
+
.attr("y2", "100%");
|
| 81 |
+
|
| 82 |
+
gridGradient.append("stop")
|
| 83 |
+
.attr("offset", "0%")
|
| 84 |
+
.attr("stop-color", "#f0dcc1")
|
| 85 |
+
.attr("stop-opacity", 0); // 顶部完全透明
|
| 86 |
+
|
| 87 |
+
gridGradient.append("stop")
|
| 88 |
+
.attr("offset", "100%")
|
| 89 |
+
.attr("stop-color", "#f0dcc1")
|
| 90 |
+
.attr("stop-opacity", 0.3); // 底部半透明
|
| 91 |
+
|
| 92 |
+
// 添加网格背景矩形
|
| 93 |
+
g.append("rect")
|
| 94 |
+
.attr("width", innerWidth)
|
| 95 |
+
.attr("height", innerHeight)
|
| 96 |
+
.attr("fill", `url(#${gridGradientId})`) // 使用渐变填充
|
| 97 |
+
.attr("class", "background")
|
| 98 |
+
.attr("rx", 0)
|
| 99 |
+
.attr("ry", 0);
|
| 100 |
+
|
| 101 |
+
// 创建比例尺 - 修改为时间比例尺
|
| 102 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
|
| 103 |
+
|
| 104 |
+
// 修改Y轴比例尺,支持负值
|
| 105 |
+
const yScale = d3.scaleLinear()
|
| 106 |
+
.domain([
|
| 107 |
+
Math.min(0, d3.min(chartData, d => +d[yField]) * 1.1), // 取最小值和0中的较小者
|
| 108 |
+
d3.max(chartData, d => +d[yField]) * 1.1
|
| 109 |
+
])
|
| 110 |
+
.range([innerHeight, 0]);
|
| 111 |
+
|
| 112 |
+
// 获取颜色
|
| 113 |
+
const getColor = (group) => {
|
| 114 |
+
return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
// 添加网格线 - 使用半透明白色
|
| 118 |
+
// 首先获取Y轴的最大刻度值
|
| 119 |
+
const yTicks = yScale.ticks(5);
|
| 120 |
+
const maxYTick = yTicks[yTicks.length - 1];
|
| 121 |
+
const maxYPos = yScale(maxYTick);
|
| 122 |
+
|
| 123 |
+
// 绘制水平网格线 - 向左延伸
|
| 124 |
+
const gridExtension = 5; // 网格线向左延伸的距离
|
| 125 |
+
g.selectAll("line.grid-line-y")
|
| 126 |
+
.data(yTicks)
|
| 127 |
+
.enter()
|
| 128 |
+
.append("line")
|
| 129 |
+
.attr("class", "grid-line-y")
|
| 130 |
+
.attr("class", "background")
|
| 131 |
+
.attr("x1", -gridExtension) // 向左延伸
|
| 132 |
+
.attr("y1", d => yScale(d))
|
| 133 |
+
.attr("x2", innerWidth)
|
| 134 |
+
.attr("y2", d => yScale(d))
|
| 135 |
+
.attr("stroke", "#f0dcc1")
|
| 136 |
+
.attr("stroke-width", 1)
|
| 137 |
+
.attr("opacity", 0.2); // 半透明
|
| 138 |
+
|
| 139 |
+
// 添加垂直网格线渐变
|
| 140 |
+
const verticalGridGradientId = "vertical-grid-gradient";
|
| 141 |
+
const verticalGridGradient = defs.append("linearGradient")
|
| 142 |
+
.attr("id", verticalGridGradientId)
|
| 143 |
+
.attr("x1", "0%")
|
| 144 |
+
.attr("y1", "0%")
|
| 145 |
+
.attr("x2", "0%")
|
| 146 |
+
.attr("y2", "100%");
|
| 147 |
+
|
| 148 |
+
verticalGridGradient.append("stop")
|
| 149 |
+
.attr("offset", "0%")
|
| 150 |
+
.attr("stop-color", "#f0dcc1")
|
| 151 |
+
.attr("stop-opacity", 0.1); // 顶部较透明
|
| 152 |
+
|
| 153 |
+
verticalGridGradient.append("stop")
|
| 154 |
+
.attr("offset", "100%")
|
| 155 |
+
.attr("stop-color", "#f0dcc1")
|
| 156 |
+
.attr("stop-opacity", 0.4); // 底部较不透明
|
| 157 |
+
|
| 158 |
+
// 绘制垂直网格线 - 延伸超过Y轴最大刻度和X轴,但不包括最后一个刻度
|
| 159 |
+
g.selectAll("line.grid-line-x")
|
| 160 |
+
.data(xTicks.filter((d, i) => i > 0 && i < xTicks.length - 1)) // 移除第一个和最后一个刻度
|
| 161 |
+
.enter()
|
| 162 |
+
.append("line")
|
| 163 |
+
.attr("class", "grid-line-x")
|
| 164 |
+
.attr("class", "background")
|
| 165 |
+
.attr("x1", d => xScale(d))
|
| 166 |
+
.attr("y1", 0) // 从顶部开始,超过最大Y刻度
|
| 167 |
+
.attr("x2", d => xScale(d))
|
| 168 |
+
.attr("y2", innerHeight + 10) // 延伸到X轴下方10像素
|
| 169 |
+
.attr("stroke", "#f0dcc1") // 使用固定颜色
|
| 170 |
+
.attr("stroke-width", 1)
|
| 171 |
+
.attr("opacity", 0.3); // 半透明
|
| 172 |
+
|
| 173 |
+
// 为每个组添加线条渐变
|
| 174 |
+
groups.forEach(group => {
|
| 175 |
+
const gradientId = `line-gradient-${group.replace(/\s+/g, '-').toLowerCase()}`;
|
| 176 |
+
const baseColor = getColor(group);
|
| 177 |
+
|
| 178 |
+
const gradient = defs.append("linearGradient")
|
| 179 |
+
.attr("id", gradientId)
|
| 180 |
+
.attr("x1", "0%")
|
| 181 |
+
.attr("y1", "0%")
|
| 182 |
+
.attr("x2", "0%")
|
| 183 |
+
.attr("y2", "100%");
|
| 184 |
+
|
| 185 |
+
gradient.append("stop")
|
| 186 |
+
.attr("offset", "0%")
|
| 187 |
+
.attr("stop-color", baseColor)
|
| 188 |
+
.attr("stop-opacity", 0.3);
|
| 189 |
+
|
| 190 |
+
gradient.append("stop")
|
| 191 |
+
.attr("offset", "100%")
|
| 192 |
+
.attr("stop-color", baseColor)
|
| 193 |
+
.attr("stop-opacity", 0);
|
| 194 |
+
});
|
| 195 |
+
|
| 196 |
+
// 创建曲线生成器(而不是折线)
|
| 197 |
+
const line = d3.line()
|
| 198 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 199 |
+
.y(d => yScale(+d[yField]))
|
| 200 |
+
.curve(d3.curveMonotoneX); // 使用单调曲线插值
|
| 201 |
+
|
| 202 |
+
// 计算所有标签的固定宽度
|
| 203 |
+
const fixedLabelWidth = 30; // 固定标签宽度
|
| 204 |
+
|
| 205 |
+
// 在最后的竖线上为每个组添加彩色小圆点,并创建指向它们的标签
|
| 206 |
+
let labelPositions = [];
|
| 207 |
+
// 按照最终值,对group排序,从高到低
|
| 208 |
+
groups.sort((a, b) => {
|
| 209 |
+
const aData = chartData.filter(d => d[groupField] === a);
|
| 210 |
+
const bData = chartData.filter(d => d[groupField] === b);
|
| 211 |
+
return d3.descending(aData[aData.length - 1][yField], bData[bData.length - 1][yField]);
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
groups.forEach((group, i) => {
|
| 215 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 216 |
+
const lastPoint = groupData[groupData.length - 1];
|
| 217 |
+
const circleY = yScale(+lastPoint[yField]); // 圆点的实际Y坐标
|
| 218 |
+
let labelY = circleY; // 标签的Y坐标,初始与圆点相同
|
| 219 |
+
|
| 220 |
+
// 检查是否与现有标签重叠
|
| 221 |
+
labelPositions.forEach(pos => {
|
| 222 |
+
if (Math.abs(labelY - pos) < 30) {
|
| 223 |
+
// 如果重叠,向下移动
|
| 224 |
+
labelY = pos + 30;
|
| 225 |
+
}
|
| 226 |
+
});
|
| 227 |
+
|
| 228 |
+
labelPositions.push(labelY);
|
| 229 |
+
|
| 230 |
+
// 创建标签组
|
| 231 |
+
const labelGroup = g.append("g")
|
| 232 |
+
.attr("transform", `translate(${innerWidth + 20}, ${labelY})`);
|
| 233 |
+
|
| 234 |
+
// 计算数值文本
|
| 235 |
+
const valueText = chartUtils.format.integer(+lastPoint[yField]).text;
|
| 236 |
+
|
| 237 |
+
const textWidth = chartUtils.text.measure(null, valueText, { fontSize: typography.label.font_size }).width + 10;
|
| 238 |
+
|
| 239 |
+
// 标签背景 - 固定宽度,无圆角
|
| 240 |
+
labelGroup.append("rect")
|
| 241 |
+
.attr("x", 0)
|
| 242 |
+
.attr("y", -10)
|
| 243 |
+
.attr("width", textWidth * 1.1) // 固定宽度
|
| 244 |
+
.attr("height", 20)
|
| 245 |
+
.attr("fill", getColor(group))
|
| 246 |
+
.attr("rx", 0) // 移除圆角
|
| 247 |
+
.attr("ry", 0); // 移除圆角
|
| 248 |
+
|
| 249 |
+
// 计算三角形的位置
|
| 250 |
+
// 计算圆点相对于标签的位置
|
| 251 |
+
const relativeCircleY = circleY - labelY; // 圆点相对于标签中心的Y偏移
|
| 252 |
+
|
| 253 |
+
// 创建一个自定义的三角形路径
|
| 254 |
+
// 三角形右边是垂直的,贴在标签上,左边的尖尖指向圆点
|
| 255 |
+
const trianglePath = `
|
| 256 |
+
M -12,${relativeCircleY}
|
| 257 |
+
L 0,-10
|
| 258 |
+
L 0,10
|
| 259 |
+
Z
|
| 260 |
+
`;
|
| 261 |
+
|
| 262 |
+
// 添加指向小圆点的三角形
|
| 263 |
+
labelGroup.append("path")
|
| 264 |
+
.attr("d", trianglePath)
|
| 265 |
+
.attr("fill", getColor(group));
|
| 266 |
+
|
| 267 |
+
// 标签文本 - 只显示数值
|
| 268 |
+
labelGroup.append("text")
|
| 269 |
+
.attr("x", textWidth/2) // 左对齐,留出一点间距
|
| 270 |
+
.attr("y", 2) // 垂直居中
|
| 271 |
+
.attr("text-anchor", "middle") //
|
| 272 |
+
.attr("dominant-baseline", "middle")
|
| 273 |
+
.style("font-family", typography.label.font_family)
|
| 274 |
+
.style("font-size", typography.label.font_size)
|
| 275 |
+
.style("fill", "#1c1c1c")
|
| 276 |
+
.text(valueText);
|
| 277 |
+
|
| 278 |
+
// 添加组名文本
|
| 279 |
+
labelGroup.append("text")
|
| 280 |
+
.attr("x", textWidth + 10) // 位于标签右侧,留出间距
|
| 281 |
+
.attr("y", 2) // 垂直居中
|
| 282 |
+
.attr("text-anchor", "start") // 左对齐
|
| 283 |
+
.attr("dominant-baseline", "middle")
|
| 284 |
+
.style("font-family", typography.label.font_family)
|
| 285 |
+
.style("font-size", typography.label.font_size)
|
| 286 |
+
.style("font-weight", "bold")
|
| 287 |
+
.style("fill", getColor(group)) // 使用与组相同的颜色
|
| 288 |
+
.text(group);
|
| 289 |
+
});
|
| 290 |
+
|
| 291 |
+
// 为每个组绘制线条和面积
|
| 292 |
+
groups.forEach(group => {
|
| 293 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 294 |
+
|
| 295 |
+
// 绘制线条
|
| 296 |
+
g.append("path")
|
| 297 |
+
.datum(groupData)
|
| 298 |
+
.attr("class", "line")
|
| 299 |
+
.attr("fill", "none")
|
| 300 |
+
.attr("stroke", getColor(group))
|
| 301 |
+
.attr("stroke-width", 2)
|
| 302 |
+
.attr("d", line);
|
| 303 |
+
});
|
| 304 |
+
|
| 305 |
+
// 直接使用xTicks绘制X轴文本
|
| 306 |
+
xTicks.forEach(tick => {
|
| 307 |
+
g.append("text")
|
| 308 |
+
.attr("x", xScale(tick))
|
| 309 |
+
.attr("y", innerHeight + 25) // 下移文本
|
| 310 |
+
.attr("text-anchor", "middle")
|
| 311 |
+
.style("font-family", typography.label.font_family)
|
| 312 |
+
.style("font-size", typography.label.font_size)
|
| 313 |
+
.style("fill", "#f0dcc1")
|
| 314 |
+
.text(xFormat(tick));
|
| 315 |
+
});
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
// 为每个刻度添加文本
|
| 319 |
+
yTicks.forEach(tick => {
|
| 320 |
+
g.append("text")
|
| 321 |
+
.attr("x", -gridExtension - 5)
|
| 322 |
+
.attr("y", yScale(tick))
|
| 323 |
+
.attr("text-anchor", "end")
|
| 324 |
+
.attr("dominant-baseline", "middle")
|
| 325 |
+
.style("font-family", typography.label.font_family)
|
| 326 |
+
.style("font-size", typography.label.font_size)
|
| 327 |
+
.style("fill", "#f0dcc1")
|
| 328 |
+
.text(tick);
|
| 329 |
+
});
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
// 添加Y轴编码标签
|
| 333 |
+
const labelGroup = g.append("g")
|
| 334 |
+
.attr("transform", `translate(${-margin.left + 35}, ${maxYPos - 40})`);
|
| 335 |
+
|
| 336 |
+
// 计算标签宽度(根据文字长度调整)
|
| 337 |
+
const labelText = yField;
|
| 338 |
+
const labelPadding = 20;
|
| 339 |
+
const textWidth = chartUtils.text.measure(null, labelText, { fontSize: typography.label.font_size }).width;
|
| 340 |
+
|
| 341 |
+
const labelWidth = textWidth + 2 * labelPadding;
|
| 342 |
+
const labelHeight = 20;
|
| 343 |
+
const triangleHeight = 6;
|
| 344 |
+
|
| 345 |
+
// 创建标签形状(矩形+三角形)
|
| 346 |
+
const labelPath = `
|
| 347 |
+
M 0,0
|
| 348 |
+
H ${labelWidth}
|
| 349 |
+
V ${labelHeight}
|
| 350 |
+
H ${labelWidth/2 + triangleHeight}
|
| 351 |
+
L ${labelWidth/2},${labelHeight + triangleHeight}
|
| 352 |
+
L ${labelWidth/2 - triangleHeight},${labelHeight}
|
| 353 |
+
H 0
|
| 354 |
+
Z
|
| 355 |
+
`;
|
| 356 |
+
|
| 357 |
+
// 绘制标签背景
|
| 358 |
+
labelGroup.append("path")
|
| 359 |
+
.attr("d", labelPath)
|
| 360 |
+
.attr("fill", "transparent")
|
| 361 |
+
.attr("stroke", "#f0dcc1")
|
| 362 |
+
.attr("stroke-width", 1)
|
| 363 |
+
.attr("stroke-opacity", 0.5);
|
| 364 |
+
|
| 365 |
+
// 添加文本
|
| 366 |
+
labelGroup.append("text")
|
| 367 |
+
.attr("x", labelWidth/2)
|
| 368 |
+
.attr("y", labelHeight/2 + 2)
|
| 369 |
+
.attr("text-anchor", "middle")
|
| 370 |
+
.attr("dominant-baseline", "middle")
|
| 371 |
+
.style("font-family", typography.label.font_family)
|
| 372 |
+
.style("font-size", typography.label.font_size)
|
| 373 |
+
.style("fill", "#f0dcc1")
|
| 374 |
+
.text(labelText);
|
| 375 |
+
|
| 376 |
+
return svg.node();
|
| 377 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/multiple_spline_graph_02.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Spline Graph",
|
| 5 |
+
"chart_name": "multiple_spline_graph_02",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 30], ["-inf", "inf"], [2, 8]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 400,
|
| 15 |
+
"min_width": 600,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "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 |
+
d3.select(containerSelector).html("");
|
| 37 |
+
|
| 38 |
+
// 设置尺寸和边距 - 右侧留出更多空间用于标签
|
| 39 |
+
const width = variables.width;
|
| 40 |
+
const height = variables.height;
|
| 41 |
+
const margin = { top: 40, right: 180, bottom: 60, left: 80 };
|
| 42 |
+
const innerWidth = width - margin.left - margin.right;
|
| 43 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 44 |
+
|
| 45 |
+
// 获取字段名
|
| 46 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 47 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 48 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 49 |
+
|
| 50 |
+
// 获取唯一的组值和X值
|
| 51 |
+
const groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 52 |
+
const xValues = [...new Set(chartData.map(d => d[xField]))]
|
| 53 |
+
.sort((a, b) => chartUtils.format.parseDate(a) - chartUtils.format.parseDate(b));
|
| 54 |
+
|
| 55 |
+
// 创建SVG
|
| 56 |
+
const svg = d3.select(containerSelector)
|
| 57 |
+
.append("svg")
|
| 58 |
+
.attr("width", "100%")
|
| 59 |
+
.attr("height", height)
|
| 60 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 61 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 62 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 63 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 64 |
+
|
| 65 |
+
// 创建图表组
|
| 66 |
+
const g = svg.append("g")
|
| 67 |
+
.attr("transform", `translate(${margin.left},${margin.top})`);
|
| 68 |
+
|
| 69 |
+
// 添加渐变效果
|
| 70 |
+
const defs = svg.append("defs");
|
| 71 |
+
|
| 72 |
+
// 添加网格背景渐变
|
| 73 |
+
const gridGradientId = "grid-background-gradient";
|
| 74 |
+
const gridGradient = defs.append("linearGradient")
|
| 75 |
+
.attr("class", "background")
|
| 76 |
+
.attr("id", gridGradientId)
|
| 77 |
+
.attr("x1", "0%")
|
| 78 |
+
.attr("y1", "0%")
|
| 79 |
+
.attr("x2", "0%")
|
| 80 |
+
.attr("y2", "100%");
|
| 81 |
+
|
| 82 |
+
gridGradient.append("stop")
|
| 83 |
+
.attr("offset", "0%")
|
| 84 |
+
.attr("stop-color", "#1e90ff") // 深科技蓝
|
| 85 |
+
.attr("stop-opacity", 0); // 顶部完全透明
|
| 86 |
+
|
| 87 |
+
gridGradient.append("stop")
|
| 88 |
+
.attr("offset", "100%")
|
| 89 |
+
.attr("stop-color", "#1e90ff") // 深科技蓝
|
| 90 |
+
.attr("stop-opacity", 0.15); // 底部半透明
|
| 91 |
+
|
| 92 |
+
// 添加网格背景矩形
|
| 93 |
+
g.append("rect")
|
| 94 |
+
.attr("width", innerWidth)
|
| 95 |
+
.attr("height", innerHeight)
|
| 96 |
+
.attr("fill", `url(#${gridGradientId})`) // 使用渐变填充
|
| 97 |
+
.attr("class", "background")
|
| 98 |
+
.attr("rx", 0)
|
| 99 |
+
.attr("ry", 0);
|
| 100 |
+
|
| 101 |
+
// 创建比例尺 - 修改为时间比例尺
|
| 102 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
|
| 103 |
+
|
| 104 |
+
// 修改Y轴比例尺,支持负值
|
| 105 |
+
const yScale = d3.scaleLinear()
|
| 106 |
+
.domain([
|
| 107 |
+
Math.min(0, d3.min(chartData, d => +d[yField]) * 1.1), // 取最小值和0中的较小者
|
| 108 |
+
d3.max(chartData, d => +d[yField]) * 1.1
|
| 109 |
+
])
|
| 110 |
+
.range([innerHeight, 0]);
|
| 111 |
+
|
| 112 |
+
// 获取颜色
|
| 113 |
+
const getColor = (group) => {
|
| 114 |
+
return colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
// 添加网格线 - 使用半透明白色
|
| 118 |
+
// 首先获取Y轴的最大刻度值
|
| 119 |
+
const yTicks = yScale.ticks(5);
|
| 120 |
+
const maxYTick = yTicks[yTicks.length - 1];
|
| 121 |
+
const maxYPos = yScale(maxYTick);
|
| 122 |
+
|
| 123 |
+
// 绘制水平网格线 - 向左延伸
|
| 124 |
+
const gridExtension = 5; // 网格线向左延伸的距离
|
| 125 |
+
g.selectAll("line.grid-line-y")
|
| 126 |
+
.data(yTicks)
|
| 127 |
+
.enter()
|
| 128 |
+
.append("line")
|
| 129 |
+
.attr("class", "grid-line-y")
|
| 130 |
+
.attr("class", "background")
|
| 131 |
+
.attr("x1", -gridExtension) // 向左延伸
|
| 132 |
+
.attr("y1", d => yScale(d))
|
| 133 |
+
.attr("x2", innerWidth)
|
| 134 |
+
.attr("y2", d => yScale(d))
|
| 135 |
+
.attr("stroke", "#1e90ff") // 深科技蓝
|
| 136 |
+
.attr("stroke-width", 1)
|
| 137 |
+
.attr("opacity", 0.15); // 半透明
|
| 138 |
+
|
| 139 |
+
// 添加垂直网格线渐变
|
| 140 |
+
const verticalGridGradientId = "vertical-grid-gradient";
|
| 141 |
+
const verticalGridGradient = defs.append("linearGradient")
|
| 142 |
+
.attr("id", verticalGridGradientId)
|
| 143 |
+
.attr("x1", "0%")
|
| 144 |
+
.attr("y1", "0%")
|
| 145 |
+
.attr("x2", "0%")
|
| 146 |
+
.attr("y2", "100%");
|
| 147 |
+
|
| 148 |
+
verticalGridGradient.append("stop")
|
| 149 |
+
.attr("offset", "0%")
|
| 150 |
+
.attr("stop-color", "#1e90ff") // 深科技蓝
|
| 151 |
+
.attr("stop-opacity", 0.1); // 顶部较透明
|
| 152 |
+
|
| 153 |
+
verticalGridGradient.append("stop")
|
| 154 |
+
.attr("offset", "100%")
|
| 155 |
+
.attr("stop-color", "#1e90ff") // 深科技蓝
|
| 156 |
+
.attr("stop-opacity", 0.2); // 底部较不透明
|
| 157 |
+
|
| 158 |
+
// 绘制垂直网格线 - 延伸超过Y轴最大刻度和X轴,但不包括最后一个刻度
|
| 159 |
+
g.selectAll("line.grid-line-x")
|
| 160 |
+
.data(xTicks.filter((d, i) => i > 0 && i < xTicks.length - 1)) // 移除第一个和最后一个刻度
|
| 161 |
+
.enter()
|
| 162 |
+
.append("line")
|
| 163 |
+
.attr("class", "grid-line-x")
|
| 164 |
+
.attr("class", "background")
|
| 165 |
+
.attr("x1", d => xScale(d))
|
| 166 |
+
.attr("y1", 0) // 从顶部开始,超过最大Y刻度
|
| 167 |
+
.attr("x2", d => xScale(d))
|
| 168 |
+
.attr("y2", innerHeight + 10) // 延伸到X轴下方10像素
|
| 169 |
+
.attr("stroke", "#1e90ff") // 深科技蓝
|
| 170 |
+
.attr("stroke-width", 1)
|
| 171 |
+
.attr("opacity", 0.15); // 半透明
|
| 172 |
+
|
| 173 |
+
// 为每个组添加线条渐变
|
| 174 |
+
groups.forEach(group => {
|
| 175 |
+
const gradientId = `line-gradient-${group.replace(/\s+/g, '-').toLowerCase()}`;
|
| 176 |
+
const baseColor = getColor(group);
|
| 177 |
+
|
| 178 |
+
const gradient = defs.append("linearGradient")
|
| 179 |
+
.attr("id", gradientId)
|
| 180 |
+
.attr("x1", "0%")
|
| 181 |
+
.attr("y1", "0%")
|
| 182 |
+
.attr("x2", "0%")
|
| 183 |
+
.attr("y2", "100%");
|
| 184 |
+
|
| 185 |
+
gradient.append("stop")
|
| 186 |
+
.attr("offset", "0%")
|
| 187 |
+
.attr("stop-color", baseColor)
|
| 188 |
+
.attr("stop-opacity", 0.3);
|
| 189 |
+
|
| 190 |
+
gradient.append("stop")
|
| 191 |
+
.attr("offset", "100%")
|
| 192 |
+
.attr("stop-color", baseColor)
|
| 193 |
+
.attr("stop-opacity", 0);
|
| 194 |
+
});
|
| 195 |
+
|
| 196 |
+
// 创建曲线生成器(而不是折线)
|
| 197 |
+
const line = d3.line()
|
| 198 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 199 |
+
.y(d => yScale(+d[yField]))
|
| 200 |
+
.curve(d3.curveMonotoneX); // 使用单调曲线插值
|
| 201 |
+
|
| 202 |
+
// 计算所有标签的固定宽度
|
| 203 |
+
const fixedLabelWidth = 30; // 固定标签宽度
|
| 204 |
+
|
| 205 |
+
// 在最后的竖线上为每个组添加彩色小圆点,并创建指向它们的标签
|
| 206 |
+
let labelPositions = [];
|
| 207 |
+
// 按照最终值,对group排序,从高到低
|
| 208 |
+
groups.sort((a, b) => {
|
| 209 |
+
const aData = chartData.filter(d => d[groupField] === a);
|
| 210 |
+
const bData = chartData.filter(d => d[groupField] === b);
|
| 211 |
+
return d3.descending(aData[aData.length - 1][yField], bData[bData.length - 1][yField]);
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
groups.forEach((group, i) => {
|
| 215 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 216 |
+
const lastPoint = groupData[groupData.length - 1];
|
| 217 |
+
const circleY = yScale(+lastPoint[yField]); // 圆点的实际Y坐标
|
| 218 |
+
let labelY = circleY; // 标签的Y坐标,初始与圆点相同
|
| 219 |
+
|
| 220 |
+
// 检查是否与现有标签重叠
|
| 221 |
+
labelPositions.forEach(pos => {
|
| 222 |
+
if (Math.abs(labelY - pos) < 30) {
|
| 223 |
+
// 如果重叠,向下移动
|
| 224 |
+
labelY = pos + 30;
|
| 225 |
+
}
|
| 226 |
+
});
|
| 227 |
+
|
| 228 |
+
labelPositions.push(labelY);
|
| 229 |
+
|
| 230 |
+
// 创建标签组
|
| 231 |
+
const labelGroup = g.append("g")
|
| 232 |
+
.attr("transform", `translate(${innerWidth + 20}, ${labelY})`);
|
| 233 |
+
|
| 234 |
+
// 计算数值文本
|
| 235 |
+
const valueText = chartUtils.format.integer(+lastPoint[yField]).text;
|
| 236 |
+
|
| 237 |
+
const textWidth = chartUtils.text.measure(null, valueText, { fontSize: typography.label.font_size }).width + 10;
|
| 238 |
+
|
| 239 |
+
// 标签背景 - 固定宽度,无圆角
|
| 240 |
+
labelGroup.append("rect")
|
| 241 |
+
.attr("x", 0)
|
| 242 |
+
.attr("y", -10)
|
| 243 |
+
.attr("width", textWidth * 1.1) // 固定宽度
|
| 244 |
+
.attr("height", 20)
|
| 245 |
+
.attr("fill", getColor(group))
|
| 246 |
+
.attr("rx", 0) // 移除圆角
|
| 247 |
+
.attr("ry", 0); // 移除圆角
|
| 248 |
+
|
| 249 |
+
// 计算三角形的位置
|
| 250 |
+
// 计算圆点相对于标签的位置
|
| 251 |
+
const relativeCircleY = circleY - labelY; // 圆点相对于标签中心的Y偏移
|
| 252 |
+
|
| 253 |
+
// 创建一个自定义的三角形路径
|
| 254 |
+
// 三角形右边是垂直的,贴在标签上,左边的尖尖指向圆点
|
| 255 |
+
const trianglePath = `
|
| 256 |
+
M -12,${relativeCircleY}
|
| 257 |
+
L 0,-10
|
| 258 |
+
L 0,10
|
| 259 |
+
Z
|
| 260 |
+
`;
|
| 261 |
+
|
| 262 |
+
// 添加指向小圆点的三角形
|
| 263 |
+
labelGroup.append("path")
|
| 264 |
+
.attr("d", trianglePath)
|
| 265 |
+
.attr("fill", getColor(group));
|
| 266 |
+
|
| 267 |
+
// 标签文本 - 只显示数值
|
| 268 |
+
labelGroup.append("text")
|
| 269 |
+
.attr("x", textWidth/2) // 左对齐,留出一点间距
|
| 270 |
+
.attr("y", 2) // 垂直居中
|
| 271 |
+
.attr("text-anchor", "middle") //
|
| 272 |
+
.attr("dominant-baseline", "middle")
|
| 273 |
+
.style("font-family", typography.label.font_family)
|
| 274 |
+
.style("font-size", typography.label.font_size)
|
| 275 |
+
.style("fill", "#ffffff") // 改为白色
|
| 276 |
+
.text(valueText);
|
| 277 |
+
|
| 278 |
+
// 添加组名文本
|
| 279 |
+
labelGroup.append("text")
|
| 280 |
+
.attr("x", textWidth + 10) // 位于标签右侧,留出间距
|
| 281 |
+
.attr("y", 2) // 垂直居中
|
| 282 |
+
.attr("text-anchor", "start") // 左对齐
|
| 283 |
+
.attr("dominant-baseline", "middle")
|
| 284 |
+
.style("font-family", typography.label.font_family)
|
| 285 |
+
.style("font-size", typography.label.font_size)
|
| 286 |
+
.style("font-weight", "bold")
|
| 287 |
+
.style("fill", getColor(group)) // 使用与组相同的颜色
|
| 288 |
+
.text(group);
|
| 289 |
+
});
|
| 290 |
+
|
| 291 |
+
// 为每个组绘制线条和面积
|
| 292 |
+
groups.forEach(group => {
|
| 293 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 294 |
+
|
| 295 |
+
// 绘制线条
|
| 296 |
+
g.append("path")
|
| 297 |
+
.datum(groupData)
|
| 298 |
+
.attr("class", "line")
|
| 299 |
+
.attr("fill", "none")
|
| 300 |
+
.attr("stroke", getColor(group))
|
| 301 |
+
.attr("stroke-width", 2)
|
| 302 |
+
.attr("d", line);
|
| 303 |
+
});
|
| 304 |
+
|
| 305 |
+
// 直接使用xTicks绘制X轴文本
|
| 306 |
+
xTicks.forEach(tick => {
|
| 307 |
+
g.append("text")
|
| 308 |
+
.attr("x", xScale(tick))
|
| 309 |
+
.attr("y", innerHeight + 25) // 下移文本
|
| 310 |
+
.attr("text-anchor", "middle")
|
| 311 |
+
.style("font-family", typography.label.font_family)
|
| 312 |
+
.style("font-size", typography.label.font_size)
|
| 313 |
+
.style("fill", "#333333")
|
| 314 |
+
.text(xFormat(tick));
|
| 315 |
+
});
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
// 为每个刻度添加文本
|
| 319 |
+
yTicks.forEach(tick => {
|
| 320 |
+
g.append("text")
|
| 321 |
+
.attr("x", -gridExtension - 5)
|
| 322 |
+
.attr("y", yScale(tick))
|
| 323 |
+
.attr("text-anchor", "end")
|
| 324 |
+
.attr("dominant-baseline", "middle")
|
| 325 |
+
.style("font-family", typography.label.font_family)
|
| 326 |
+
.style("font-size", typography.label.font_size)
|
| 327 |
+
.style("fill", "#333333")
|
| 328 |
+
.text(tick);
|
| 329 |
+
});
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
// 添加Y轴编码标签
|
| 333 |
+
const labelGroup = g.append("g")
|
| 334 |
+
.attr("transform", `translate(${-margin.left + 35}, ${maxYPos - 40})`);
|
| 335 |
+
|
| 336 |
+
// 计算标签宽度(根据文字长度调整)
|
| 337 |
+
const labelText = yField;
|
| 338 |
+
const labelPadding = 20;
|
| 339 |
+
const textWidth = chartUtils.text.measure(null, labelText, { fontSize: typography.label.font_size }).width;
|
| 340 |
+
|
| 341 |
+
const labelWidth = textWidth + 2 * labelPadding;
|
| 342 |
+
const labelHeight = 20;
|
| 343 |
+
const triangleHeight = 6;
|
| 344 |
+
|
| 345 |
+
// 创建标签形状(矩形+三角形)
|
| 346 |
+
const labelPath = `
|
| 347 |
+
M 0,0
|
| 348 |
+
H ${labelWidth}
|
| 349 |
+
V ${labelHeight}
|
| 350 |
+
H ${labelWidth/2 + triangleHeight}
|
| 351 |
+
L ${labelWidth/2},${labelHeight + triangleHeight}
|
| 352 |
+
L ${labelWidth/2 - triangleHeight},${labelHeight}
|
| 353 |
+
H 0
|
| 354 |
+
Z
|
| 355 |
+
`;
|
| 356 |
+
|
| 357 |
+
// 绘制标签背景
|
| 358 |
+
labelGroup.append("path")
|
| 359 |
+
.attr("d", labelPath)
|
| 360 |
+
.attr("fill", "transparent")
|
| 361 |
+
.attr("stroke", "#333333")
|
| 362 |
+
.attr("stroke-width", 1)
|
| 363 |
+
.attr("stroke-opacity", 0.5);
|
| 364 |
+
|
| 365 |
+
// 添加文本
|
| 366 |
+
labelGroup.append("text")
|
| 367 |
+
.attr("x", labelWidth/2)
|
| 368 |
+
.attr("y", labelHeight/2 + 2)
|
| 369 |
+
.attr("text-anchor", "middle")
|
| 370 |
+
.attr("dominant-baseline", "middle")
|
| 371 |
+
.style("font-family", typography.label.font_family)
|
| 372 |
+
.style("font-size", typography.label.font_size)
|
| 373 |
+
.style("fill", "#333333")
|
| 374 |
+
.text(labelText);
|
| 375 |
+
|
| 376 |
+
return svg.node();
|
| 377 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/multiple_step_line_graph_01.js
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Step Line Graph",
|
| 5 |
+
"chart_name": "multiple_step_line_graph_01",
|
| 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.autoText(tick)); // 四舍五入到整数,不带百分号
|
| 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.curveStepAfter);
|
| 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 |
+
const legendSize = chartUtils.legend.draw(g, groups, {
|
| 269 |
+
fontSize: 14,
|
| 270 |
+
fontWeight: "bold",
|
| 271 |
+
maxWidth: chartWidth,
|
| 272 |
+
markerShape: "line",
|
| 273 |
+
markerSize: 10,
|
| 274 |
+
strokeWidth: 4,
|
| 275 |
+
colorScale,
|
| 276 |
+
});
|
| 277 |
+
|
| 278 |
+
const maxYTickPosition = yScale(yTicks[yTicks.length - 1]);
|
| 279 |
+
|
| 280 |
+
legendSize.group.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
|
| 281 |
+
|
| 282 |
+
return svg.node();
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
// 动态规划标签放置算法 - 添加g参数
|
| 286 |
+
function placeLabelsDP(points, avoidYPositions = [], debugName = "", chartHeight, chartWidth, g) {
|
| 287 |
+
// 每个格点的高度(像素)
|
| 288 |
+
const GRID_SIZE = 3;
|
| 289 |
+
// 圆点周围的保护区域(格点数)
|
| 290 |
+
const PROTECTION_RADIUS = 3;
|
| 291 |
+
// 标签高度(格点数)
|
| 292 |
+
const LABEL_HEIGHT = 10;
|
| 293 |
+
|
| 294 |
+
// 离散化Y坐标,创建格点系统
|
| 295 |
+
const minY = 0;
|
| 296 |
+
const maxY = chartHeight; // 使用传入的chartHeight
|
| 297 |
+
const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
|
| 298 |
+
|
| 299 |
+
// 按照Y坐标排序点
|
| 300 |
+
points.sort((a, b) => a.y - b.y);
|
| 301 |
+
|
| 302 |
+
// 创建格点占用标记
|
| 303 |
+
const occupied = new Array(gridCount).fill(false);
|
| 304 |
+
// 存储占用原因,用于可视化
|
| 305 |
+
const occupiedReason = new Array(gridCount).fill("");
|
| 306 |
+
|
| 307 |
+
// 标记圆点周围的保护区域为已占用
|
| 308 |
+
points.forEach((point, idx) => {
|
| 309 |
+
const gridY = Math.floor(point.y / GRID_SIZE) - 3;
|
| 310 |
+
for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
|
| 311 |
+
occupied[i] = true;
|
| 312 |
+
occupiedReason[i] = `数据点${idx + 1}保护区`;
|
| 313 |
+
}
|
| 314 |
+
});
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
// 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
|
| 318 |
+
const n = points.length;
|
| 319 |
+
const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
|
| 320 |
+
const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
|
| 321 |
+
|
| 322 |
+
// 初始条件:第一个点的标签放置
|
| 323 |
+
const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
|
| 324 |
+
|
| 325 |
+
// 尝试将第一个点的标签放在可行的位置
|
| 326 |
+
for (let j = 0; j < gridCount; j++) {
|
| 327 |
+
// 检查是否可行(不在保护区域内且有足够空间放置标签)
|
| 328 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 329 |
+
// 检查标签是否会与其他标签重叠
|
| 330 |
+
let canPlace = true;
|
| 331 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 332 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 333 |
+
canPlace = false;
|
| 334 |
+
break;
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
if (canPlace) {
|
| 339 |
+
// 计算代价:与圆点位置的距离
|
| 340 |
+
const cost = Math.abs(j - firstPointGridY);
|
| 341 |
+
dp[0][j] = cost;
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
// 填充dp表
|
| 347 |
+
for (let i = 1; i < n; i++) {
|
| 348 |
+
const pointGridY = Math.floor(points[i].y / GRID_SIZE);
|
| 349 |
+
|
| 350 |
+
for (let j = 0; j < gridCount; j++) {
|
| 351 |
+
// 检查当前位置是否可行
|
| 352 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 353 |
+
// 检查标签是否会与其他标签重叠
|
| 354 |
+
let canPlace = true;
|
| 355 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 356 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 357 |
+
canPlace = false;
|
| 358 |
+
break;
|
| 359 |
+
}
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
if (canPlace) {
|
| 363 |
+
// 根据上一个点的标签位置计算当前最小代价
|
| 364 |
+
for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
|
| 365 |
+
if (dp[i-1][k] !== Infinity) {
|
| 366 |
+
|
| 367 |
+
// 当前标签的代价
|
| 368 |
+
const curCost = Math.abs(j - pointGridY);
|
| 369 |
+
const totalCost = dp[i-1][k] + curCost;
|
| 370 |
+
|
| 371 |
+
if (totalCost < dp[i][j]) {
|
| 372 |
+
dp[i][j] = totalCost;
|
| 373 |
+
prev[i][j] = k; // 记录前驱
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
// 找出最后一个点的最优放置位置
|
| 383 |
+
let minCost = Infinity;
|
| 384 |
+
let bestPos = -1;
|
| 385 |
+
|
| 386 |
+
for (let j = 0; j < gridCount; j++) {
|
| 387 |
+
if (dp[n-1][j] < minCost) {
|
| 388 |
+
minCost = dp[n-1][j];
|
| 389 |
+
bestPos = j;
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
// 存储最终选择的标签位置(格点索引)
|
| 394 |
+
const selectedGridPositions = [];
|
| 395 |
+
|
| 396 |
+
// 回溯构建结果
|
| 397 |
+
const labelPositions = [];
|
| 398 |
+
if (bestPos !== -1) {
|
| 399 |
+
// 从后向前回溯
|
| 400 |
+
let pos = bestPos;
|
| 401 |
+
|
| 402 |
+
for (let i = n - 1; i >= 0; i--) {
|
| 403 |
+
labelPositions.unshift({
|
| 404 |
+
point: points[i],
|
| 405 |
+
labelY: pos * GRID_SIZE
|
| 406 |
+
});
|
| 407 |
+
|
| 408 |
+
// 记录选择的位置
|
| 409 |
+
selectedGridPositions.unshift(pos);
|
| 410 |
+
|
| 411 |
+
pos = prev[i][pos];
|
| 412 |
+
}
|
| 413 |
+
} else {
|
| 414 |
+
// 无可行解,退化为简单方案处理
|
| 415 |
+
let lastY = 0;
|
| 416 |
+
for (let i = 0; i < n; i++) {
|
| 417 |
+
const point = points[i];
|
| 418 |
+
// 确保标签不超过下一个圆点的位置
|
| 419 |
+
let maxY = chartHeight;
|
| 420 |
+
if (i < n - 1) {
|
| 421 |
+
maxY = points[i+1].y;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
|
| 425 |
+
|
| 426 |
+
labelPositions.push({
|
| 427 |
+
point: point,
|
| 428 |
+
labelY: labelY
|
| 429 |
+
});
|
| 430 |
+
|
| 431 |
+
lastY = labelY;
|
| 432 |
+
}
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
const debug = false;
|
| 436 |
+
|
| 437 |
+
// 添加格子系统可视化
|
| 438 |
+
if (debug && points.length > 0) {
|
| 439 |
+
// 确定可视化的位置 - 使用数据点的X位置
|
| 440 |
+
const mainPointX = points.length > 0 ? points[0].x : 0; // 使用该组第一个点的X位置
|
| 441 |
+
const visX = mainPointX;
|
| 442 |
+
const visY = 10; // 从图表顶部开始
|
| 443 |
+
const visWidth = 50; // 减小宽度以免干扰图表
|
| 444 |
+
|
| 445 |
+
// 创建一个组用于添加可视化元素,并在需要时应用剪切
|
| 446 |
+
const visGroup = g.append("g");
|
| 447 |
+
|
| 448 |
+
// 限制可视化高度,避免太长
|
| 449 |
+
const maxVisHeight = chartHeight - 20;
|
| 450 |
+
const visibleGridCount = Math.min(gridCount, Math.floor(maxVisHeight / GRID_SIZE));
|
| 451 |
+
|
| 452 |
+
// 添加半透明背景
|
| 453 |
+
visGroup.append("rect")
|
| 454 |
+
.attr("x", visX - 5)
|
| 455 |
+
.attr("y", visY - 15)
|
| 456 |
+
.attr("width", visWidth + 10)
|
| 457 |
+
.attr("height", visibleGridCount * GRID_SIZE + 50)
|
| 458 |
+
.attr("fill", "#fff")
|
| 459 |
+
.attr("opacity", 0.8)
|
| 460 |
+
.attr("rx", 5);
|
| 461 |
+
|
| 462 |
+
// 添加标题
|
| 463 |
+
visGroup.append("text")
|
| 464 |
+
.attr("x", visX + visWidth/2)
|
| 465 |
+
.attr("y", visY - 5)
|
| 466 |
+
.attr("text-anchor", "middle")
|
| 467 |
+
.attr("fill", "#333")
|
| 468 |
+
.style("font-size", "10px")
|
| 469 |
+
.text(debugName + "标签位置");
|
| 470 |
+
|
| 471 |
+
// 绘制每个格子
|
| 472 |
+
for (let i = 0; i < visibleGridCount; i++) {
|
| 473 |
+
// 计算格子的颜色
|
| 474 |
+
let fillColor = "#ddd"; // 默认颜色 - 可用格子
|
| 475 |
+
|
| 476 |
+
if (occupied[i]) {
|
| 477 |
+
fillColor = "#ffcccc"; // 不可用格子 - 红色
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
// 标记最终选择的位置
|
| 481 |
+
const isSelected = selectedGridPositions.some(pos => {
|
| 482 |
+
// 检查格子是否在标签范围内
|
| 483 |
+
for (let j = 0; j < LABEL_HEIGHT; j++) {
|
| 484 |
+
if (pos + j === i) return true;
|
| 485 |
+
}
|
| 486 |
+
return false;
|
| 487 |
+
});
|
| 488 |
+
|
| 489 |
+
if (isSelected) {
|
| 490 |
+
fillColor = "#99ff99"; // 选中的格子 - 绿色
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
// 绘制格子
|
| 494 |
+
visGroup.append("rect")
|
| 495 |
+
.attr("x", visX)
|
| 496 |
+
.attr("y", visY + i * GRID_SIZE)
|
| 497 |
+
.attr("width", visWidth)
|
| 498 |
+
.attr("height", GRID_SIZE)
|
| 499 |
+
.attr("fill", fillColor)
|
| 500 |
+
.attr("stroke", "#ccc")
|
| 501 |
+
.attr("stroke-width", 0.2);
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
// 添加数据点位置标记 - 只显示该组的点
|
| 505 |
+
points.forEach((point, idx) => {
|
| 506 |
+
const gridY = Math.floor(point.y / GRID_SIZE);
|
| 507 |
+
|
| 508 |
+
// 仅当点位置在可视范围内时添加
|
| 509 |
+
if (gridY < visibleGridCount) {
|
| 510 |
+
// 绘制数据点位置线
|
| 511 |
+
visGroup.append("line")
|
| 512 |
+
.attr("x1", visX - 3)
|
| 513 |
+
.attr("y1", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 514 |
+
.attr("x2", visX + visWidth)
|
| 515 |
+
.attr("y2", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 516 |
+
.attr("stroke", point.color)
|
| 517 |
+
.attr("stroke-width", 1)
|
| 518 |
+
.attr("stroke-dasharray", "2,1");
|
| 519 |
+
|
| 520 |
+
// 添加简短的数据点标识
|
| 521 |
+
visGroup.append("text")
|
| 522 |
+
.attr("x", visX - 5)
|
| 523 |
+
.attr("y", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 524 |
+
.attr("text-anchor", "end")
|
| 525 |
+
.attr("dominant-baseline", "middle")
|
| 526 |
+
.attr("fill", point.color)
|
| 527 |
+
.style("font-size", "8px")
|
| 528 |
+
.text("点" + (idx + 1));
|
| 529 |
+
}
|
| 530 |
+
});
|
| 531 |
+
|
| 532 |
+
// 在底部添加简化的图例
|
| 533 |
+
const legendY = visY + visibleGridCount * GRID_SIZE + 5;
|
| 534 |
+
const legendItems = [
|
| 535 |
+
{ label: "可用", color: "#ddd" },
|
| 536 |
+
{ label: "占用", color: "#ffcccc" },
|
| 537 |
+
{ label: "选中", color: "#99ff99" }
|
| 538 |
+
];
|
| 539 |
+
|
| 540 |
+
legendItems.forEach((item, idx) => {
|
| 541 |
+
// 水平放置图例项
|
| 542 |
+
const itemX = visX + idx * (visWidth / 3);
|
| 543 |
+
|
| 544 |
+
visGroup.append("rect")
|
| 545 |
+
.attr("x", itemX)
|
| 546 |
+
.attr("y", legendY)
|
| 547 |
+
.attr("width", 6)
|
| 548 |
+
.attr("height", 6)
|
| 549 |
+
.attr("fill", item.color);
|
| 550 |
+
|
| 551 |
+
visGroup.append("text")
|
| 552 |
+
.attr("x", itemX + 8)
|
| 553 |
+
.attr("y", legendY + 3)
|
| 554 |
+
.attr("text-anchor", "start")
|
| 555 |
+
.attr("dominant-baseline", "middle")
|
| 556 |
+
.attr("fill", "#333")
|
| 557 |
+
.style("font-size", "6px")
|
| 558 |
+
.text(item.label);
|
| 559 |
+
});
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
return labelPositions;
|
| 563 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/new_line_chart_01.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Line Graph",
|
| 5 |
+
"chart_name": "vertical_bar_chart_new_01",
|
| 6 |
+
"required_fields": ["x", "y"],
|
| 7 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 8 |
+
"required_fields_range": [[4, 12], [0, "inf"]],
|
| 9 |
+
"required_fields_icons": ["x"],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": [],
|
| 12 |
+
"required_other_colors": ["primary"],
|
| 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 |
+
"chart_for": "trend"
|
| 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 |
+
|
| 44 |
+
// 获取y轴单位
|
| 45 |
+
const yUnit = chartUtils.schema.column(dataColumns, 1).unit;
|
| 46 |
+
|
| 47 |
+
// 设置尺寸和边距
|
| 48 |
+
const width = variables.width;
|
| 49 |
+
const height = variables.height;
|
| 50 |
+
const margin = { top: 20, right: 30, bottom: 80, left: 50 };
|
| 51 |
+
|
| 52 |
+
// 创建SVG
|
| 53 |
+
const svg = d3.select(containerSelector)
|
| 54 |
+
.append("svg")
|
| 55 |
+
.attr("width", "100%")
|
| 56 |
+
.attr("height", height)
|
| 57 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 58 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 59 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 60 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 61 |
+
|
| 62 |
+
// 创建图表区域
|
| 63 |
+
const chartWidth = width - margin.left - margin.right;
|
| 64 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 65 |
+
|
| 66 |
+
const g = svg.append("g")
|
| 67 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 68 |
+
|
| 69 |
+
// 创建x轴比例尺 - 使用band scale来显示柱状图
|
| 70 |
+
const categories = chartData.map(d => d[xField]);
|
| 71 |
+
const xScale = d3.scaleBand()
|
| 72 |
+
.domain(categories)
|
| 73 |
+
.range([0, chartWidth])
|
| 74 |
+
.padding(0.3);
|
| 75 |
+
|
| 76 |
+
// 获取y值范围
|
| 77 |
+
const yMax = d3.max(chartData, d => +d[yField]);
|
| 78 |
+
|
| 79 |
+
// 最小柱子高度
|
| 80 |
+
const minBarHeight = 50;
|
| 81 |
+
|
| 82 |
+
// 创建y轴比例尺 - 调整range使小值也有足够高度
|
| 83 |
+
const effectiveHeight = chartHeight - minBarHeight; // 减去最小柱高
|
| 84 |
+
const yScale = d3.scaleLinear()
|
| 85 |
+
.domain([0, yMax * 1.2]) // 为标签留出空间
|
| 86 |
+
.range([effectiveHeight, 0]); // 调整可用高度范围
|
| 87 |
+
|
| 88 |
+
// 获取柱状图颜色 - 使用colorResolver.other("primary").value
|
| 89 |
+
const barColor = colorResolver.other("primary").value;
|
| 90 |
+
|
| 91 |
+
// 添加y轴网格线
|
| 92 |
+
yScale.ticks(5).forEach(tick => {
|
| 93 |
+
g.append("line")
|
| 94 |
+
.attr("x1", 0)
|
| 95 |
+
.attr("y1", yScale(tick))
|
| 96 |
+
.attr("x2", chartWidth)
|
| 97 |
+
.attr("y2", yScale(tick))
|
| 98 |
+
.attr("stroke", "#e0e0e0")
|
| 99 |
+
.attr("stroke-width", 1)
|
| 100 |
+
.attr("stroke-dasharray", "2,2");
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
// 创建渐变定义
|
| 104 |
+
const defs = svg.append("defs");
|
| 105 |
+
|
| 106 |
+
// 为每个柱子创建唯一的渐变ID
|
| 107 |
+
chartData.forEach((d, i) => {
|
| 108 |
+
const gradientId = `barGradient-${i}`;
|
| 109 |
+
|
| 110 |
+
// 创建线性渐变
|
| 111 |
+
const gradient = defs.append("linearGradient")
|
| 112 |
+
.attr("id", gradientId)
|
| 113 |
+
.attr("x1", "0%")
|
| 114 |
+
.attr("y1", "0%") // 顶部
|
| 115 |
+
.attr("x2", "0%")
|
| 116 |
+
.attr("y2", "100%"); // 底部
|
| 117 |
+
|
| 118 |
+
// 添加渐变停止点
|
| 119 |
+
gradient.append("stop")
|
| 120 |
+
.attr("offset", "0%")
|
| 121 |
+
.attr("stop-color", barColor)
|
| 122 |
+
.attr("stop-opacity", 0.25); // 顶部透明度
|
| 123 |
+
|
| 124 |
+
gradient.append("stop")
|
| 125 |
+
.attr("offset", "100%")
|
| 126 |
+
.attr("stop-color", barColor)
|
| 127 |
+
.attr("stop-opacity", 1.0); // 底部透明度
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
// 添加柱状图
|
| 131 |
+
g.selectAll(".bar")
|
| 132 |
+
.data(chartData)
|
| 133 |
+
.enter()
|
| 134 |
+
.append("rect")
|
| 135 |
+
.attr("class", "bar")
|
| 136 |
+
.attr("x", d => xScale(d[xField]))
|
| 137 |
+
.attr("y", d => yScale(+d[yField]))
|
| 138 |
+
.attr("width", xScale.bandwidth())
|
| 139 |
+
.attr("height", d => {
|
| 140 |
+
// 确保柱子高度至少为minBarHeight
|
| 141 |
+
return chartHeight - yScale(+d[yField]);
|
| 142 |
+
})
|
| 143 |
+
.attr("fill", (d, i) => `url(#barGradient-${i})`) // 使用渐变填充
|
| 144 |
+
.attr("rx", 4) // 圆角
|
| 145 |
+
.attr("ry", 4);
|
| 146 |
+
|
| 147 |
+
// 创建线条生成器,连接各个数据点
|
| 148 |
+
const line = d3.line()
|
| 149 |
+
.x(d => xScale(d[xField]) + xScale.bandwidth()/2)
|
| 150 |
+
.y(d => yScale(+d[yField]))
|
| 151 |
+
.curve(d3.curveLinear);
|
| 152 |
+
|
| 153 |
+
// 添加连接线
|
| 154 |
+
g.append("path")
|
| 155 |
+
.datum(chartData)
|
| 156 |
+
.attr("fill", "none")
|
| 157 |
+
.attr("stroke", barColor)
|
| 158 |
+
.attr("stroke-width", 2)
|
| 159 |
+
.attr("d", line);
|
| 160 |
+
|
| 161 |
+
// 添加数据点圆圈和标签
|
| 162 |
+
const circleRadius = 8; // 更小的圆圈
|
| 163 |
+
|
| 164 |
+
// 添加数据点圆圈
|
| 165 |
+
g.selectAll(".data-point")
|
| 166 |
+
.data(chartData)
|
| 167 |
+
.enter()
|
| 168 |
+
.append("circle")
|
| 169 |
+
.attr("class", "data-point")
|
| 170 |
+
.attr("cx", d => xScale(d[xField]) + xScale.bandwidth()/2)
|
| 171 |
+
.attr("cy", d => yScale(+d[yField]))
|
| 172 |
+
.attr("r", circleRadius)
|
| 173 |
+
.attr("fill", "white")
|
| 174 |
+
.attr("stroke", barColor)
|
| 175 |
+
.attr("stroke-width", 2);
|
| 176 |
+
|
| 177 |
+
// 添加数据标签,使用正确的单位
|
| 178 |
+
g.selectAll(".value-text")
|
| 179 |
+
.data(chartData)
|
| 180 |
+
.enter()
|
| 181 |
+
.append("text")
|
| 182 |
+
.attr("x", d => xScale(d[xField]) + xScale.bandwidth()/2)
|
| 183 |
+
.attr("y", d => yScale(+d[yField]) - circleRadius - 5) // 放在圆圈上方
|
| 184 |
+
.attr("text-anchor", "middle")
|
| 185 |
+
.attr("dominant-baseline", "middle")
|
| 186 |
+
.attr("fill", "#333")
|
| 187 |
+
.style("font-size", "12px")
|
| 188 |
+
.style("font-weight", "bold")
|
| 189 |
+
.text(d => `${chartUtils.format.autoText(+d[yField])}${yUnit}`);
|
| 190 |
+
|
| 191 |
+
// 添加图标,使用images对象中的base64数据
|
| 192 |
+
// 放在柱状图底部,宽度与柱状图相同,减少空隙
|
| 193 |
+
g.selectAll(".icon")
|
| 194 |
+
.data(chartData)
|
| 195 |
+
.enter()
|
| 196 |
+
.append("image")
|
| 197 |
+
.attr("x", d => xScale(d[xField])) // 与柱状图左边缘对齐
|
| 198 |
+
.attr("y", chartHeight - xScale.bandwidth()) // 紧贴柱状图底部
|
| 199 |
+
.attr("width", xScale.bandwidth()) // 宽度与柱状图相同
|
| 200 |
+
.attr("height", xScale.bandwidth())
|
| 201 |
+
.attr("xlink:href", d => {
|
| 202 |
+
// 从images.field获取对应的图标base64数据
|
| 203 |
+
if (images && images.field && images.field[d[xField]]) {
|
| 204 |
+
return images.field[d[xField]];
|
| 205 |
+
}
|
| 206 |
+
// 如果没有找到对应的图标,返回null
|
| 207 |
+
return null;
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
// 计算x轴标签是否需要换行和合适的字体大小
|
| 211 |
+
const calculateLabelFormat = () => {
|
| 212 |
+
// 创建临时canvas用于测量文本宽度
|
| 213 |
+
const canvas = document.createElement('canvas');
|
| 214 |
+
const context = canvas.getContext('2d');
|
| 215 |
+
|
| 216 |
+
// 初始字体大小
|
| 217 |
+
let fontSize = 12;
|
| 218 |
+
const minFontSize = 8; // 最小字体大小
|
| 219 |
+
const padding = 5; // 标签之间的最小间距
|
| 220 |
+
let needsWordWrap = false;
|
| 221 |
+
|
| 222 |
+
// 测试字体大小是否会导致标签重叠
|
| 223 |
+
while (fontSize >= minFontSize) {
|
| 224 |
+
context.font = `${fontSize}px sans-serif`;
|
| 225 |
+
|
| 226 |
+
let isOverlapping = false;
|
| 227 |
+
|
| 228 |
+
// 计算每个标签的宽度
|
| 229 |
+
const labelWidths = categories.map(category => {
|
| 230 |
+
return chartUtils.text.contextWidth(context, category);
|
| 231 |
+
});
|
| 232 |
+
|
| 233 |
+
// 检查相邻标签是否重叠
|
| 234 |
+
for (let i = 0; i < categories.length - 1; i++) {
|
| 235 |
+
const leftLabelWidth = labelWidths[i] / 2;
|
| 236 |
+
const rightLabelWidth = labelWidths[i + 1] / 2;
|
| 237 |
+
|
| 238 |
+
const leftCenter = xScale(categories[i]) + xScale.bandwidth() / 2;
|
| 239 |
+
const rightCenter = xScale(categories[i + 1]) + xScale.bandwidth() / 2;
|
| 240 |
+
|
| 241 |
+
const distance = rightCenter - leftCenter;
|
| 242 |
+
|
| 243 |
+
if (leftLabelWidth + rightLabelWidth + padding > distance) {
|
| 244 |
+
isOverlapping = true;
|
| 245 |
+
break;
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// 如果不重叠,无需继续减小字体
|
| 250 |
+
if (!isOverlapping) {
|
| 251 |
+
break;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
// 减小字体大小
|
| 255 |
+
fontSize -= 1;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
// 检查是否需要换行(达到最小字体仍然重叠)
|
| 259 |
+
if (fontSize < minFontSize) {
|
| 260 |
+
fontSize = minFontSize;
|
| 261 |
+
needsWordWrap = true;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
return { fontSize, needsWordWrap };
|
| 265 |
+
};
|
| 266 |
+
|
| 267 |
+
// 将文本分成多行
|
| 268 |
+
const wrapText = (text, bandWidth) => {
|
| 269 |
+
const canvas = document.createElement('canvas');
|
| 270 |
+
const context = canvas.getContext('2d');
|
| 271 |
+
context.font = `${minFontSize}px sans-serif`;
|
| 272 |
+
|
| 273 |
+
const words = text.split(' ');
|
| 274 |
+
const lines = [];
|
| 275 |
+
let currentLine = words[0];
|
| 276 |
+
|
| 277 |
+
for (let i = 1; i < words.length; i++) {
|
| 278 |
+
const word = words[i];
|
| 279 |
+
const width = chartUtils.text.contextWidth(context, currentLine + ' ' + word);
|
| 280 |
+
|
| 281 |
+
if (width < bandWidth * 0.8) { // 保留一些边距
|
| 282 |
+
currentLine += ' ' + word;
|
| 283 |
+
} else {
|
| 284 |
+
lines.push(currentLine);
|
| 285 |
+
currentLine = word;
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
lines.push(currentLine);
|
| 289 |
+
|
| 290 |
+
return lines;
|
| 291 |
+
};
|
| 292 |
+
|
| 293 |
+
// 获取合适的标签格式
|
| 294 |
+
const { fontSize, needsWordWrap } = calculateLabelFormat();
|
| 295 |
+
const minFontSize = 8; // 定义为常量,供wrapText使用
|
| 296 |
+
|
| 297 |
+
// 添加x轴标签,调整位置使其靠近柱状图底部
|
| 298 |
+
const labelYPosition = chartHeight + 25; // 减少与柱状图底部的间距
|
| 299 |
+
|
| 300 |
+
if (!needsWordWrap) {
|
| 301 |
+
// 单行标签
|
| 302 |
+
g.selectAll(".x-label")
|
| 303 |
+
.data(chartData)
|
| 304 |
+
.enter()
|
| 305 |
+
.append("text")
|
| 306 |
+
.attr("x", d => xScale(d[xField]) + xScale.bandwidth()/2)
|
| 307 |
+
.attr("y", labelYPosition)
|
| 308 |
+
.attr("text-anchor", "middle")
|
| 309 |
+
.attr("fill", "#333")
|
| 310 |
+
.style("font-size", `${fontSize}px`)
|
| 311 |
+
.text(d => d[xField]);
|
| 312 |
+
} else {
|
| 313 |
+
// 多行标签
|
| 314 |
+
chartData.forEach((d, i) => {
|
| 315 |
+
const lines = wrapText(d[xField], xScale.bandwidth());
|
| 316 |
+
const x = xScale(d[xField]) + xScale.bandwidth()/2;
|
| 317 |
+
|
| 318 |
+
// 添加每一行文本
|
| 319 |
+
lines.forEach((line, lineIndex) => {
|
| 320 |
+
g.append("text")
|
| 321 |
+
.attr("x", x)
|
| 322 |
+
.attr("y", labelYPosition + lineIndex * (minFontSize + 2)) // 每行之间添加2px的间距
|
| 323 |
+
.attr("text-anchor", "middle")
|
| 324 |
+
.attr("fill", "#333")
|
| 325 |
+
.style("font-size", `${minFontSize}px`)
|
| 326 |
+
.text(line);
|
| 327 |
+
});
|
| 328 |
+
});
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
// y轴标签 - 仅当确实存在单位时才绘制 (避免显示 sentinel "none")
|
| 332 |
+
if (yUnit) {
|
| 333 |
+
g.append("text")
|
| 334 |
+
.attr("transform", "rotate(-90)")
|
| 335 |
+
.attr("x", -chartHeight / 2)
|
| 336 |
+
.attr("y", -30)
|
| 337 |
+
.attr("text-anchor", "middle")
|
| 338 |
+
.attr("fill", "#333")
|
| 339 |
+
.style("font-size", "14px")
|
| 340 |
+
.text(yUnit);
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
return svg.node();
|
| 344 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/new_line_chart_02.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Multiple Line Graph",
|
| 5 |
+
"chart_name": "vertical_bar_chart_new_02",
|
| 6 |
+
"required_fields": ["x", "y"],
|
| 7 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 8 |
+
"required_fields_range": [[4, 12], [0, "inf"]],
|
| 9 |
+
"required_fields_icons": ["x"],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": [],
|
| 12 |
+
"required_other_colors": ["primary"],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 400,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "dark",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes",
|
| 21 |
+
"chart_for": "trend"
|
| 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_dark || {};
|
| 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 |
+
|
| 44 |
+
// 获取y轴单位
|
| 45 |
+
const yUnit = chartUtils.schema.column(dataColumns, 1).unit;
|
| 46 |
+
|
| 47 |
+
// 设置尺寸和边距
|
| 48 |
+
const width = variables.width;
|
| 49 |
+
const height = variables.height;
|
| 50 |
+
const margin = { top: 20, right: 30, bottom: 80, left: 50 };
|
| 51 |
+
|
| 52 |
+
// 创建SVG
|
| 53 |
+
const svg = d3.select(containerSelector)
|
| 54 |
+
.append("svg")
|
| 55 |
+
.attr("width", "100%")
|
| 56 |
+
.attr("height", height)
|
| 57 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 58 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 59 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 60 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 61 |
+
|
| 62 |
+
// 创建图表区域
|
| 63 |
+
const chartWidth = width - margin.left - margin.right;
|
| 64 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 65 |
+
|
| 66 |
+
const g = svg.append("g")
|
| 67 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 68 |
+
|
| 69 |
+
// 创建x轴比例尺 - 使用band scale来显示柱状图
|
| 70 |
+
const categories = chartData.map(d => d[xField]);
|
| 71 |
+
const xScale = d3.scaleBand()
|
| 72 |
+
.domain(categories)
|
| 73 |
+
.range([0, chartWidth])
|
| 74 |
+
.padding(0.3);
|
| 75 |
+
|
| 76 |
+
// 获取y值范围
|
| 77 |
+
const yMax = d3.max(chartData, d => +d[yField]);
|
| 78 |
+
|
| 79 |
+
// 最小柱子高度
|
| 80 |
+
const minBarHeight = 50;
|
| 81 |
+
|
| 82 |
+
// 创建y轴比例尺 - 调整range使小值也有足够高度
|
| 83 |
+
const effectiveHeight = chartHeight - minBarHeight; // 减去最小柱高
|
| 84 |
+
const yScale = d3.scaleLinear()
|
| 85 |
+
.domain([0, yMax * 1.2]) // 为标签留出空间
|
| 86 |
+
.range([effectiveHeight, 0]); // 调整可用高度范围
|
| 87 |
+
|
| 88 |
+
// 获取柱状图颜色 - 使用colorResolver.other("primary").value
|
| 89 |
+
const barColor = colorResolver.other("primary").value;
|
| 90 |
+
|
| 91 |
+
// 创建渐变定义
|
| 92 |
+
const defs = svg.append("defs");
|
| 93 |
+
|
| 94 |
+
// 为每个柱子创建唯一的渐变ID
|
| 95 |
+
chartData.forEach((d, i) => {
|
| 96 |
+
const gradientId = `barGradient-${i}`;
|
| 97 |
+
|
| 98 |
+
// 创建线性渐变
|
| 99 |
+
const gradient = defs.append("linearGradient")
|
| 100 |
+
.attr("id", gradientId)
|
| 101 |
+
.attr("x1", "0%")
|
| 102 |
+
.attr("y1", "0%") // 顶部
|
| 103 |
+
.attr("x2", "0%")
|
| 104 |
+
.attr("y2", "100%"); // 底部
|
| 105 |
+
|
| 106 |
+
// 添加渐变停止点
|
| 107 |
+
gradient.append("stop")
|
| 108 |
+
.attr("offset", "0%")
|
| 109 |
+
.attr("stop-color", barColor)
|
| 110 |
+
.attr("stop-opacity", 0.25); // 顶部透明度
|
| 111 |
+
|
| 112 |
+
gradient.append("stop")
|
| 113 |
+
.attr("offset", "100%")
|
| 114 |
+
.attr("stop-color", "white")
|
| 115 |
+
.attr("stop-opacity", 1.0); // 底部透明度
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
// 添加柱状图
|
| 119 |
+
g.selectAll(".bar")
|
| 120 |
+
.data(chartData)
|
| 121 |
+
.enter()
|
| 122 |
+
.append("rect")
|
| 123 |
+
.attr("class", "bar")
|
| 124 |
+
.attr("x", d => xScale(d[xField]))
|
| 125 |
+
.attr("y", d => yScale(+d[yField]))
|
| 126 |
+
.attr("width", xScale.bandwidth())
|
| 127 |
+
.attr("height", d => {
|
| 128 |
+
// 确保柱子高度至少为minBarHeight
|
| 129 |
+
return chartHeight - yScale(+d[yField]);
|
| 130 |
+
})
|
| 131 |
+
.attr("fill", (d, i) => `url(#barGradient-${i})`) // 使用渐变填充
|
| 132 |
+
.attr("rx", 4) // 圆角
|
| 133 |
+
.attr("ry", 4);
|
| 134 |
+
|
| 135 |
+
// 创建线条生成器,连接各个数据点
|
| 136 |
+
const line = d3.line()
|
| 137 |
+
.x(d => xScale(d[xField]) + xScale.bandwidth()/2)
|
| 138 |
+
.y(d => yScale(+d[yField]))
|
| 139 |
+
.curve(d3.curveLinear);
|
| 140 |
+
|
| 141 |
+
// 添加连接线
|
| 142 |
+
g.append("path")
|
| 143 |
+
.datum(chartData)
|
| 144 |
+
.attr("fill", "none")
|
| 145 |
+
.attr("stroke", barColor)
|
| 146 |
+
.attr("stroke-width", 2)
|
| 147 |
+
.attr("d", line);
|
| 148 |
+
|
| 149 |
+
// 添加数据点圆圈和标签
|
| 150 |
+
const circleRadius = 8; // 更小的圆圈
|
| 151 |
+
|
| 152 |
+
// 添加数据点圆圈
|
| 153 |
+
g.selectAll(".data-point")
|
| 154 |
+
.data(chartData)
|
| 155 |
+
.enter()
|
| 156 |
+
.append("circle")
|
| 157 |
+
.attr("class", "data-point")
|
| 158 |
+
.attr("cx", d => xScale(d[xField]) + xScale.bandwidth()/2)
|
| 159 |
+
.attr("cy", d => yScale(+d[yField]))
|
| 160 |
+
.attr("r", circleRadius)
|
| 161 |
+
.attr("fill", "white")
|
| 162 |
+
.attr("stroke", barColor)
|
| 163 |
+
.attr("stroke-width", 2);
|
| 164 |
+
|
| 165 |
+
// 添加数据标签,使用正确的单位
|
| 166 |
+
g.selectAll(".value-text")
|
| 167 |
+
.data(chartData)
|
| 168 |
+
.enter()
|
| 169 |
+
.append("text")
|
| 170 |
+
.attr("x", d => xScale(d[xField]) + xScale.bandwidth()/2)
|
| 171 |
+
.attr("y", d => yScale(+d[yField]) - circleRadius - 5) // 放在圆圈上方
|
| 172 |
+
.attr("text-anchor", "middle")
|
| 173 |
+
.attr("dominant-baseline", "middle")
|
| 174 |
+
.attr("fill", "white")
|
| 175 |
+
.style("font-size", "12px")
|
| 176 |
+
.style("font-weight", "bold")
|
| 177 |
+
.text(d => `${chartUtils.format.autoText(+d[yField])}`);
|
| 178 |
+
|
| 179 |
+
// 添加图标,使用images对象中的base64数据
|
| 180 |
+
// 放在柱状图底部,宽度与柱状图相同,减少空隙
|
| 181 |
+
g.selectAll(".icon")
|
| 182 |
+
.data(chartData)
|
| 183 |
+
.enter()
|
| 184 |
+
.append("image")
|
| 185 |
+
.attr("x", d => xScale(d[xField])) // 与柱状图左边缘对齐
|
| 186 |
+
.attr("y", chartHeight - xScale.bandwidth()) // 紧贴柱状图底部
|
| 187 |
+
.attr("width", xScale.bandwidth()) // 宽度与柱状图相同
|
| 188 |
+
.attr("height", xScale.bandwidth())
|
| 189 |
+
.attr("xlink:href", d => {
|
| 190 |
+
// 从images.field获取对应的图标base64数据
|
| 191 |
+
if (images && images.field && images.field[d[xField]]) {
|
| 192 |
+
return images.field[d[xField]];
|
| 193 |
+
}
|
| 194 |
+
// 如果没有找到对应的图标,返回null
|
| 195 |
+
return null;
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
// 计算x轴标签是否需要换行和合适的字体大小
|
| 199 |
+
const calculateLabelFormat = () => {
|
| 200 |
+
// 创建临时canvas用于测量文本宽度
|
| 201 |
+
const canvas = document.createElement('canvas');
|
| 202 |
+
const context = canvas.getContext('2d');
|
| 203 |
+
|
| 204 |
+
// 初始字体大小
|
| 205 |
+
let fontSize = 12;
|
| 206 |
+
const minFontSize = 8; // 最小字体大小
|
| 207 |
+
const padding = 5; // 标签之间的最小间距
|
| 208 |
+
let needsWordWrap = false;
|
| 209 |
+
|
| 210 |
+
// 测试字体大小是否会导致标签重叠
|
| 211 |
+
while (fontSize >= minFontSize) {
|
| 212 |
+
context.font = `${fontSize}px sans-serif`;
|
| 213 |
+
|
| 214 |
+
let isOverlapping = false;
|
| 215 |
+
|
| 216 |
+
// 计算每个标签的宽度
|
| 217 |
+
const labelWidths = categories.map(category => {
|
| 218 |
+
return chartUtils.text.contextWidth(context, category);
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
// 检查相邻标签是否重叠
|
| 222 |
+
for (let i = 0; i < categories.length - 1; i++) {
|
| 223 |
+
const leftLabelWidth = labelWidths[i] / 2;
|
| 224 |
+
const rightLabelWidth = labelWidths[i + 1] / 2;
|
| 225 |
+
|
| 226 |
+
const leftCenter = xScale(categories[i]) + xScale.bandwidth() / 2;
|
| 227 |
+
const rightCenter = xScale(categories[i + 1]) + xScale.bandwidth() / 2;
|
| 228 |
+
|
| 229 |
+
const distance = rightCenter - leftCenter;
|
| 230 |
+
|
| 231 |
+
if (leftLabelWidth + rightLabelWidth + padding > distance) {
|
| 232 |
+
isOverlapping = true;
|
| 233 |
+
break;
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// 如果不重叠,无需继续减小字体
|
| 238 |
+
if (!isOverlapping) {
|
| 239 |
+
break;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
// 减小字体大小
|
| 243 |
+
fontSize -= 1;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
// 检查是否需要换行(达到最小字体仍然重叠)
|
| 247 |
+
if (fontSize < minFontSize) {
|
| 248 |
+
fontSize = minFontSize;
|
| 249 |
+
needsWordWrap = true;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
return { fontSize, needsWordWrap };
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
// 将文本分成多行
|
| 256 |
+
const wrapText = (text, bandWidth) => {
|
| 257 |
+
const canvas = document.createElement('canvas');
|
| 258 |
+
const context = canvas.getContext('2d');
|
| 259 |
+
context.font = `${minFontSize}px sans-serif`;
|
| 260 |
+
|
| 261 |
+
const words = text.split(' ');
|
| 262 |
+
const lines = [];
|
| 263 |
+
let currentLine = words[0];
|
| 264 |
+
|
| 265 |
+
for (let i = 1; i < words.length; i++) {
|
| 266 |
+
const word = words[i];
|
| 267 |
+
const width = chartUtils.text.contextWidth(context, currentLine + ' ' + word);
|
| 268 |
+
|
| 269 |
+
if (width < bandWidth * 0.8) { // 保留一些边距
|
| 270 |
+
currentLine += ' ' + word;
|
| 271 |
+
} else {
|
| 272 |
+
lines.push(currentLine);
|
| 273 |
+
currentLine = word;
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
lines.push(currentLine);
|
| 277 |
+
|
| 278 |
+
return lines;
|
| 279 |
+
};
|
| 280 |
+
|
| 281 |
+
// 获取合适的标签格式
|
| 282 |
+
const { fontSize, needsWordWrap } = calculateLabelFormat();
|
| 283 |
+
const minFontSize = 8; // 定义为常量,供wrapText使用
|
| 284 |
+
|
| 285 |
+
// 添加x轴标签,调整位置使其靠近柱��图底部
|
| 286 |
+
const labelYPosition = chartHeight + 25; // 减少与柱状图底部的间距
|
| 287 |
+
|
| 288 |
+
if (!needsWordWrap) {
|
| 289 |
+
// 单行标签
|
| 290 |
+
g.selectAll(".x-label")
|
| 291 |
+
.data(chartData)
|
| 292 |
+
.enter()
|
| 293 |
+
.append("text")
|
| 294 |
+
.attr("x", d => xScale(d[xField]) + xScale.bandwidth()/2)
|
| 295 |
+
.attr("y", labelYPosition)
|
| 296 |
+
.attr("text-anchor", "middle")
|
| 297 |
+
.attr("fill", "white")
|
| 298 |
+
.style("font-size", `${fontSize}px`)
|
| 299 |
+
.text(d => d[xField]);
|
| 300 |
+
} else {
|
| 301 |
+
// 多行标签
|
| 302 |
+
chartData.forEach((d, i) => {
|
| 303 |
+
const lines = wrapText(d[xField], xScale.bandwidth());
|
| 304 |
+
const x = xScale(d[xField]) + xScale.bandwidth()/2;
|
| 305 |
+
|
| 306 |
+
// 添加每一行文本
|
| 307 |
+
lines.forEach((line, lineIndex) => {
|
| 308 |
+
g.append("text")
|
| 309 |
+
.attr("x", x)
|
| 310 |
+
.attr("y", labelYPosition + lineIndex * (minFontSize + 2)) // 每行之间添加2px的间距
|
| 311 |
+
.attr("text-anchor", "middle")
|
| 312 |
+
.attr("fill", "white")
|
| 313 |
+
.style("font-size", `${minFontSize}px`)
|
| 314 |
+
.text(line);
|
| 315 |
+
});
|
| 316 |
+
});
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
// y轴标签 - 仅当确实存在单位时才绘制 (避免显示 sentinel "none")
|
| 320 |
+
if (yUnit) {
|
| 321 |
+
g.append("text")
|
| 322 |
+
.attr("transform", "rotate(-90)")
|
| 323 |
+
.attr("x", -chartHeight / 2)
|
| 324 |
+
.attr("y", -30)
|
| 325 |
+
.attr("text-anchor", "middle")
|
| 326 |
+
.attr("fill", "white")
|
| 327 |
+
.style("font-size", "14px")
|
| 328 |
+
.text(yUnit);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
return svg.node();
|
| 332 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/slope_chart_01.js
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Slope Chart",
|
| 5 |
+
"chart_name": "slope_chart_01",
|
| 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.max(0, yMin - yPadding); // 确保下限不小于0
|
| 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 endPoints = [];
|
| 169 |
+
|
| 170 |
+
groupedData.forEach((values, group) => {
|
| 171 |
+
// 确保数据按日期排序
|
| 172 |
+
values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 173 |
+
|
| 174 |
+
const color = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 175 |
+
|
| 176 |
+
// 只获取第一个和最后一个点
|
| 177 |
+
const firstPoint = values[0];
|
| 178 |
+
const lastPoint = values[values.length - 1];
|
| 179 |
+
|
| 180 |
+
// 创建只包含首尾两点的数据
|
| 181 |
+
const slopeData = [firstPoint, lastPoint];
|
| 182 |
+
|
| 183 |
+
// 绘制线条
|
| 184 |
+
g.append("path")
|
| 185 |
+
.datum(slopeData)
|
| 186 |
+
.attr("fill", "none")
|
| 187 |
+
.attr("stroke", color)
|
| 188 |
+
.attr("stroke-width", lineWidth)
|
| 189 |
+
.attr("d", line);
|
| 190 |
+
|
| 191 |
+
// 收集需要标注的点
|
| 192 |
+
// 起点
|
| 193 |
+
startPoints.push({
|
| 194 |
+
x: xScale(chartUtils.format.parseDate(firstPoint[xField])),
|
| 195 |
+
y: yScale(+firstPoint[yField]),
|
| 196 |
+
value: chartUtils.format.integer(firstPoint[yField]).text,
|
| 197 |
+
color: color,
|
| 198 |
+
group: group,
|
| 199 |
+
point: firstPoint
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
// 终点
|
| 203 |
+
endPoints.push({
|
| 204 |
+
x: xScale(chartUtils.format.parseDate(lastPoint[xField])),
|
| 205 |
+
y: yScale(+lastPoint[yField]),
|
| 206 |
+
value: chartUtils.format.integer(lastPoint[yField]).text,
|
| 207 |
+
color: color,
|
| 208 |
+
group: group,
|
| 209 |
+
point: lastPoint
|
| 210 |
+
});
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
// 分别对起点和终点应用动态规划 - 添加g参数
|
| 214 |
+
const startLabelPositions = placeLabelsDP(startPoints, yTicks.map(tick => yScale(tick)), "起点", chartHeight, chartWidth, g);
|
| 215 |
+
const endLabelPositions = placeLabelsDP(endPoints, yTicks.map(tick => yScale(tick)), "终点", chartHeight, chartWidth, g);
|
| 216 |
+
|
| 217 |
+
// 绘制标签函数 - 保持multiple_line_graph_10的样式
|
| 218 |
+
function drawLabels(labelPositions) {
|
| 219 |
+
labelPositions.forEach(placement => {
|
| 220 |
+
const point = placement.point;
|
| 221 |
+
const labelY = placement.labelY;
|
| 222 |
+
|
| 223 |
+
// 计算标签文本和宽度
|
| 224 |
+
const labelText = point.value;
|
| 225 |
+
const labelWidth = chartUtils.text.measure(null, labelText, { fontSize: 14, fontWeight: "bold" }).width + 10;
|
| 226 |
+
const labelHeight = 24;
|
| 227 |
+
|
| 228 |
+
// 添加圆角矩形背景
|
| 229 |
+
g.append("rect")
|
| 230 |
+
.attr("x", point.x - labelWidth / 2)
|
| 231 |
+
.attr("y", labelY + labelHeight / 2)
|
| 232 |
+
.attr("width", labelWidth)
|
| 233 |
+
.attr("height", labelHeight)
|
| 234 |
+
.attr("rx", 5)
|
| 235 |
+
.attr("ry", 5)
|
| 236 |
+
.attr("fill", point.color);
|
| 237 |
+
|
| 238 |
+
// 添加文本 - 白色粗体
|
| 239 |
+
g.append("text")
|
| 240 |
+
.attr("x", point.x)
|
| 241 |
+
.attr("y", labelY + labelHeight)
|
| 242 |
+
.attr("text-anchor", "middle")
|
| 243 |
+
.attr("dominant-baseline", "middle")
|
| 244 |
+
.attr("fill", "#fff")
|
| 245 |
+
.attr("font-weight", "bold")
|
| 246 |
+
.style("font-size", "14px")
|
| 247 |
+
.text(labelText);
|
| 248 |
+
});
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
// 绘制所有标签 - 只绘制起点和终点
|
| 252 |
+
drawLabels(startLabelPositions);
|
| 253 |
+
drawLabels(endLabelPositions);
|
| 254 |
+
|
| 255 |
+
const legendSize = chartUtils.legend.draw(g, groups, {
|
| 256 |
+
fontSize: 14,
|
| 257 |
+
fontWeight: "bold",
|
| 258 |
+
maxWidth: chartWidth,
|
| 259 |
+
markerShape: "line",
|
| 260 |
+
markerSize: 10,
|
| 261 |
+
strokeWidth: 4,
|
| 262 |
+
colorScale,
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
const maxYTickPosition = yScale(yTicks[yTicks.length - 1]);
|
| 266 |
+
|
| 267 |
+
legendSize.group.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
|
| 268 |
+
|
| 269 |
+
return svg.node();
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// 动态规划标签放置算法 - 添加g参数
|
| 273 |
+
function placeLabelsDP(points, avoidYPositions = [], debugName = "", chartHeight, chartWidth, g) {
|
| 274 |
+
// 每个格点的高度(像素)
|
| 275 |
+
const GRID_SIZE = 3;
|
| 276 |
+
// 圆点周围的保护区域(格点数)
|
| 277 |
+
const PROTECTION_RADIUS = 3;
|
| 278 |
+
// 标签高度(格点数)
|
| 279 |
+
const LABEL_HEIGHT = 10;
|
| 280 |
+
|
| 281 |
+
// 离散化Y坐标,创建格点系统
|
| 282 |
+
const minY = 0;
|
| 283 |
+
const maxY = chartHeight; // 使用传入的chartHeight
|
| 284 |
+
const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
|
| 285 |
+
|
| 286 |
+
// 按照Y坐标排序点
|
| 287 |
+
points.sort((a, b) => a.y - b.y);
|
| 288 |
+
|
| 289 |
+
// 创建格点占用标记
|
| 290 |
+
const occupied = new Array(gridCount).fill(false);
|
| 291 |
+
// 存储占用原因,用于可视化
|
| 292 |
+
const occupiedReason = new Array(gridCount).fill("");
|
| 293 |
+
|
| 294 |
+
// 标记圆点周围的保护区域为已占用
|
| 295 |
+
points.forEach((point, idx) => {
|
| 296 |
+
const gridY = Math.floor(point.y / GRID_SIZE) - 3;
|
| 297 |
+
for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
|
| 298 |
+
occupied[i] = true;
|
| 299 |
+
occupiedReason[i] = `数据点${idx + 1}保护区`;
|
| 300 |
+
}
|
| 301 |
+
});
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
// 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
|
| 305 |
+
const n = points.length;
|
| 306 |
+
const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
|
| 307 |
+
const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
|
| 308 |
+
|
| 309 |
+
// 初始条件:第一个点的标签放置
|
| 310 |
+
const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
|
| 311 |
+
|
| 312 |
+
// 尝试将第一个点的标签放在可行的位置
|
| 313 |
+
for (let j = 0; j < gridCount; j++) {
|
| 314 |
+
// 检查是否可行(不在保护区域内且有足够空间放置标签)
|
| 315 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 316 |
+
// 检查标签是否会与其他标签重叠
|
| 317 |
+
let canPlace = true;
|
| 318 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 319 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 320 |
+
canPlace = false;
|
| 321 |
+
break;
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
if (canPlace) {
|
| 326 |
+
// 计算代价:与圆点位置的距离
|
| 327 |
+
const cost = Math.abs(j - firstPointGridY);
|
| 328 |
+
dp[0][j] = cost;
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
// 填充dp表
|
| 334 |
+
for (let i = 1; i < n; i++) {
|
| 335 |
+
const pointGridY = Math.floor(points[i].y / GRID_SIZE);
|
| 336 |
+
|
| 337 |
+
for (let j = 0; j < gridCount; j++) {
|
| 338 |
+
// 检查当前位置是否可行
|
| 339 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 340 |
+
// 检查标签是否会与其他标签重叠
|
| 341 |
+
let canPlace = true;
|
| 342 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 343 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 344 |
+
canPlace = false;
|
| 345 |
+
break;
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
if (canPlace) {
|
| 350 |
+
// 根据上一个点的标签位置计算当前最小代价
|
| 351 |
+
for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
|
| 352 |
+
if (dp[i-1][k] !== Infinity) {
|
| 353 |
+
|
| 354 |
+
// 当前标签的代价
|
| 355 |
+
const curCost = Math.abs(j - pointGridY);
|
| 356 |
+
const totalCost = dp[i-1][k] + curCost;
|
| 357 |
+
|
| 358 |
+
if (totalCost < dp[i][j]) {
|
| 359 |
+
dp[i][j] = totalCost;
|
| 360 |
+
prev[i][j] = k; // 记录前驱
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
// 找出最后一个点的最优放置位置
|
| 370 |
+
let minCost = Infinity;
|
| 371 |
+
let bestPos = -1;
|
| 372 |
+
|
| 373 |
+
for (let j = 0; j < gridCount; j++) {
|
| 374 |
+
if (dp[n-1][j] < minCost) {
|
| 375 |
+
minCost = dp[n-1][j];
|
| 376 |
+
bestPos = j;
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
// 存储最终选择的标签位置(格点索引)
|
| 381 |
+
const selectedGridPositions = [];
|
| 382 |
+
|
| 383 |
+
// 回溯构建结果
|
| 384 |
+
const labelPositions = [];
|
| 385 |
+
if (bestPos !== -1) {
|
| 386 |
+
// 从后向前回溯
|
| 387 |
+
let pos = bestPos;
|
| 388 |
+
|
| 389 |
+
for (let i = n - 1; i >= 0; i--) {
|
| 390 |
+
labelPositions.unshift({
|
| 391 |
+
point: points[i],
|
| 392 |
+
labelY: pos * GRID_SIZE
|
| 393 |
+
});
|
| 394 |
+
|
| 395 |
+
// 记录选择的位置
|
| 396 |
+
selectedGridPositions.unshift(pos);
|
| 397 |
+
|
| 398 |
+
pos = prev[i][pos];
|
| 399 |
+
}
|
| 400 |
+
} else {
|
| 401 |
+
// 无可行解,退化为简单方案处理
|
| 402 |
+
let lastY = 0;
|
| 403 |
+
for (let i = 0; i < n; i++) {
|
| 404 |
+
const point = points[i];
|
| 405 |
+
// 确保标签不超过下一个圆点的位置
|
| 406 |
+
let maxY = chartHeight;
|
| 407 |
+
if (i < n - 1) {
|
| 408 |
+
maxY = points[i+1].y;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
|
| 412 |
+
|
| 413 |
+
labelPositions.push({
|
| 414 |
+
point: point,
|
| 415 |
+
labelY: labelY
|
| 416 |
+
});
|
| 417 |
+
|
| 418 |
+
lastY = labelY;
|
| 419 |
+
}
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
const debug = false;
|
| 423 |
+
|
| 424 |
+
// 添加格子系统可视化
|
| 425 |
+
if (debug && points.length > 0) {
|
| 426 |
+
// 确定可视化的位置 - 使用数据点的X位���
|
| 427 |
+
const mainPointX = points.length > 0 ? points[0].x : 0; // 使用该组第一个点的X位置
|
| 428 |
+
const visX = mainPointX;
|
| 429 |
+
const visY = 10; // 从图表顶部开始
|
| 430 |
+
const visWidth = 50; // 减小宽度以免干扰图表
|
| 431 |
+
|
| 432 |
+
// 创建一个组用于添加可视化元素,并在需要时应用剪切
|
| 433 |
+
const visGroup = g.append("g");
|
| 434 |
+
|
| 435 |
+
// 限制可视化高度,避免太长
|
| 436 |
+
const maxVisHeight = chartHeight - 20;
|
| 437 |
+
const visibleGridCount = Math.min(gridCount, Math.floor(maxVisHeight / GRID_SIZE));
|
| 438 |
+
|
| 439 |
+
// 添加半透明背景
|
| 440 |
+
visGroup.append("rect")
|
| 441 |
+
.attr("x", visX - 5)
|
| 442 |
+
.attr("y", visY - 15)
|
| 443 |
+
.attr("width", visWidth + 10)
|
| 444 |
+
.attr("height", visibleGridCount * GRID_SIZE + 50)
|
| 445 |
+
.attr("fill", "#fff")
|
| 446 |
+
.attr("opacity", 0.8)
|
| 447 |
+
.attr("rx", 5);
|
| 448 |
+
|
| 449 |
+
// 添加标题
|
| 450 |
+
visGroup.append("text")
|
| 451 |
+
.attr("x", visX + visWidth/2)
|
| 452 |
+
.attr("y", visY - 5)
|
| 453 |
+
.attr("text-anchor", "middle")
|
| 454 |
+
.attr("fill", "#333")
|
| 455 |
+
.style("font-size", "10px")
|
| 456 |
+
.text(debugName + "标签位置");
|
| 457 |
+
|
| 458 |
+
// 绘制每个格子
|
| 459 |
+
for (let i = 0; i < visibleGridCount; i++) {
|
| 460 |
+
// 计算格子的颜色
|
| 461 |
+
let fillColor = "#ddd"; // 默认颜色 - 可用格子
|
| 462 |
+
|
| 463 |
+
if (occupied[i]) {
|
| 464 |
+
fillColor = "#ffcccc"; // 不可用格子 - 红色
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
// 标记最终选择的位置
|
| 468 |
+
const isSelected = selectedGridPositions.some(pos => {
|
| 469 |
+
// 检查格子是否在标签范围内
|
| 470 |
+
for (let j = 0; j < LABEL_HEIGHT; j++) {
|
| 471 |
+
if (pos + j === i) return true;
|
| 472 |
+
}
|
| 473 |
+
return false;
|
| 474 |
+
});
|
| 475 |
+
|
| 476 |
+
if (isSelected) {
|
| 477 |
+
fillColor = "#99ff99"; // 选中的格子 - 绿色
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
// 绘制格子
|
| 481 |
+
visGroup.append("rect")
|
| 482 |
+
.attr("x", visX)
|
| 483 |
+
.attr("y", visY + i * GRID_SIZE)
|
| 484 |
+
.attr("width", visWidth)
|
| 485 |
+
.attr("height", GRID_SIZE)
|
| 486 |
+
.attr("fill", fillColor)
|
| 487 |
+
.attr("stroke", "#ccc")
|
| 488 |
+
.attr("stroke-width", 0.2);
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
// 添加数据点位置标记 - 只显示该组的点
|
| 492 |
+
points.forEach((point, idx) => {
|
| 493 |
+
const gridY = Math.floor(point.y / GRID_SIZE);
|
| 494 |
+
|
| 495 |
+
// 仅当点位置在可视范围内时添加
|
| 496 |
+
if (gridY < visibleGridCount) {
|
| 497 |
+
// 绘制数据点位置线
|
| 498 |
+
visGroup.append("line")
|
| 499 |
+
.attr("x1", visX - 3)
|
| 500 |
+
.attr("y1", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 501 |
+
.attr("x2", visX + visWidth)
|
| 502 |
+
.attr("y2", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 503 |
+
.attr("stroke", point.color)
|
| 504 |
+
.attr("stroke-width", 1)
|
| 505 |
+
.attr("stroke-dasharray", "2,1");
|
| 506 |
+
|
| 507 |
+
// 添加简短的数据点标识
|
| 508 |
+
visGroup.append("text")
|
| 509 |
+
.attr("x", visX - 5)
|
| 510 |
+
.attr("y", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 511 |
+
.attr("text-anchor", "end")
|
| 512 |
+
.attr("dominant-baseline", "middle")
|
| 513 |
+
.attr("fill", point.color)
|
| 514 |
+
.style("font-size", "8px")
|
| 515 |
+
.text("点" + (idx + 1));
|
| 516 |
+
}
|
| 517 |
+
});
|
| 518 |
+
|
| 519 |
+
// 在底部添加简化的图例
|
| 520 |
+
const legendY = visY + visibleGridCount * GRID_SIZE + 5;
|
| 521 |
+
const legendItems = [
|
| 522 |
+
{ label: "可用", color: "#ddd" },
|
| 523 |
+
{ label: "占用", color: "#ffcccc" },
|
| 524 |
+
{ label: "选中", color: "#99ff99" }
|
| 525 |
+
];
|
| 526 |
+
|
| 527 |
+
legendItems.forEach((item, idx) => {
|
| 528 |
+
// 水平放置图例项
|
| 529 |
+
const itemX = visX + idx * (visWidth / 3);
|
| 530 |
+
|
| 531 |
+
visGroup.append("rect")
|
| 532 |
+
.attr("x", itemX)
|
| 533 |
+
.attr("y", legendY)
|
| 534 |
+
.attr("width", 6)
|
| 535 |
+
.attr("height", 6)
|
| 536 |
+
.attr("fill", item.color);
|
| 537 |
+
|
| 538 |
+
visGroup.append("text")
|
| 539 |
+
.attr("x", itemX + 8)
|
| 540 |
+
.attr("y", legendY + 3)
|
| 541 |
+
.attr("text-anchor", "start")
|
| 542 |
+
.attr("dominant-baseline", "middle")
|
| 543 |
+
.attr("fill", "#333")
|
| 544 |
+
.style("font-size", "6px")
|
| 545 |
+
.text(item.label);
|
| 546 |
+
});
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
return labelPositions;
|
| 550 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/slope_chart_02.js
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Slope Chart",
|
| 5 |
+
"chart_name": "slope_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": "dark",
|
| 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_dark || {};
|
| 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.max(0, yMin - yPadding); // 确保下限不小于0
|
| 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", colorResolver.text({ fallback: "#333333" }).value)
|
| 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", colorResolver.text({ fallback: "#333333" }).value)
|
| 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 endPoints = [];
|
| 169 |
+
|
| 170 |
+
groupedData.forEach((values, group) => {
|
| 171 |
+
// 确保数据按日期排序
|
| 172 |
+
values.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 173 |
+
|
| 174 |
+
const color = colorResolver.field(group, 0, { fallbackKey: "primary" }).value;
|
| 175 |
+
|
| 176 |
+
// 只获取第一个和最后一个点
|
| 177 |
+
const firstPoint = values[0];
|
| 178 |
+
const lastPoint = values[values.length - 1];
|
| 179 |
+
|
| 180 |
+
// 创建只包含首尾两点的数据
|
| 181 |
+
const slopeData = [firstPoint, lastPoint];
|
| 182 |
+
|
| 183 |
+
// 绘制线条
|
| 184 |
+
g.append("path")
|
| 185 |
+
.datum(slopeData)
|
| 186 |
+
.attr("fill", "none")
|
| 187 |
+
.attr("stroke", color)
|
| 188 |
+
.attr("stroke-width", lineWidth)
|
| 189 |
+
.attr("d", line);
|
| 190 |
+
|
| 191 |
+
// 收集需要标注的点
|
| 192 |
+
// 起点
|
| 193 |
+
startPoints.push({
|
| 194 |
+
x: xScale(chartUtils.format.parseDate(firstPoint[xField])),
|
| 195 |
+
y: yScale(+firstPoint[yField]),
|
| 196 |
+
value: chartUtils.format.integer(firstPoint[yField]).text,
|
| 197 |
+
color: color,
|
| 198 |
+
group: group,
|
| 199 |
+
point: firstPoint
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
// 终点
|
| 203 |
+
endPoints.push({
|
| 204 |
+
x: xScale(chartUtils.format.parseDate(lastPoint[xField])),
|
| 205 |
+
y: yScale(+lastPoint[yField]),
|
| 206 |
+
value: chartUtils.format.integer(lastPoint[yField]).text,
|
| 207 |
+
color: color,
|
| 208 |
+
group: group,
|
| 209 |
+
point: lastPoint
|
| 210 |
+
});
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
// 分别对起点和终点应用动态规划 - 添加g参数
|
| 214 |
+
const startLabelPositions = placeLabelsDP(startPoints, yTicks.map(tick => yScale(tick)), "起点", chartHeight, chartWidth, g);
|
| 215 |
+
const endLabelPositions = placeLabelsDP(endPoints, yTicks.map(tick => yScale(tick)), "终点", chartHeight, chartWidth, g);
|
| 216 |
+
|
| 217 |
+
// 绘制标签函数 - 保持multiple_line_graph_10的样式
|
| 218 |
+
function drawLabels(labelPositions) {
|
| 219 |
+
labelPositions.forEach(placement => {
|
| 220 |
+
const point = placement.point;
|
| 221 |
+
const labelY = placement.labelY;
|
| 222 |
+
|
| 223 |
+
// 计算标签文本和宽度
|
| 224 |
+
const labelText = point.value;
|
| 225 |
+
const labelWidth = chartUtils.text.measure(null, labelText, { fontSize: 14, fontWeight: "bold" }).width + 10;
|
| 226 |
+
const labelHeight = 24;
|
| 227 |
+
|
| 228 |
+
// 添加圆角矩形背景
|
| 229 |
+
g.append("rect")
|
| 230 |
+
.attr("x", point.x - labelWidth / 2)
|
| 231 |
+
.attr("y", labelY + labelHeight / 2)
|
| 232 |
+
.attr("width", labelWidth)
|
| 233 |
+
.attr("height", labelHeight)
|
| 234 |
+
.attr("rx", 5)
|
| 235 |
+
.attr("ry", 5)
|
| 236 |
+
.attr("fill", point.color);
|
| 237 |
+
|
| 238 |
+
// 添加文本 - 白色粗体
|
| 239 |
+
g.append("text")
|
| 240 |
+
.attr("x", point.x)
|
| 241 |
+
.attr("y", labelY + labelHeight)
|
| 242 |
+
.attr("text-anchor", "middle")
|
| 243 |
+
.attr("dominant-baseline", "middle")
|
| 244 |
+
.attr("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 245 |
+
.attr("font-weight", "bold")
|
| 246 |
+
.style("font-size", "14px")
|
| 247 |
+
.text(labelText);
|
| 248 |
+
});
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
// 绘制所有标签 - 只绘制起点和终点
|
| 252 |
+
drawLabels(startLabelPositions);
|
| 253 |
+
drawLabels(endLabelPositions);
|
| 254 |
+
|
| 255 |
+
const legendSize = chartUtils.legend.draw(g, groups, {
|
| 256 |
+
fontSize: 14,
|
| 257 |
+
fontWeight: "bold",
|
| 258 |
+
maxWidth: chartWidth,
|
| 259 |
+
markerShape: "line",
|
| 260 |
+
markerSize: 10,
|
| 261 |
+
strokeWidth: 4,
|
| 262 |
+
colorScale,
|
| 263 |
+
textColor: colorResolver.text({ fallback: "#333333" }).value,
|
| 264 |
+
});
|
| 265 |
+
|
| 266 |
+
const maxYTickPosition = yScale(yTicks[yTicks.length - 1]);
|
| 267 |
+
|
| 268 |
+
legendSize.group.attr("transform", `translate(${(chartWidth - legendSize.width) / 2}, ${maxYTickPosition - 50 - legendSize.height/2})`);
|
| 269 |
+
|
| 270 |
+
return svg.node();
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
// 动态规划标签放置算法 - 添加g参数
|
| 274 |
+
function placeLabelsDP(points, avoidYPositions = [], debugName = "", chartHeight, chartWidth, g) {
|
| 275 |
+
// 每个格点的高度(像素)
|
| 276 |
+
const GRID_SIZE = 3;
|
| 277 |
+
// 圆点周围的保护区��(格点数)
|
| 278 |
+
const PROTECTION_RADIUS = 3;
|
| 279 |
+
// 标签高度(格点数)
|
| 280 |
+
const LABEL_HEIGHT = 10;
|
| 281 |
+
|
| 282 |
+
// 离散化Y坐标,创建格点系统
|
| 283 |
+
const minY = 0;
|
| 284 |
+
const maxY = chartHeight; // 使用传入的chartHeight
|
| 285 |
+
const gridCount = Math.ceil((maxY - minY) / GRID_SIZE);
|
| 286 |
+
|
| 287 |
+
// 按照Y坐标排序点
|
| 288 |
+
points.sort((a, b) => a.y - b.y);
|
| 289 |
+
|
| 290 |
+
// 创建格点占用标记
|
| 291 |
+
const occupied = new Array(gridCount).fill(false);
|
| 292 |
+
// 存储占用原因,用于可视化
|
| 293 |
+
const occupiedReason = new Array(gridCount).fill("");
|
| 294 |
+
|
| 295 |
+
// 标记圆点周围的保护区域为已占用
|
| 296 |
+
points.forEach((point, idx) => {
|
| 297 |
+
const gridY = Math.floor(point.y / GRID_SIZE) - 3;
|
| 298 |
+
for (let i = Math.max(0, gridY - PROTECTION_RADIUS); i <= Math.min(gridCount - 1, gridY + PROTECTION_RADIUS); i++) {
|
| 299 |
+
occupied[i] = true;
|
| 300 |
+
occupiedReason[i] = `数据点${idx + 1}保护区`;
|
| 301 |
+
}
|
| 302 |
+
});
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
// 定义状态: dp[i][j] 表示前i个点,第i个点的标签放在第j个格点时的最小代价
|
| 306 |
+
const n = points.length;
|
| 307 |
+
const dp = Array(n).fill().map(() => Array(gridCount).fill(Infinity));
|
| 308 |
+
const prev = Array(n).fill().map(() => Array(gridCount).fill(-1));
|
| 309 |
+
|
| 310 |
+
// 初始条件:第一个点的标签放置
|
| 311 |
+
const firstPointGridY = Math.floor(points[0].y / GRID_SIZE);
|
| 312 |
+
|
| 313 |
+
// 尝试将第一个点的标签放在可行的位置
|
| 314 |
+
for (let j = 0; j < gridCount; j++) {
|
| 315 |
+
// 检查是否可行(不在保护区域内且有足够空间放置标签)
|
| 316 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 317 |
+
// 检查标签是否会与其他标签重叠
|
| 318 |
+
let canPlace = true;
|
| 319 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 320 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 321 |
+
canPlace = false;
|
| 322 |
+
break;
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
if (canPlace) {
|
| 327 |
+
// 计算代价:与圆点位置的距离
|
| 328 |
+
const cost = Math.abs(j - firstPointGridY);
|
| 329 |
+
dp[0][j] = cost;
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
// 填充dp表
|
| 335 |
+
for (let i = 1; i < n; i++) {
|
| 336 |
+
const pointGridY = Math.floor(points[i].y / GRID_SIZE);
|
| 337 |
+
|
| 338 |
+
for (let j = 0; j < gridCount; j++) {
|
| 339 |
+
// 检查当前位置是否可行
|
| 340 |
+
if (!occupied[j] && j + LABEL_HEIGHT <= gridCount) {
|
| 341 |
+
// 检查标签是否会与其他标签重叠
|
| 342 |
+
let canPlace = true;
|
| 343 |
+
for (let k = 0; k < LABEL_HEIGHT; k++) {
|
| 344 |
+
if (j + k < gridCount && occupied[j + k]) {
|
| 345 |
+
canPlace = false;
|
| 346 |
+
break;
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
if (canPlace) {
|
| 351 |
+
// 根据上一个点的标签位置计算当前最小代价
|
| 352 |
+
for (let k = 0; k + LABEL_HEIGHT <= j; k++) {
|
| 353 |
+
if (dp[i-1][k] !== Infinity) {
|
| 354 |
+
|
| 355 |
+
// 当前标签的代价
|
| 356 |
+
const curCost = Math.abs(j - pointGridY);
|
| 357 |
+
const totalCost = dp[i-1][k] + curCost;
|
| 358 |
+
|
| 359 |
+
if (totalCost < dp[i][j]) {
|
| 360 |
+
dp[i][j] = totalCost;
|
| 361 |
+
prev[i][j] = k; // 记录前驱
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
// 找出最后一个点的最优放置位置
|
| 371 |
+
let minCost = Infinity;
|
| 372 |
+
let bestPos = -1;
|
| 373 |
+
|
| 374 |
+
for (let j = 0; j < gridCount; j++) {
|
| 375 |
+
if (dp[n-1][j] < minCost) {
|
| 376 |
+
minCost = dp[n-1][j];
|
| 377 |
+
bestPos = j;
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
// 存储最终选择的标签位置(格点索引)
|
| 382 |
+
const selectedGridPositions = [];
|
| 383 |
+
|
| 384 |
+
// 回溯构建结果
|
| 385 |
+
const labelPositions = [];
|
| 386 |
+
if (bestPos !== -1) {
|
| 387 |
+
// 从后向前回溯
|
| 388 |
+
let pos = bestPos;
|
| 389 |
+
|
| 390 |
+
for (let i = n - 1; i >= 0; i--) {
|
| 391 |
+
labelPositions.unshift({
|
| 392 |
+
point: points[i],
|
| 393 |
+
labelY: pos * GRID_SIZE
|
| 394 |
+
});
|
| 395 |
+
|
| 396 |
+
// 记录选择的位置
|
| 397 |
+
selectedGridPositions.unshift(pos);
|
| 398 |
+
|
| 399 |
+
pos = prev[i][pos];
|
| 400 |
+
}
|
| 401 |
+
} else {
|
| 402 |
+
// 无可行解,退化为简单方案处理
|
| 403 |
+
let lastY = 0;
|
| 404 |
+
for (let i = 0; i < n; i++) {
|
| 405 |
+
const point = points[i];
|
| 406 |
+
// 确保标签不超过下一个圆点的位置
|
| 407 |
+
let maxY = chartHeight;
|
| 408 |
+
if (i < n - 1) {
|
| 409 |
+
maxY = points[i+1].y;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
const labelY = Math.min(Math.max(point.y + 20, lastY + 25), maxY - 5);
|
| 413 |
+
|
| 414 |
+
labelPositions.push({
|
| 415 |
+
point: point,
|
| 416 |
+
labelY: labelY
|
| 417 |
+
});
|
| 418 |
+
|
| 419 |
+
lastY = labelY;
|
| 420 |
+
}
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
const debug = false;
|
| 424 |
+
|
| 425 |
+
// 添加格子系统可视化
|
| 426 |
+
if (debug && points.length > 0) {
|
| 427 |
+
// 确定可视化的位置 - 使用数据点的X位置
|
| 428 |
+
const mainPointX = points.length > 0 ? points[0].x : 0; // 使用该组第一个点的X位置
|
| 429 |
+
const visX = mainPointX;
|
| 430 |
+
const visY = 10; // 从图表顶部开始
|
| 431 |
+
const visWidth = 50; // 减小宽度以免干扰图表
|
| 432 |
+
|
| 433 |
+
// 创建一个组用于添加可视化元素,并在需要时应用剪切
|
| 434 |
+
const visGroup = g.append("g");
|
| 435 |
+
|
| 436 |
+
// 限制可视化高度,避免太长
|
| 437 |
+
const maxVisHeight = chartHeight - 20;
|
| 438 |
+
const visibleGridCount = Math.min(gridCount, Math.floor(maxVisHeight / GRID_SIZE));
|
| 439 |
+
|
| 440 |
+
// 添加半透明背景
|
| 441 |
+
visGroup.append("rect")
|
| 442 |
+
.attr("x", visX - 5)
|
| 443 |
+
.attr("y", visY - 15)
|
| 444 |
+
.attr("width", visWidth + 10)
|
| 445 |
+
.attr("height", visibleGridCount * GRID_SIZE + 50)
|
| 446 |
+
.attr("fill", "#fff")
|
| 447 |
+
.attr("opacity", 0.8)
|
| 448 |
+
.attr("rx", 5);
|
| 449 |
+
|
| 450 |
+
// 添加标题
|
| 451 |
+
visGroup.append("text")
|
| 452 |
+
.attr("x", visX + visWidth/2)
|
| 453 |
+
.attr("y", visY - 5)
|
| 454 |
+
.attr("text-anchor", "middle")
|
| 455 |
+
.attr("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 456 |
+
.style("font-size", "10px")
|
| 457 |
+
.text(debugName + "标签位置");
|
| 458 |
+
|
| 459 |
+
// 绘制每个格子
|
| 460 |
+
for (let i = 0; i < visibleGridCount; i++) {
|
| 461 |
+
// 计算格子的颜色
|
| 462 |
+
let fillColor = "#ddd"; // 默认颜色 - 可用格子
|
| 463 |
+
|
| 464 |
+
if (occupied[i]) {
|
| 465 |
+
fillColor = "#ffcccc"; // 不可用格子 - 红色
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
// 标记最终选择的位置
|
| 469 |
+
const isSelected = selectedGridPositions.some(pos => {
|
| 470 |
+
// 检查格子是否在标签范围内
|
| 471 |
+
for (let j = 0; j < LABEL_HEIGHT; j++) {
|
| 472 |
+
if (pos + j === i) return true;
|
| 473 |
+
}
|
| 474 |
+
return false;
|
| 475 |
+
});
|
| 476 |
+
|
| 477 |
+
if (isSelected) {
|
| 478 |
+
fillColor = "#99ff99"; // 选中的格子 - 绿色
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
// 绘制格子
|
| 482 |
+
visGroup.append("rect")
|
| 483 |
+
.attr("x", visX)
|
| 484 |
+
.attr("y", visY + i * GRID_SIZE)
|
| 485 |
+
.attr("width", visWidth)
|
| 486 |
+
.attr("height", GRID_SIZE)
|
| 487 |
+
.attr("fill", fillColor)
|
| 488 |
+
.attr("stroke", "#ccc")
|
| 489 |
+
.attr("stroke-width", 0.2);
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
// 添加数据点位置标记 - 只显示该组的点
|
| 493 |
+
points.forEach((point, idx) => {
|
| 494 |
+
const gridY = Math.floor(point.y / GRID_SIZE);
|
| 495 |
+
|
| 496 |
+
// 仅当点位置在可视范围内时添加
|
| 497 |
+
if (gridY < visibleGridCount) {
|
| 498 |
+
// 绘制数据点位置线
|
| 499 |
+
visGroup.append("line")
|
| 500 |
+
.attr("x1", visX - 3)
|
| 501 |
+
.attr("y1", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 502 |
+
.attr("x2", visX + visWidth)
|
| 503 |
+
.attr("y2", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 504 |
+
.attr("stroke", point.color)
|
| 505 |
+
.attr("stroke-width", 1)
|
| 506 |
+
.attr("stroke-dasharray", "2,1");
|
| 507 |
+
|
| 508 |
+
// 添加简短的数据点标识
|
| 509 |
+
visGroup.append("text")
|
| 510 |
+
.attr("x", visX - 5)
|
| 511 |
+
.attr("y", visY + gridY * GRID_SIZE + GRID_SIZE/2)
|
| 512 |
+
.attr("text-anchor", "end")
|
| 513 |
+
.attr("dominant-baseline", "middle")
|
| 514 |
+
.attr("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 515 |
+
.style("font-size", "8px")
|
| 516 |
+
.text("点" + (idx + 1));
|
| 517 |
+
}
|
| 518 |
+
});
|
| 519 |
+
|
| 520 |
+
// 在底部添加简化的图例
|
| 521 |
+
const legendY = visY + visibleGridCount * GRID_SIZE + 5;
|
| 522 |
+
const legendItems = [
|
| 523 |
+
{ label: "可用", color: "#ddd" },
|
| 524 |
+
{ label: "占用", color: "#ffcccc" },
|
| 525 |
+
{ label: "选中", color: "#99ff99" }
|
| 526 |
+
];
|
| 527 |
+
|
| 528 |
+
legendItems.forEach((item, idx) => {
|
| 529 |
+
// 水平放置图例项
|
| 530 |
+
const itemX = visX + idx * (visWidth / 3);
|
| 531 |
+
|
| 532 |
+
visGroup.append("rect")
|
| 533 |
+
.attr("x", itemX)
|
| 534 |
+
.attr("y", legendY)
|
| 535 |
+
.attr("width", 6)
|
| 536 |
+
.attr("height", 6)
|
| 537 |
+
.attr("fill", item.color);
|
| 538 |
+
|
| 539 |
+
visGroup.append("text")
|
| 540 |
+
.attr("x", itemX + 8)
|
| 541 |
+
.attr("y", legendY + 3)
|
| 542 |
+
.attr("text-anchor", "start")
|
| 543 |
+
.attr("dominant-baseline", "middle")
|
| 544 |
+
.attr("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 545 |
+
.style("font-size", "6px")
|
| 546 |
+
.text(item.label);
|
| 547 |
+
});
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
return labelPositions;
|
| 551 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/small_multiple_line_graph_01.js
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "small multiple line graph",
|
| 5 |
+
"chart_name": "small_multiple_line_graph_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y", "group"],
|
| 8 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 9 |
+
"required_fields_range": [[5, 30], ["-inf", "inf"], [2, 8]],
|
| 10 |
+
"required_fields_icons": [],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": ["group"],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": ["stroke", "spacing"],
|
| 15 |
+
"min_height": 600,
|
| 16 |
+
"min_width": 800,
|
| 17 |
+
"background": "light",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "none",
|
| 20 |
+
"has_x_axis": "yes",
|
| 21 |
+
"has_y_axis": "yes"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data || {};
|
| 28 |
+
const chartData = (jsonData.data && jsonData.data.data) || [];
|
| 29 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 30 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 31 |
+
const chartUtilsFormatSample = chartUtils.format.autoText;
|
| 32 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 33 |
+
const chartUtilsStandard = {
|
| 34 |
+
schema: chartUtils.schema,
|
| 35 |
+
format: chartUtils.format,
|
| 36 |
+
text: chartUtils.text,
|
| 37 |
+
color: chartUtils.color,
|
| 38 |
+
legendLayout: chartUtils.legend.layout,
|
| 39 |
+
legendDraw: chartUtils.legend.draw,
|
| 40 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 41 |
+
};
|
| 42 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 43 |
+
x: { fallbackIndex: 0 },
|
| 44 |
+
y: { fallbackIndex: 1 },
|
| 45 |
+
y2: { fallbackIndex: 2 },
|
| 46 |
+
y3: { fallbackIndex: 3 },
|
| 47 |
+
size: { fallbackIndex: 2 },
|
| 48 |
+
group: { fallbackIndex: 2 },
|
| 49 |
+
group2: { fallbackIndex: 3 },
|
| 50 |
+
group3: { fallbackIndex: 4 }
|
| 51 |
+
});
|
| 52 |
+
const variables = jsonData.variables || {};
|
| 53 |
+
const typography = jsonData.typography || {
|
| 54 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 55 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 56 |
+
title: { font_family: "Arial", font_size: "14px", font_weight: "bold" }
|
| 57 |
+
};
|
| 58 |
+
const sourceColors = jsonData.colors || {};
|
| 59 |
+
const colorField = sourceColors.field || {};
|
| 60 |
+
const colorOther = sourceColors.other || {};
|
| 61 |
+
|
| 62 |
+
d3.select(containerSelector).html("");
|
| 63 |
+
|
| 64 |
+
const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
|
| 65 |
+
const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
|
| 66 |
+
const groupColumn = chartUtils.schema.channel(jsonData, "group", { fallbackIndex: 2 }).raw || {};
|
| 67 |
+
const xField = xColumn.name || "date";
|
| 68 |
+
const yField = yColumn.name || "value";
|
| 69 |
+
const groupField = groupColumn.name || "group";
|
| 70 |
+
const yUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
|
| 71 |
+
|
| 72 |
+
const width = Number(variables.width) || 800;
|
| 73 |
+
const height = Number(variables.height) || 620;
|
| 74 |
+
const textColor = sourceColors.text_color || "#243041";
|
| 75 |
+
const primaryColor = colorOther.primary || "#2f7db7";
|
| 76 |
+
const secondaryColor = colorOther.secondary || "#5b8f69";
|
| 77 |
+
const canvasColor = "#eef7f4";
|
| 78 |
+
const panelColor = "#fffdf7";
|
| 79 |
+
const cardColor = "#ffffff";
|
| 80 |
+
const linePalette = ["#2f7db7", "#f07a2b", "#67a84f", "#d6a22b", "#7b68b2", "#bf5f8d", "#2d9c9c", "#5a6f8f"];
|
| 81 |
+
const labelTypography = typography.label || {};
|
| 82 |
+
const titleTypography = typography.title || labelTypography;
|
| 83 |
+
const valueTypography = typography.annotation || labelTypography;
|
| 84 |
+
const labelFamily = labelTypography.font_family || "Arial";
|
| 85 |
+
const titleFamily = titleTypography.font_family || labelFamily;
|
| 86 |
+
const valueFamily = valueTypography.font_family || labelFamily;
|
| 87 |
+
const labelWeight = labelTypography.font_weight || "normal";
|
| 88 |
+
const titleWeight = titleTypography.font_weight || "700";
|
| 89 |
+
const valueWeight = valueTypography.font_weight || "600";
|
| 90 |
+
const baseLabelSize = parseFloat(labelTypography.font_size) || 11;
|
| 91 |
+
const baseTitleSize = parseFloat(titleTypography.font_size) || 13;
|
| 92 |
+
const baseValueSize = parseFloat(valueTypography.font_size) || 11;
|
| 93 |
+
|
| 94 |
+
function rgba(color, opacity) {
|
| 95 |
+
const c = d3.rgb(color);
|
| 96 |
+
return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function parseTemporalDate(value) {
|
| 100 |
+
if (value instanceof Date && Number.isFinite(value.getTime())) return value;
|
| 101 |
+
const text = String(value ?? "").trim();
|
| 102 |
+
if (!text) return null;
|
| 103 |
+
|
| 104 |
+
let match = text.match(/^(-?\d{4})$/);
|
| 105 |
+
if (match) return new Date(Date.UTC(Number(match[1]), 0, 1));
|
| 106 |
+
|
| 107 |
+
match = text.match(/^(-?\d{4})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
|
| 108 |
+
if (match) {
|
| 109 |
+
return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3] || 1)));
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
match = text.match(/^Q([1-4])\s+(-?\d{4})$/i) || text.match(/^(-?\d{4})\s+Q([1-4])$/i);
|
| 113 |
+
if (match) {
|
| 114 |
+
const year = match[1].length === 4 ? Number(match[1]) : Number(match[2]);
|
| 115 |
+
const quarter = match[1].length === 4 ? Number(match[2]) : Number(match[1]);
|
| 116 |
+
return new Date(Date.UTC(year, (quarter - 1) * 3, 1));
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const season = text.match(/^(spring|summer|fall|autumn|winter)\s+(-?\d{4})$/i);
|
| 120 |
+
if (season) {
|
| 121 |
+
const monthBySeason = { spring: 2, summer: 5, fall: 8, autumn: 8, winter: 11 };
|
| 122 |
+
return new Date(Date.UTC(Number(season[2]), monthBySeason[season[1].toLowerCase()], 1));
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
const parsed = new Date(text);
|
| 126 |
+
if (Number.isFinite(parsed.getTime())) {
|
| 127 |
+
return new Date(Date.UTC(parsed.getUTCFullYear(), parsed.getUTCMonth(), parsed.getUTCDate()));
|
| 128 |
+
}
|
| 129 |
+
return null;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function formatNumber(value) {
|
| 133 |
+
const numericValue = Number(value);
|
| 134 |
+
if (!Number.isFinite(numericValue)) return String(value ?? "");
|
| 135 |
+
const absValue = Math.abs(numericValue);
|
| 136 |
+
if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
|
| 137 |
+
if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
|
| 138 |
+
if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}K`;
|
| 139 |
+
return d3.format(",.4~g")(numericValue);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function formatWithUnit(value, unit) {
|
| 143 |
+
const formatted = formatNumber(value);
|
| 144 |
+
if (!unit) return formatted;
|
| 145 |
+
if (unit === "$") return `$${formatted}`;
|
| 146 |
+
if (unit === "%") return `${formatted}%`;
|
| 147 |
+
return `${formatted} ${unit}`;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function wrapText(text, maxChars) {
|
| 151 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 152 |
+
if (!words.length) return [String(text || "")];
|
| 153 |
+
const lines = [];
|
| 154 |
+
let current = "";
|
| 155 |
+
words.forEach(word => {
|
| 156 |
+
const next = current ? `${current} ${word}` : word;
|
| 157 |
+
if (next.length <= maxChars) {
|
| 158 |
+
current = next;
|
| 159 |
+
return;
|
| 160 |
+
}
|
| 161 |
+
if (current) lines.push(current);
|
| 162 |
+
if (word.length <= maxChars) {
|
| 163 |
+
current = word;
|
| 164 |
+
return;
|
| 165 |
+
}
|
| 166 |
+
let chunk = "";
|
| 167 |
+
for (const char of word) {
|
| 168 |
+
if ((chunk + char).length > maxChars) {
|
| 169 |
+
lines.push(`${chunk}-`);
|
| 170 |
+
chunk = char;
|
| 171 |
+
} else {
|
| 172 |
+
chunk += char;
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
current = chunk;
|
| 176 |
+
});
|
| 177 |
+
if (current) lines.push(current);
|
| 178 |
+
return lines.length ? lines : [String(text || "")];
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
function selectEvenTicks(values, maxTicks) {
|
| 182 |
+
if (values.length <= maxTicks) return values;
|
| 183 |
+
const selected = [];
|
| 184 |
+
for (let i = 0; i < maxTicks; i += 1) {
|
| 185 |
+
const index = Math.round(i * (values.length - 1) / (maxTicks - 1));
|
| 186 |
+
const value = values[index];
|
| 187 |
+
if (!selected.includes(value)) selected.push(value);
|
| 188 |
+
}
|
| 189 |
+
return selected;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
const parsedRows = chartData.map((d, index) => {
|
| 193 |
+
const date = parseTemporalDate(d[xField]);
|
| 194 |
+
const value = Number(d[yField]);
|
| 195 |
+
const group = String(d[groupField] ?? "");
|
| 196 |
+
return {
|
| 197 |
+
...d,
|
| 198 |
+
__index: index,
|
| 199 |
+
__date: date,
|
| 200 |
+
__time: date ? date.getTime() : NaN,
|
| 201 |
+
__xLabel: String(d[xField] ?? ""),
|
| 202 |
+
__value: value,
|
| 203 |
+
__group: group
|
| 204 |
+
};
|
| 205 |
+
}).filter(d => d.__date && Number.isFinite(d.__time) && Number.isFinite(d.__value) && d.__group !== "");
|
| 206 |
+
|
| 207 |
+
if (!parsedRows.length) return;
|
| 208 |
+
|
| 209 |
+
const groups = [];
|
| 210 |
+
parsedRows.forEach(d => {
|
| 211 |
+
if (!groups.includes(d.__group)) groups.push(d.__group);
|
| 212 |
+
});
|
| 213 |
+
if (!groups.length) return;
|
| 214 |
+
|
| 215 |
+
const xLabelByTime = new Map();
|
| 216 |
+
parsedRows.forEach(d => {
|
| 217 |
+
if (!xLabelByTime.has(d.__time)) xLabelByTime.set(d.__time, d.__xLabel);
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
const groupSeries = groups.map(group => {
|
| 221 |
+
const byTime = new Map();
|
| 222 |
+
parsedRows.filter(d => d.__group === group).forEach(d => {
|
| 223 |
+
if (!byTime.has(d.__time)) {
|
| 224 |
+
byTime.set(d.__time, {
|
| 225 |
+
date: d.__date,
|
| 226 |
+
time: d.__time,
|
| 227 |
+
label: d.__xLabel,
|
| 228 |
+
valueSum: 0,
|
| 229 |
+
count: 0
|
| 230 |
+
});
|
| 231 |
+
}
|
| 232 |
+
const entry = byTime.get(d.__time);
|
| 233 |
+
entry.valueSum += d.__value;
|
| 234 |
+
entry.count += 1;
|
| 235 |
+
});
|
| 236 |
+
const values = Array.from(byTime.values())
|
| 237 |
+
.map(d => ({
|
| 238 |
+
date: d.date,
|
| 239 |
+
time: d.time,
|
| 240 |
+
label: d.label,
|
| 241 |
+
value: d.valueSum / d.count
|
| 242 |
+
}))
|
| 243 |
+
.sort((a, b) => a.time - b.time);
|
| 244 |
+
return { group, values };
|
| 245 |
+
}).filter(d => d.values.length);
|
| 246 |
+
|
| 247 |
+
if (!groupSeries.length) return;
|
| 248 |
+
|
| 249 |
+
const xValues = Array.from(new Set(parsedRows.map(d => d.__time)))
|
| 250 |
+
.sort((a, b) => a - b)
|
| 251 |
+
.map(time => new Date(time));
|
| 252 |
+
const allValues = groupSeries.flatMap(series => series.values.map(d => d.value));
|
| 253 |
+
let yMin = d3.min(allValues);
|
| 254 |
+
let yMax = d3.max(allValues);
|
| 255 |
+
if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) return;
|
| 256 |
+
const rawYMin = yMin;
|
| 257 |
+
const rawYMax = yMax;
|
| 258 |
+
if (yMin === yMax) {
|
| 259 |
+
const pad = Math.max(1, Math.abs(yMin) * 0.1);
|
| 260 |
+
yMin -= pad;
|
| 261 |
+
yMax += pad;
|
| 262 |
+
} else {
|
| 263 |
+
const pad = (yMax - yMin) * 0.1;
|
| 264 |
+
yMin -= pad;
|
| 265 |
+
yMax += pad;
|
| 266 |
+
}
|
| 267 |
+
if (rawYMin >= 0) yMin = Math.max(0, yMin);
|
| 268 |
+
if (rawYMax <= 0) yMax = Math.min(0, yMax);
|
| 269 |
+
if (yMin > 0 && (yMax - yMin) / Math.max(1, Math.abs(yMax)) > 0.55) yMin = 0;
|
| 270 |
+
|
| 271 |
+
const cols = groupSeries.length <= 2 ? groupSeries.length : (groupSeries.length <= 4 ? 2 : 3);
|
| 272 |
+
const rows = Math.ceil(groupSeries.length / cols);
|
| 273 |
+
const outer = { top: 20, right: 22, bottom: 26, left: 22 };
|
| 274 |
+
const gapX = variables.has_spacing === false ? 14 : 20;
|
| 275 |
+
const gapY = variables.has_spacing === false ? 18 : 24;
|
| 276 |
+
const panelWidth = (width - outer.left - outer.right - gapX * (cols - 1)) / cols;
|
| 277 |
+
const panelHeight = (height - outer.top - outer.bottom - gapY * (rows - 1)) / rows;
|
| 278 |
+
const plot = {
|
| 279 |
+
left: Math.min(50, Math.max(40, panelWidth * 0.18)),
|
| 280 |
+
right: 14,
|
| 281 |
+
top: 50,
|
| 282 |
+
bottom: 34
|
| 283 |
+
};
|
| 284 |
+
const innerWidth = Math.max(80, panelWidth - plot.left - plot.right);
|
| 285 |
+
const innerHeight = Math.max(70, panelHeight - plot.top - plot.bottom);
|
| 286 |
+
const tickCount = innerWidth < 145 ? 2 : (innerWidth < 230 ? 3 : 4);
|
| 287 |
+
const xTicks = selectEvenTicks(xValues, tickCount);
|
| 288 |
+
|
| 289 |
+
const xScale = d3.scaleUtc()
|
| 290 |
+
.domain(d3.extent(xValues))
|
| 291 |
+
.range([0, innerWidth]);
|
| 292 |
+
const yScale = d3.scaleLinear()
|
| 293 |
+
.domain([yMin, yMax])
|
| 294 |
+
.nice(4)
|
| 295 |
+
.range([innerHeight, 0]);
|
| 296 |
+
const yTicks = yScale.ticks(innerHeight < 95 ? 3 : 4);
|
| 297 |
+
|
| 298 |
+
const palette = d3.schemeTableau10 || d3.schemeCategory10;
|
| 299 |
+
const colorScale = d3.scaleOrdinal()
|
| 300 |
+
.domain(groups)
|
| 301 |
+
.range(groups.map((group, index) => {
|
| 302 |
+
if (colorField[group]) return colorField[group];
|
| 303 |
+
return linePalette[index % linePalette.length] || palette[index % palette.length] || primaryColor;
|
| 304 |
+
}));
|
| 305 |
+
|
| 306 |
+
function formatXTick(date) {
|
| 307 |
+
const label = xLabelByTime.get(date.getTime());
|
| 308 |
+
if (label) return label;
|
| 309 |
+
return d3.utcFormat("%Y")(date);
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
const yScaleNote = rawYMin >= 0
|
| 313 |
+
? "Shared y-scale starts at zero for nonnegative data."
|
| 314 |
+
: "All panels use a shared y-scale from the original values.";
|
| 315 |
+
|
| 316 |
+
const line = d3.line()
|
| 317 |
+
.defined(d => Number.isFinite(d.value))
|
| 318 |
+
.x(d => xScale(d.date))
|
| 319 |
+
.y(d => yScale(d.value))
|
| 320 |
+
.curve(d3.curveMonotoneX);
|
| 321 |
+
|
| 322 |
+
const svg = d3.select(containerSelector)
|
| 323 |
+
.append("svg")
|
| 324 |
+
.attr("width", "100%")
|
| 325 |
+
.attr("height", height)
|
| 326 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 327 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 328 |
+
.attr("xmlns", "http://www.w3.org/2000/svg");
|
| 329 |
+
|
| 330 |
+
const defs = svg.append("defs");
|
| 331 |
+
const panelGradient = defs.append("linearGradient")
|
| 332 |
+
.attr("id", "small_multiple_line_01_panel_gradient")
|
| 333 |
+
.attr("x1", "0%")
|
| 334 |
+
.attr("x2", "0%")
|
| 335 |
+
.attr("y1", "0%")
|
| 336 |
+
.attr("y2", "100%");
|
| 337 |
+
panelGradient.append("stop").attr("offset", "0%").attr("stop-color", "#fffef9");
|
| 338 |
+
panelGradient.append("stop").attr("offset", "100%").attr("stop-color", panelColor);
|
| 339 |
+
const cardShadow = defs.append("filter")
|
| 340 |
+
.attr("id", "small_multiple_line_01_card_shadow")
|
| 341 |
+
.attr("x", "-12%")
|
| 342 |
+
.attr("y", "-18%")
|
| 343 |
+
.attr("width", "124%")
|
| 344 |
+
.attr("height", "140%");
|
| 345 |
+
cardShadow.append("feDropShadow")
|
| 346 |
+
.attr("dx", 0)
|
| 347 |
+
.attr("dy", 1.4)
|
| 348 |
+
.attr("stdDeviation", 1.5)
|
| 349 |
+
.attr("flood-color", "#315d5a")
|
| 350 |
+
.attr("flood-opacity", 0.12);
|
| 351 |
+
|
| 352 |
+
svg.append("rect")
|
| 353 |
+
.attr("class", "chart-canvas")
|
| 354 |
+
.attr("x", 0)
|
| 355 |
+
.attr("y", 0)
|
| 356 |
+
.attr("width", width)
|
| 357 |
+
.attr("height", height)
|
| 358 |
+
.attr("fill", canvasColor);
|
| 359 |
+
|
| 360 |
+
svg.append("rect")
|
| 361 |
+
.attr("class", "chart-style-panel")
|
| 362 |
+
.attr("x", 9)
|
| 363 |
+
.attr("y", 9)
|
| 364 |
+
.attr("width", width - 18)
|
| 365 |
+
.attr("height", height - 18)
|
| 366 |
+
.attr("rx", 12)
|
| 367 |
+
.attr("ry", 12)
|
| 368 |
+
.attr("fill", "url(#small_multiple_line_01_panel_gradient)")
|
| 369 |
+
.attr("stroke", "rgba(95, 143, 120, 0.28)")
|
| 370 |
+
.attr("stroke-width", 1.1);
|
| 371 |
+
|
| 372 |
+
groupSeries.forEach((series, index) => {
|
| 373 |
+
const row = Math.floor(index / cols);
|
| 374 |
+
const col = index % cols;
|
| 375 |
+
const panelX = outer.left + col * (panelWidth + gapX);
|
| 376 |
+
const panelY = outer.top + row * (panelHeight + gapY);
|
| 377 |
+
const panel = svg.append("g")
|
| 378 |
+
.attr("class", "subplot")
|
| 379 |
+
.attr("data-group", series.group)
|
| 380 |
+
.attr("transform", `translate(${panelX}, ${panelY})`);
|
| 381 |
+
|
| 382 |
+
panel.append("rect")
|
| 383 |
+
.attr("class", "subplot-background subplot-card")
|
| 384 |
+
.attr("width", panelWidth)
|
| 385 |
+
.attr("height", panelHeight)
|
| 386 |
+
.attr("rx", 10)
|
| 387 |
+
.attr("ry", 10)
|
| 388 |
+
.attr("fill", cardColor)
|
| 389 |
+
.attr("stroke", "rgba(91, 134, 119, 0.16)")
|
| 390 |
+
.attr("stroke-width", 0.9)
|
| 391 |
+
.style("filter", "url(#small_multiple_line_01_card_shadow)");
|
| 392 |
+
|
| 393 |
+
panel.append("rect")
|
| 394 |
+
.attr("class", "subplot-accent")
|
| 395 |
+
.attr("x", 0)
|
| 396 |
+
.attr("y", 0)
|
| 397 |
+
.attr("width", panelWidth)
|
| 398 |
+
.attr("height", 7)
|
| 399 |
+
.attr("rx", 10)
|
| 400 |
+
.attr("ry", 10)
|
| 401 |
+
.attr("fill", colorScale(series.group))
|
| 402 |
+
.attr("opacity", 0.9);
|
| 403 |
+
|
| 404 |
+
const titleLines = wrapText(series.group, Math.max(9, Math.floor((panelWidth - 20) / 7.2)));
|
| 405 |
+
panel.append("line")
|
| 406 |
+
.attr("class", "group-swatch")
|
| 407 |
+
.attr("x1", 14)
|
| 408 |
+
.attr("x2", 34)
|
| 409 |
+
.attr("y1", 23)
|
| 410 |
+
.attr("y2", 23)
|
| 411 |
+
.attr("stroke", colorScale(series.group))
|
| 412 |
+
.attr("stroke-width", 3.2)
|
| 413 |
+
.attr("stroke-linecap", "round");
|
| 414 |
+
|
| 415 |
+
const title = panel.append("text")
|
| 416 |
+
.attr("class", "label group-label")
|
| 417 |
+
.attr("data-group", series.group)
|
| 418 |
+
.attr("x", 42)
|
| 419 |
+
.attr("y", 20)
|
| 420 |
+
.attr("text-anchor", "start")
|
| 421 |
+
.style("font-family", titleFamily)
|
| 422 |
+
.style("font-size", `${Math.max(10, Math.min(baseTitleSize, 13))}px`)
|
| 423 |
+
.style("font-weight", titleWeight)
|
| 424 |
+
.style("fill", textColor);
|
| 425 |
+
titleLines.slice(0, 3).forEach((lineText, lineIndex) => {
|
| 426 |
+
title.append("tspan")
|
| 427 |
+
.attr("x", 42)
|
| 428 |
+
.attr("dy", lineIndex === 0 ? 0 : 12)
|
| 429 |
+
.text(lineText);
|
| 430 |
+
});
|
| 431 |
+
|
| 432 |
+
const chart = panel.append("g")
|
| 433 |
+
.attr("class", "panel-plot")
|
| 434 |
+
.attr("transform", `translate(${plot.left}, ${plot.top})`);
|
| 435 |
+
|
| 436 |
+
yTicks.forEach(tick => {
|
| 437 |
+
chart.append("line")
|
| 438 |
+
.attr("class", "gridline")
|
| 439 |
+
.attr("x1", 0)
|
| 440 |
+
.attr("x2", innerWidth)
|
| 441 |
+
.attr("y1", yScale(tick))
|
| 442 |
+
.attr("y2", yScale(tick))
|
| 443 |
+
.attr("stroke", rgba(secondaryColor, 0.14))
|
| 444 |
+
.attr("stroke-width", 1);
|
| 445 |
+
chart.append("text")
|
| 446 |
+
.attr("class", "axis-tick y-axis-tick")
|
| 447 |
+
.attr("x", -7)
|
| 448 |
+
.attr("y", yScale(tick))
|
| 449 |
+
.attr("text-anchor", "end")
|
| 450 |
+
.attr("dominant-baseline", "middle")
|
| 451 |
+
.style("font-family", labelFamily)
|
| 452 |
+
.style("font-size", `${Math.max(8.5, Math.min(baseLabelSize, 10))}px`)
|
| 453 |
+
.style("font-weight", labelWeight)
|
| 454 |
+
.style("fill", rgba(textColor, 0.62))
|
| 455 |
+
.text(formatWithUnit(tick, yUnit));
|
| 456 |
+
});
|
| 457 |
+
|
| 458 |
+
chart.append("line")
|
| 459 |
+
.attr("class", "axis-line x-axis-line")
|
| 460 |
+
.attr("x1", 0)
|
| 461 |
+
.attr("x2", innerWidth)
|
| 462 |
+
.attr("y1", innerHeight)
|
| 463 |
+
.attr("y2", innerHeight)
|
| 464 |
+
.attr("stroke", rgba(textColor, 0.28))
|
| 465 |
+
.attr("stroke-width", 1);
|
| 466 |
+
|
| 467 |
+
xTicks.forEach(tick => {
|
| 468 |
+
chart.append("line")
|
| 469 |
+
.attr("class", "axis-tick-line")
|
| 470 |
+
.attr("x1", xScale(tick))
|
| 471 |
+
.attr("x2", xScale(tick))
|
| 472 |
+
.attr("y1", innerHeight)
|
| 473 |
+
.attr("y2", innerHeight + 4)
|
| 474 |
+
.attr("stroke", rgba(textColor, 0.36))
|
| 475 |
+
.attr("stroke-width", 1);
|
| 476 |
+
chart.append("text")
|
| 477 |
+
.attr("class", "axis-tick x-axis-tick")
|
| 478 |
+
.attr("x", xScale(tick))
|
| 479 |
+
.attr("y", innerHeight + 17)
|
| 480 |
+
.attr("text-anchor", "middle")
|
| 481 |
+
.style("font-family", labelFamily)
|
| 482 |
+
.style("font-size", `${Math.max(8.2, Math.min(baseLabelSize, 9.5))}px`)
|
| 483 |
+
.style("font-weight", labelWeight)
|
| 484 |
+
.style("fill", rgba(textColor, 0.72))
|
| 485 |
+
.text(formatXTick(tick));
|
| 486 |
+
});
|
| 487 |
+
|
| 488 |
+
chart.append("path")
|
| 489 |
+
.datum(series.values)
|
| 490 |
+
.attr("class", "line mark")
|
| 491 |
+
.attr("data-group", series.group)
|
| 492 |
+
.attr("data-count", series.values.length)
|
| 493 |
+
.attr("data-latest-value", series.values[series.values.length - 1]?.value ?? "")
|
| 494 |
+
.attr("fill", "none")
|
| 495 |
+
.attr("stroke", colorScale(series.group))
|
| 496 |
+
.attr("stroke-width", variables.has_stroke === false ? 2.6 : 3.2)
|
| 497 |
+
.attr("stroke-linecap", "round")
|
| 498 |
+
.attr("stroke-linejoin", "round")
|
| 499 |
+
.attr("d", line);
|
| 500 |
+
|
| 501 |
+
chart.selectAll(".point")
|
| 502 |
+
.data(series.values)
|
| 503 |
+
.enter()
|
| 504 |
+
.append("circle")
|
| 505 |
+
.attr("class", "point mark")
|
| 506 |
+
.attr("data-group", series.group)
|
| 507 |
+
.attr("data-time", d => d.time)
|
| 508 |
+
.attr("data-label", d => d.label)
|
| 509 |
+
.attr("data-value", d => d.value)
|
| 510 |
+
.attr("cx", d => xScale(d.date))
|
| 511 |
+
.attr("cy", d => yScale(d.value))
|
| 512 |
+
.attr("r", 3)
|
| 513 |
+
.attr("fill", "#ffffff")
|
| 514 |
+
.attr("stroke", colorScale(series.group))
|
| 515 |
+
.attr("stroke-width", 1.7);
|
| 516 |
+
|
| 517 |
+
const lastPoint = series.values[series.values.length - 1];
|
| 518 |
+
if (lastPoint) {
|
| 519 |
+
const latestText = formatWithUnit(lastPoint.value, yUnit);
|
| 520 |
+
const latestFontSize = Math.max(8.5, Math.min(baseValueSize, 10.5));
|
| 521 |
+
const latestY = Math.max(9, Math.min(innerHeight - 5, yScale(lastPoint.value) - 8));
|
| 522 |
+
const pillWidth = Math.max(34, latestText.length * latestFontSize * 0.58 + 12);
|
| 523 |
+
chart.append("rect")
|
| 524 |
+
.attr("class", "latest-value-pill")
|
| 525 |
+
.attr("data-group", series.group)
|
| 526 |
+
.attr("x", innerWidth - pillWidth)
|
| 527 |
+
.attr("y", latestY - latestFontSize + 1)
|
| 528 |
+
.attr("width", pillWidth)
|
| 529 |
+
.attr("height", latestFontSize + 8)
|
| 530 |
+
.attr("rx", 7)
|
| 531 |
+
.attr("ry", 7)
|
| 532 |
+
.attr("fill", "#fffdf8")
|
| 533 |
+
.attr("stroke", rgba(colorScale(series.group), 0.32))
|
| 534 |
+
.attr("stroke-width", 0.9);
|
| 535 |
+
chart.append("text")
|
| 536 |
+
.attr("class", "value latest-value")
|
| 537 |
+
.attr("data-group", series.group)
|
| 538 |
+
.attr("data-time", lastPoint.time)
|
| 539 |
+
.attr("data-label", lastPoint.label)
|
| 540 |
+
.attr("data-value", lastPoint.value)
|
| 541 |
+
.attr("x", innerWidth)
|
| 542 |
+
.attr("y", latestY)
|
| 543 |
+
.attr("text-anchor", "end")
|
| 544 |
+
.style("font-family", valueFamily)
|
| 545 |
+
.style("font-size", `${latestFontSize}px`)
|
| 546 |
+
.style("font-weight", valueWeight)
|
| 547 |
+
.style("fill", colorScale(series.group))
|
| 548 |
+
.text(latestText);
|
| 549 |
+
}
|
| 550 |
+
});
|
| 551 |
+
|
| 552 |
+
svg.append("text")
|
| 553 |
+
.attr("class", "scale-note")
|
| 554 |
+
.attr("x", width / 2)
|
| 555 |
+
.attr("y", height - 13)
|
| 556 |
+
.attr("text-anchor", "middle")
|
| 557 |
+
.style("font-family", valueFamily)
|
| 558 |
+
.style("font-size", `${Math.max(8, Math.min(baseValueSize, 10))}px`)
|
| 559 |
+
.style("font-style", "italic")
|
| 560 |
+
.style("fill", rgba(textColor, 0.58))
|
| 561 |
+
.text(yScaleNote);
|
| 562 |
+
|
| 563 |
+
return svg.node();
|
| 564 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/small_multiple_step_line_graph_01.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Small Multiple Step Line Graph",
|
| 5 |
+
"chart_name": "small_multiple_step_line_graph_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[5, 30], ["-inf", "inf"], [4, 4]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": ["group"],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": [],
|
| 14 |
+
"min_height": 600,
|
| 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 |
+
// 提取数据
|
| 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_dark || {};
|
| 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 groups = [...new Set(chartData.map(d => d[groupField]))];
|
| 46 |
+
|
| 47 |
+
// 设置尺寸和边距
|
| 48 |
+
const width = variables.width;
|
| 49 |
+
const height = variables.height;
|
| 50 |
+
const margin = { top: 20, right: 20, bottom: 20, left: 20 };
|
| 51 |
+
|
| 52 |
+
// 计算子图布局 - 2x2布局或根据组数量调整
|
| 53 |
+
const rows = Math.ceil(groups.length / 2);
|
| 54 |
+
const cols = Math.min(groups.length, 2);
|
| 55 |
+
|
| 56 |
+
// 子图尺寸
|
| 57 |
+
const subplotWidth = (width - margin.left - margin.right) / cols;
|
| 58 |
+
const subplotHeight = (height - margin.top - margin.bottom) / rows;
|
| 59 |
+
const subplotMargin = { top: 60, right: 20, bottom: 40, left: 50 };
|
| 60 |
+
const innerWidth = subplotWidth - subplotMargin.left - subplotMargin.right;
|
| 61 |
+
const innerHeight = subplotHeight - subplotMargin.top - subplotMargin.bottom;
|
| 62 |
+
|
| 63 |
+
// 创建SVG
|
| 64 |
+
const svg = d3.select(containerSelector)
|
| 65 |
+
.append("svg")
|
| 66 |
+
.attr("width", "100%")
|
| 67 |
+
.attr("height", height)
|
| 68 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 69 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 70 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 71 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 72 |
+
|
| 73 |
+
// 创建defs元素用于存放遮罩
|
| 74 |
+
const defs = svg.append("defs");
|
| 75 |
+
|
| 76 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(chartData, xField, 0, innerWidth);
|
| 77 |
+
|
| 78 |
+
// 为每个组创建子图
|
| 79 |
+
groups.forEach((group, i) => {
|
| 80 |
+
const row = Math.floor(i / cols);
|
| 81 |
+
const col = i % cols;
|
| 82 |
+
|
| 83 |
+
// 子图位置
|
| 84 |
+
const subplotX = margin.left + col * subplotWidth;
|
| 85 |
+
const subplotY = margin.top + row * subplotHeight;
|
| 86 |
+
|
| 87 |
+
// 创建子图组
|
| 88 |
+
const subplot = svg.append("g")
|
| 89 |
+
.attr("class", "subplot")
|
| 90 |
+
.attr("transform", `translate(${subplotX}, ${subplotY})`);
|
| 91 |
+
|
| 92 |
+
// 创建图表组
|
| 93 |
+
const g = subplot.append("g")
|
| 94 |
+
.attr("transform", `translate(${subplotMargin.left}, ${subplotMargin.top})`);
|
| 95 |
+
|
| 96 |
+
// 获取当前组的数据
|
| 97 |
+
const groupData = chartData.filter(d => d[groupField] === group);
|
| 98 |
+
|
| 99 |
+
// 创建y轴比例尺 - 根据数据调整
|
| 100 |
+
const groupYMin = Math.min(0, d3.min(groupData, d => +d[yField]) * 1.4);
|
| 101 |
+
const groupYMax = d3.max(groupData, d => +d[yField]) * 1.1;
|
| 102 |
+
|
| 103 |
+
const yScale = d3.scaleLinear()
|
| 104 |
+
.domain([groupYMin, groupYMax])
|
| 105 |
+
.range([innerHeight, 0]);
|
| 106 |
+
|
| 107 |
+
// 添加网格线 - 减少刻度密度
|
| 108 |
+
const yTicks = yScale.ticks(4); // 从6减少到4
|
| 109 |
+
yTicks.forEach(tick => {
|
| 110 |
+
g.append("line")
|
| 111 |
+
.attr("x1", 0)
|
| 112 |
+
.attr("y1", yScale(tick))
|
| 113 |
+
.attr("x2", innerWidth)
|
| 114 |
+
.attr("y2", yScale(tick))
|
| 115 |
+
.attr("stroke", "#333")
|
| 116 |
+
.attr("stroke-width", 0.5);
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
// 减少X轴刻度密度
|
| 120 |
+
xTicks.forEach(tick => {
|
| 121 |
+
g.append("line")
|
| 122 |
+
.attr("x1", xScale(tick))
|
| 123 |
+
.attr("y1", 0)
|
| 124 |
+
.attr("x2", xScale(tick))
|
| 125 |
+
.attr("y2", innerHeight)
|
| 126 |
+
.attr("stroke", "#333")
|
| 127 |
+
.attr("stroke-width", 0.5);
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
// 添加X轴线条(绘制在0刻度处)
|
| 131 |
+
g.append("line")
|
| 132 |
+
.attr("x1", 0)
|
| 133 |
+
.attr("y1", yScale(0)) // 使用0刻度的Y坐标
|
| 134 |
+
.attr("x2", innerWidth)
|
| 135 |
+
.attr("y2", yScale(0)) // 使用0���度的Y坐标
|
| 136 |
+
.attr("stroke", "#999999")
|
| 137 |
+
.attr("stroke-width", 1);
|
| 138 |
+
|
| 139 |
+
// 如果是最右侧的图表,在右侧也添加Y轴线
|
| 140 |
+
if (col === cols - 1) {
|
| 141 |
+
g.append("line")
|
| 142 |
+
.attr("x1", innerWidth)
|
| 143 |
+
.attr("y1", 0)
|
| 144 |
+
.attr("x2", innerWidth)
|
| 145 |
+
.attr("y2", innerHeight)
|
| 146 |
+
.attr("stroke", "#999999")
|
| 147 |
+
.attr("stroke-width", 1);
|
| 148 |
+
} else {
|
| 149 |
+
g.append("line")
|
| 150 |
+
.attr("x1", 0)
|
| 151 |
+
.attr("y1", 0)
|
| 152 |
+
.attr("x2", 0)
|
| 153 |
+
.attr("y2", innerHeight)
|
| 154 |
+
.attr("stroke", "#999999")
|
| 155 |
+
.attr("stroke-width", 1);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// 添加标题和图片
|
| 159 |
+
subplot.append("text")
|
| 160 |
+
.attr("x", subplotMargin.left + 40)
|
| 161 |
+
.attr("y", 32)
|
| 162 |
+
.attr("text-anchor", "start")
|
| 163 |
+
.style("font-family", 'Impact')
|
| 164 |
+
.style("font-size", "24px")
|
| 165 |
+
// .style("font-weight", "bold")
|
| 166 |
+
.style("fill", "#FFFFFF")
|
| 167 |
+
.text(group);
|
| 168 |
+
|
| 169 |
+
// 添加组图片(如果有)
|
| 170 |
+
if (images.field && images.field[group]) {
|
| 171 |
+
// 创建圆形遮罩
|
| 172 |
+
const maskId = `circle-mask-${i}`;
|
| 173 |
+
|
| 174 |
+
// 添加圆形遮罩定义
|
| 175 |
+
defs.append("mask")
|
| 176 |
+
.attr("id", maskId)
|
| 177 |
+
.append("circle")
|
| 178 |
+
.attr("cx", 18)
|
| 179 |
+
.attr("cy", 18)
|
| 180 |
+
.attr("r", 19.5)
|
| 181 |
+
.attr("fill", "white");
|
| 182 |
+
|
| 183 |
+
// 获取图片的base64数据
|
| 184 |
+
const imgData = images.field[group];
|
| 185 |
+
|
| 186 |
+
// 创建图片容器组
|
| 187 |
+
const imgGroup = subplot.append("g")
|
| 188 |
+
.attr("transform", `translate(${subplotMargin.left}, 7)`);
|
| 189 |
+
|
| 190 |
+
// 添加白色圆形边框
|
| 191 |
+
imgGroup.append("circle")
|
| 192 |
+
.attr("cx", 18)
|
| 193 |
+
.attr("cy", 18)
|
| 194 |
+
.attr("r", 19.5)
|
| 195 |
+
.attr("fill", "none")
|
| 196 |
+
.attr("stroke", "white")
|
| 197 |
+
.attr("stroke-width", 1);
|
| 198 |
+
|
| 199 |
+
// 添加带遮罩的图片
|
| 200 |
+
imgGroup.append("image")
|
| 201 |
+
.attr("x", 0)
|
| 202 |
+
.attr("y", 0)
|
| 203 |
+
.attr("width", 36)
|
| 204 |
+
.attr("height", 36)
|
| 205 |
+
.attr("mask", `url(#${maskId})`)
|
| 206 |
+
.attr("xlink:href", imgData);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// 添加y轴刻度 - 根据位置决定显示左侧还是右侧
|
| 210 |
+
yTicks.forEach(tick => {
|
| 211 |
+
|
| 212 |
+
// 右侧刻度 - 只有最右侧的图表显示
|
| 213 |
+
if (col === cols - 1) {
|
| 214 |
+
g.append("text")
|
| 215 |
+
.attr("x", innerWidth + 10)
|
| 216 |
+
.attr("y", yScale(tick))
|
| 217 |
+
.attr("text-anchor", "start")
|
| 218 |
+
.attr("dominant-baseline", "middle")
|
| 219 |
+
.style("font-family", 'Arial Narrow')
|
| 220 |
+
.style("font-size", "16px")
|
| 221 |
+
.style("fill", "#d5d5d5")
|
| 222 |
+
.text(tick);
|
| 223 |
+
} else {
|
| 224 |
+
// 左侧刻度
|
| 225 |
+
g.append("text")
|
| 226 |
+
.attr("x", -10)
|
| 227 |
+
.attr("y", yScale(tick))
|
| 228 |
+
.attr("text-anchor", "end")
|
| 229 |
+
.attr("dominant-baseline", "middle")
|
| 230 |
+
.style("font-family", 'Arial Narrow')
|
| 231 |
+
.style("font-size", "16px")
|
| 232 |
+
.style("fill", "#d5d5d5")
|
| 233 |
+
.text(tick);
|
| 234 |
+
}
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
+
// 添加x轴刻度
|
| 238 |
+
xTicks.forEach(tick => {
|
| 239 |
+
g.append("text")
|
| 240 |
+
.attr("x", xScale(tick))
|
| 241 |
+
.attr("y", innerHeight + 25)
|
| 242 |
+
.attr("text-anchor", "middle")
|
| 243 |
+
.style("font-family", 'Arial Narrow')
|
| 244 |
+
.style("font-size", "16px")
|
| 245 |
+
.style("fill", "#d5d5d5")
|
| 246 |
+
.text(xFormat(tick));
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
+
// 创建线条生成器
|
| 250 |
+
const line = d3.line()
|
| 251 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 252 |
+
.y(d => yScale(+d[yField]))
|
| 253 |
+
.curve(d3.curveStepAfter); // 使用阶梯曲线
|
| 254 |
+
|
| 255 |
+
// 绘制线条
|
| 256 |
+
g.append("path")
|
| 257 |
+
.datum(groupData)
|
| 258 |
+
.attr("fill", "none")
|
| 259 |
+
.attr("stroke", colorResolver.field(group, 0, { fallbackKey: "primary" }).value) // 蓝色
|
| 260 |
+
.attr("stroke-width", 2.5)
|
| 261 |
+
.attr("d", line);
|
| 262 |
+
|
| 263 |
+
// 添加最终利率值标签
|
| 264 |
+
if (groupData.length > 0) {
|
| 265 |
+
const lastPoint = groupData[groupData.length - 1];
|
| 266 |
+
const secondLastPoint = groupData[groupData.length - 2];
|
| 267 |
+
|
| 268 |
+
// 判断最后一段是向上还是向下
|
| 269 |
+
const isGoingDown = +lastPoint[yField] < +secondLastPoint[yField];
|
| 270 |
+
|
| 271 |
+
// 根据���向决定标签位置
|
| 272 |
+
const labelOffset = isGoingDown ? 20 : -10;
|
| 273 |
+
|
| 274 |
+
const labelXOffset = col === cols - 1 ? -17 : 0;
|
| 275 |
+
|
| 276 |
+
g.append("text")
|
| 277 |
+
.attr("x", xScale(chartUtils.format.parseDate(lastPoint[xField])) + labelXOffset)
|
| 278 |
+
.attr("y", yScale(+lastPoint[yField]) + labelOffset)
|
| 279 |
+
.attr("text-anchor", "middle")
|
| 280 |
+
.style("font-family", 'Arial Narrow')
|
| 281 |
+
.style("font-size", "18px")
|
| 282 |
+
.style("fill", "#d5d5d5")
|
| 283 |
+
.text(chartUtils.format.fixed(+lastPoint[yField]).text);
|
| 284 |
+
}
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
return svg.node();
|
| 288 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/spline_graph_01.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Spline Graph",
|
| 5 |
+
"chart_name": "spline_graph_01",
|
| 6 |
+
"required_fields": ["x", "y", "group"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"], ["categorical"]],
|
| 8 |
+
"required_fields_range": [[3, 50], [-1000, 1000], [2, 2]],
|
| 9 |
+
"required_fields_icons": [],
|
| 10 |
+
"required_other_icons": [],
|
| 11 |
+
"required_fields_colors": [],
|
| 12 |
+
"required_other_colors": [],
|
| 13 |
+
"supported_effects": ["gradient", "opacity"],
|
| 14 |
+
"min_height": 600,
|
| 15 |
+
"min_width": 800,
|
| 16 |
+
"background": "styled",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 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 |
+
// 清空容器
|
| 39 |
+
d3.select(containerSelector).html("");
|
| 40 |
+
|
| 41 |
+
// 获取字段名
|
| 42 |
+
const xField = chartUtils.schema.columnField(dataColumns, 0);
|
| 43 |
+
const yField = chartUtils.schema.columnField(dataColumns, 1);
|
| 44 |
+
const groupField = chartUtils.schema.columnField(dataColumns, 2);
|
| 45 |
+
const lineColor = colorResolver.other("primary", { fallback: "#8364ac" }).value;
|
| 46 |
+
const positiveColor = colorResolver.other("positive", { fallback: "#ef9522" }).value;
|
| 47 |
+
const negativeColor = colorResolver.other("negative", { fallback: "#9a8abe" }).value;
|
| 48 |
+
|
| 49 |
+
// 设置尺寸和边距
|
| 50 |
+
const width = variables.width;
|
| 51 |
+
const height = variables.height;
|
| 52 |
+
const margin = { top: 60, right: 40, bottom: 60, left: 60 };
|
| 53 |
+
|
| 54 |
+
// 创建SVG
|
| 55 |
+
const svg = d3.select(containerSelector)
|
| 56 |
+
.append("svg")
|
| 57 |
+
.attr("width", "100%")
|
| 58 |
+
.attr("height", height)
|
| 59 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 60 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 61 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 62 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 63 |
+
|
| 64 |
+
// 创建图表区域
|
| 65 |
+
const chartWidth = width - margin.left - margin.right;
|
| 66 |
+
const chartHeight = height - margin.top - margin.bottom;
|
| 67 |
+
|
| 68 |
+
// 添加背景图片
|
| 69 |
+
svg.append("image")
|
| 70 |
+
.attr("xlink:href", 'https://www.yczddgj.com/infographic_assets/line_graph_01_BG.png')
|
| 71 |
+
.attr("width", "100%")
|
| 72 |
+
.attr("height", "100%")
|
| 73 |
+
.attr("preserveAspectRatio", "xMidYMid slice")
|
| 74 |
+
.attr("class", "background")
|
| 75 |
+
.attr("x", 0)
|
| 76 |
+
.attr("y", 0);
|
| 77 |
+
|
| 78 |
+
const g = svg.append("g")
|
| 79 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 80 |
+
|
| 81 |
+
// 分离两个组的数据
|
| 82 |
+
const groupNames = [...new Set(chartData.map(d => d[groupField]))];
|
| 83 |
+
const group1Data = chartData.filter(d => d[groupField] === groupNames[0]);
|
| 84 |
+
const group2Data = chartData.filter(d => d[groupField] === groupNames[1]);
|
| 85 |
+
|
| 86 |
+
// 创建一个新的数据集,计算两组之间的差异
|
| 87 |
+
const diffData = [];
|
| 88 |
+
|
| 89 |
+
// 确保两组数据按年份排序
|
| 90 |
+
group1Data.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 91 |
+
group2Data.sort((a, b) => chartUtils.format.parseDate(a[xField]) - chartUtils.format.parseDate(b[xField]));
|
| 92 |
+
|
| 93 |
+
// 计算差异
|
| 94 |
+
for (let i = 0; i < group1Data.length; i++) {
|
| 95 |
+
const year = group1Data[i][xField];
|
| 96 |
+
const group1Value = group1Data[i][yField];
|
| 97 |
+
const group2Item = group2Data.find(d => d[xField] === year);
|
| 98 |
+
|
| 99 |
+
if (group2Item) {
|
| 100 |
+
const group2Value = group2Item[yField];
|
| 101 |
+
const diff = group1Value - group2Value;
|
| 102 |
+
diffData.push({
|
| 103 |
+
[xField]: year,
|
| 104 |
+
[yField]: diff
|
| 105 |
+
});
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const { xScale, xTicks, xFormat, timeSpan } = createXAxisScaleAndTicks(diffData, xField, 0, chartWidth);
|
| 110 |
+
|
| 111 |
+
// 创建y轴比例尺
|
| 112 |
+
const yMin = d3.min(diffData, d => +d[yField]);
|
| 113 |
+
const yMax = d3.max(diffData, d => +d[yField]);
|
| 114 |
+
const yPadding = Math.max(Math.abs(yMin), Math.abs(yMax)) * 0.1;
|
| 115 |
+
|
| 116 |
+
const yScale = d3.scaleLinear()
|
| 117 |
+
.domain([Math.min(yMin - yPadding, -5), Math.max(yMax + yPadding, 5)])
|
| 118 |
+
.range([chartHeight, 0]);
|
| 119 |
+
|
| 120 |
+
// 添加水平网格线
|
| 121 |
+
const yTicks = d3.ticks(yScale.domain()[0], yScale.domain()[1], 10);
|
| 122 |
+
|
| 123 |
+
// 添加y轴线
|
| 124 |
+
g.append("line")
|
| 125 |
+
.attr("x1", 0)
|
| 126 |
+
.attr("y1", yScale(yTicks[yTicks.length-1])) // 从最大刻度开始
|
| 127 |
+
.attr("x2", 0)
|
| 128 |
+
.attr("y2", yScale(yTicks[0])) // 到最小刻度结束
|
| 129 |
+
.attr("stroke", "#000000")
|
| 130 |
+
.attr("opacity", 0.1)
|
| 131 |
+
.attr("stroke-width", 1);
|
| 132 |
+
|
| 133 |
+
// 添加y轴刻度和标签
|
| 134 |
+
yTicks.forEach(tick => {
|
| 135 |
+
// 添加���度小横线
|
| 136 |
+
g.append("line")
|
| 137 |
+
.attr("x1", -5)
|
| 138 |
+
.attr("y1", yScale(tick))
|
| 139 |
+
.attr("x2", 0)
|
| 140 |
+
.attr("y2", yScale(tick))
|
| 141 |
+
.attr("stroke", "#000000")
|
| 142 |
+
.attr("opacity", 0.1)
|
| 143 |
+
.attr("stroke-width", 1);
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
g.append("text")
|
| 147 |
+
.attr("x", -10)
|
| 148 |
+
.attr("y", yScale(tick))
|
| 149 |
+
.attr("text-anchor", "end")
|
| 150 |
+
.attr("dominant-baseline", "middle")
|
| 151 |
+
.style("font-family", "Century Gothic")
|
| 152 |
+
.style("font-size", tick === 0 ? "16px" : "12px")
|
| 153 |
+
.style("font-weight", tick === 0 ? "bold" : "normal")
|
| 154 |
+
.style("fill", "#666666")
|
| 155 |
+
.text(tick + "%");
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
// 添加x轴刻度和标签
|
| 159 |
+
xTicks.forEach(tick => {
|
| 160 |
+
g.append("text")
|
| 161 |
+
.attr("x", xScale(tick))
|
| 162 |
+
.attr("y", yScale(0))
|
| 163 |
+
.attr("text-anchor", "middle")
|
| 164 |
+
.style("font-family", "Century Gothic")
|
| 165 |
+
.style("font-size", "12px")
|
| 166 |
+
.style("fill", "#666666")
|
| 167 |
+
.text(xFormat(tick));
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
// 创建线条生成器
|
| 171 |
+
const line = d3.line()
|
| 172 |
+
.x(d => xScale(chartUtils.format.parseDate(d[xField])))
|
| 173 |
+
.y(d => yScale(+d[yField]))
|
| 174 |
+
.curve(d3.curveMonotoneX);
|
| 175 |
+
|
| 176 |
+
// 创建渐变定义
|
| 177 |
+
const defs = svg.append("defs");
|
| 178 |
+
|
| 179 |
+
// 创建线条渐变
|
| 180 |
+
const lineGradient = defs.append("linearGradient")
|
| 181 |
+
.attr("id", "lineGradient")
|
| 182 |
+
.attr("gradientUnits", "userSpaceOnUse")
|
| 183 |
+
.attr("x1", 0)
|
| 184 |
+
.attr("y1", yScale(yScale.domain()[1])) // 顶部位置
|
| 185 |
+
.attr("x2", 0)
|
| 186 |
+
.attr("y2", yScale(yScale.domain()[0])); // 底部位置
|
| 187 |
+
|
| 188 |
+
// 添加渐变色停止点
|
| 189 |
+
lineGradient.append("stop")
|
| 190 |
+
.attr("offset", "0%")
|
| 191 |
+
.attr("stop-color", "#e2922b"); // 橙色
|
| 192 |
+
|
| 193 |
+
lineGradient.append("stop")
|
| 194 |
+
.attr("offset", `${((yScale(0) - 60) / chartHeight) * 100}%`)
|
| 195 |
+
.attr("stop-color", "#e2922b")
|
| 196 |
+
.attr("stop-opacity", 1);
|
| 197 |
+
|
| 198 |
+
lineGradient.append("stop")
|
| 199 |
+
.attr("offset", `${(yScale(0) / chartHeight) * 100}%`)
|
| 200 |
+
.attr("stop-color", "#e2922b")
|
| 201 |
+
.attr("stop-opacity", 0);
|
| 202 |
+
|
| 203 |
+
lineGradient.append("stop")
|
| 204 |
+
.attr("offset", `${(yScale(0) / chartHeight) * 100}%`)
|
| 205 |
+
.attr("stop-color", lineColor)
|
| 206 |
+
.attr("stop-opacity", 0);
|
| 207 |
+
|
| 208 |
+
lineGradient.append("stop")
|
| 209 |
+
.attr("offset", `${((yScale(0) + 60) / chartHeight) * 100}%`)
|
| 210 |
+
.attr("stop-color", lineColor)
|
| 211 |
+
.attr("stop-opacity", 1);
|
| 212 |
+
|
| 213 |
+
lineGradient.append("stop")
|
| 214 |
+
.attr("offset", "100%")
|
| 215 |
+
.attr("stop-color", lineColor); // 紫色
|
| 216 |
+
|
| 217 |
+
// 绘制线条
|
| 218 |
+
g.append("path")
|
| 219 |
+
.datum(diffData)
|
| 220 |
+
.attr("fill", "none")
|
| 221 |
+
.attr("stroke", "url(#lineGradient)")
|
| 222 |
+
.attr("stroke-width", 3)
|
| 223 |
+
.attr("d", line);
|
| 224 |
+
|
| 225 |
+
// 添加最大值和最小值标注
|
| 226 |
+
const maxPoint = diffData.reduce((max, p) => p[yField] > max[yField] ? p : max, diffData[0]);
|
| 227 |
+
g.append("circle")
|
| 228 |
+
.attr("cx", xScale(chartUtils.format.parseDate(maxPoint[xField])))
|
| 229 |
+
.attr("cy", yScale(maxPoint[yField]))
|
| 230 |
+
.attr("r", 6)
|
| 231 |
+
.attr("fill", "#ffffff")
|
| 232 |
+
.attr("stroke", positiveColor)
|
| 233 |
+
.attr("stroke-width", 2.5);
|
| 234 |
+
|
| 235 |
+
g.append("text")
|
| 236 |
+
.attr("x", xScale(chartUtils.format.parseDate(maxPoint[xField])))
|
| 237 |
+
.attr("y", yScale(maxPoint[yField]) - 15)
|
| 238 |
+
.attr("text-anchor", "middle")
|
| 239 |
+
.style("font-family", "Century Gothic")
|
| 240 |
+
.style("font-size", "12px")
|
| 241 |
+
.style("font-weight", "bold")
|
| 242 |
+
.style("fill", positiveColor)
|
| 243 |
+
.text(chartUtils.format.percent(+maxPoint[yField], { digits: 0 }).text);
|
| 244 |
+
|
| 245 |
+
const minPoint = diffData.reduce((min, p) => p[yField] < min[yField] ? p : min, diffData[0]);
|
| 246 |
+
g.append("circle")
|
| 247 |
+
.attr("cx", xScale(chartUtils.format.parseDate(minPoint[xField])))
|
| 248 |
+
.attr("cy", yScale(minPoint[yField]))
|
| 249 |
+
.attr("r", 6)
|
| 250 |
+
.attr("fill", "#ffffff")
|
| 251 |
+
.attr("stroke", negativeColor)
|
| 252 |
+
.attr("stroke-width", 2.5);
|
| 253 |
+
|
| 254 |
+
g.append("text")
|
| 255 |
+
.attr("x", xScale(chartUtils.format.parseDate(minPoint[xField])))
|
| 256 |
+
.attr("y", yScale(minPoint[yField]) + 20)
|
| 257 |
+
.attr("text-anchor", "middle")
|
| 258 |
+
.style("font-family", "Century Gothic")
|
| 259 |
+
.style("font-size", "12px")
|
| 260 |
+
.style("font-weight", "bold")
|
| 261 |
+
.style("fill", negativeColor)
|
| 262 |
+
.text(chartUtils.format.percent(+minPoint[yField], { digits: 0 }).text);
|
| 263 |
+
|
| 264 |
+
// 添加最新值标注
|
| 265 |
+
const lastPoint = diffData[diffData.length - 1];
|
| 266 |
+
if (lastPoint !== maxPoint && lastPoint !== minPoint) {
|
| 267 |
+
g.append("circle")
|
| 268 |
+
.attr("cx", xScale(chartUtils.format.parseDate(lastPoint[xField])))
|
| 269 |
+
.attr("cy", yScale(+lastPoint[yField]))
|
| 270 |
+
.attr("r", 6)
|
| 271 |
+
.attr("fill", "#ffffff")
|
| 272 |
+
.attr("stroke", +lastPoint[yField] >= 0 ? positiveColor : negativeColor)
|
| 273 |
+
.attr("stroke-width", 2.5);
|
| 274 |
+
|
| 275 |
+
g.append("text")
|
| 276 |
+
.attr("x", xScale(chartUtils.format.parseDate(lastPoint[xField])))
|
| 277 |
+
.attr("y", yScale(+lastPoint[yField]) + 20)
|
| 278 |
+
.attr("text-anchor", "middle")
|
| 279 |
+
.attr("dominant-baseline", "middle")
|
| 280 |
+
.style("font-family", "Century Gothic")
|
| 281 |
+
.style("font-size", "14px")
|
| 282 |
+
.style("font-weight", "bold")
|
| 283 |
+
.style("fill", +lastPoint[yField] >= 0 ? positiveColor : negativeColor)
|
| 284 |
+
.text(chartUtils.format.percent(+lastPoint[yField], { digits: 0 }).text);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
// 添加说明文本
|
| 288 |
+
const wrapText = (text, width) => {
|
| 289 |
+
const words = text.split(/\s+/);
|
| 290 |
+
let line = [];
|
| 291 |
+
let lines = [];
|
| 292 |
+
let currentWidth = 0;
|
| 293 |
+
|
| 294 |
+
words.forEach(word => {
|
| 295 |
+
const wordWidth = chartUtils.text.measure(null, word, { fontSize: 14 }).width;
|
| 296 |
+
if (currentWidth + wordWidth > width) {
|
| 297 |
+
lines.push(line.join(' '));
|
| 298 |
+
line = [word];
|
| 299 |
+
currentWidth = wordWidth;
|
| 300 |
+
} else {
|
| 301 |
+
line.push(word);
|
| 302 |
+
currentWidth += wordWidth;
|
| 303 |
+
}
|
| 304 |
+
});
|
| 305 |
+
lines.push(line.join(' '));
|
| 306 |
+
return lines;
|
| 307 |
+
};
|
| 308 |
+
|
| 309 |
+
const nameLines = wrapText(chartUtils.schema.columnField(dataColumns, 1).toUpperCase() + " DIFFERENCE", 140);
|
| 310 |
+
nameLines.forEach((line, i) => {
|
| 311 |
+
g.append("text")
|
| 312 |
+
.attr("x", 20)
|
| 313 |
+
.attr("y", yScale(yMax) - 5 + i * 15) // 将第一行与最高刻度对齐
|
| 314 |
+
.attr("text-anchor", "start")
|
| 315 |
+
.style("font-family", "Century Gothic")
|
| 316 |
+
.style("font-size", "12px")
|
| 317 |
+
.style("font-weight", "bold")
|
| 318 |
+
.style("fill", "#666666")
|
| 319 |
+
.text(line);
|
| 320 |
+
});
|
| 321 |
+
|
| 322 |
+
const descLines = wrapText(groupNames[0] + " and " + groupNames[1], 160);
|
| 323 |
+
descLines.forEach((line, i) => {
|
| 324 |
+
g.append("text")
|
| 325 |
+
.attr("x", 20)
|
| 326 |
+
.attr("y", yScale(yMax) - 5 + nameLines.length * 20 + i * 14) // 相应调整描述文本位置
|
| 327 |
+
.attr("text-anchor", "start")
|
| 328 |
+
.style("font-family", "Century Gothic")
|
| 329 |
+
.style("font-size", "11px")
|
| 330 |
+
.style("fill", "#666666")
|
| 331 |
+
.text(line);
|
| 332 |
+
});
|
| 333 |
+
|
| 334 |
+
// 自动寻找最佳箭头位置
|
| 335 |
+
function findBestArrowPosition(isUpArrow) {
|
| 336 |
+
const zeroY = yScale(0);
|
| 337 |
+
const arrowSize = 100; // 箭头大小为100x100像素
|
| 338 |
+
const stepSize = 10; // 每10像素尝试一次
|
| 339 |
+
|
| 340 |
+
let bestX = 0;
|
| 341 |
+
let bestY = 0;
|
| 342 |
+
let bestCenterDistance = Infinity;
|
| 343 |
+
|
| 344 |
+
// 图表中心点
|
| 345 |
+
const centerX = chartWidth / 2;
|
| 346 |
+
const centerY = chartHeight / 2;
|
| 347 |
+
|
| 348 |
+
// 遍历可能的位置
|
| 349 |
+
for (let x = 40; x <= chartWidth - arrowSize; x += stepSize) {
|
| 350 |
+
for (let y = 0; y <= chartHeight - arrowSize; y += stepSize) {
|
| 351 |
+
// 检查是否在正确的区域(上箭头在零线上方,下箭头在零线下方)
|
| 352 |
+
if ((isUpArrow && y + arrowSize + 20 > zeroY) || (!isUpArrow && y - 20 < zeroY)) {
|
| 353 |
+
continue;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
// 检查是否与数据线重叠
|
| 357 |
+
let overlapsLine = false;
|
| 358 |
+
for (let testX = x; testX < x + arrowSize; testX += 10) {
|
| 359 |
+
for (let testY = y; testY < y + arrowSize; testY += 10) {
|
| 360 |
+
// 检查是否在数据线附近
|
| 361 |
+
for (let i = 0; i < diffData.length - 1; i++) {
|
| 362 |
+
const x1 = xScale(chartUtils.format.parseDate(diffData[i][xField]));
|
| 363 |
+
const y1 = yScale(diffData[i][yField]);
|
| 364 |
+
const x2 = xScale(chartUtils.format.parseDate(diffData[i + 1][xField]));
|
| 365 |
+
const y2 = yScale(diffData[i + 1][yField]);
|
| 366 |
+
|
| 367 |
+
// 检查测试点是否在线段的范围内
|
| 368 |
+
if (testX >= Math.min(x1, x2) && testX <= Math.max(x1, x2)) {
|
| 369 |
+
// 计算点到线段的距离
|
| 370 |
+
const A = y2 - y1;
|
| 371 |
+
const B = x1 - x2;
|
| 372 |
+
const C = x2 * y1 - x1 * y2;
|
| 373 |
+
|
| 374 |
+
const distance = Math.abs(A * testX + B * testY + C) /
|
| 375 |
+
Math.sqrt(A * A + B * B);
|
| 376 |
+
|
| 377 |
+
if (distance < 5) { // 如果距离小于20像素,认为重叠
|
| 378 |
+
overlapsLine = true;
|
| 379 |
+
break;
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
if (overlapsLine) break;
|
| 384 |
+
}
|
| 385 |
+
if (overlapsLine) break;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
if (isUpArrow) {
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
if (!overlapsLine) {
|
| 392 |
+
// 计算到中心点的距离
|
| 393 |
+
const centerDistance = Math.sqrt(Math.pow(x + arrowSize/2 - centerX, 2) +
|
| 394 |
+
Math.pow(y + arrowSize/2 - centerY, 2));
|
| 395 |
+
|
| 396 |
+
// 如果这个位置比之前找到的更好,更新最佳位置
|
| 397 |
+
if (centerDistance < bestCenterDistance) {
|
| 398 |
+
bestX = x;
|
| 399 |
+
bestY = y;
|
| 400 |
+
bestCenterDistance = centerDistance;
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
return { x: bestX, y: bestY };
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// 获取最佳箭头位置
|
| 410 |
+
const upArrowPos = findBestArrowPosition(true);
|
| 411 |
+
const downArrowPos = findBestArrowPosition(false);
|
| 412 |
+
|
| 413 |
+
const leftOffset = 20;
|
| 414 |
+
|
| 415 |
+
// 添加向上的箭头和向下的箭头
|
| 416 |
+
g.append("image")
|
| 417 |
+
.attr("xlink:href", 'https://www.yczddgj.com/infographic_assets/line_graph_01_UpArrow.png')
|
| 418 |
+
.attr("width", "60")
|
| 419 |
+
.attr("height", "60")
|
| 420 |
+
.attr("x", upArrowPos.x + leftOffset)
|
| 421 |
+
.attr("y", upArrowPos.y);
|
| 422 |
+
|
| 423 |
+
// 根据文本长度计算背景宽度
|
| 424 |
+
const upLabelWidth = Math.max(groupNames[0].toUpperCase().length * 8 + 5, 100); // 每个字符10px宽度,两边各加20px边距
|
| 425 |
+
|
| 426 |
+
g.append("image")
|
| 427 |
+
.attr("xlink:href", 'https://www.yczddgj.com/infographic_assets/line_graph_01_UpLabelBG.png')
|
| 428 |
+
.attr("width", upLabelWidth)
|
| 429 |
+
.attr("height", "30")
|
| 430 |
+
.attr("x", upArrowPos.x + leftOffset - upLabelWidth/2 + 30)
|
| 431 |
+
.attr("y", upArrowPos.y + 65)
|
| 432 |
+
.attr("preserveAspectRatio", "none");
|
| 433 |
+
g.append("text")
|
| 434 |
+
.attr("x", upArrowPos.x + leftOffset + 30)
|
| 435 |
+
.attr("y", upArrowPos.y + 85)
|
| 436 |
+
.attr("text-anchor", "middle")
|
| 437 |
+
.style("font-family", "Century Gothic")
|
| 438 |
+
.style("font-size", "14px")
|
| 439 |
+
.style("font-weight", "bold")
|
| 440 |
+
.style("fill", "#111111")
|
| 441 |
+
.style("opacity", 0.8)
|
| 442 |
+
.text(groupNames[0].toUpperCase());
|
| 443 |
+
g.append("text")
|
| 444 |
+
.attr("x", upArrowPos.x + leftOffset + 30)
|
| 445 |
+
.attr("y", upArrowPos.y + 102)
|
| 446 |
+
.attr("text-anchor", "middle")
|
| 447 |
+
.style("font-family", "Century Gothic")
|
| 448 |
+
.style("font-size", "12px")
|
| 449 |
+
.style("fill", "#111111")
|
| 450 |
+
.style("opacity", 0.8)
|
| 451 |
+
.text("outperform");
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
g.append("image")
|
| 455 |
+
.attr("xlink:href", 'https://www.yczddgj.com/infographic_assets/line_graph_01_DownArrow.png')
|
| 456 |
+
.attr("width", "60")
|
| 457 |
+
.attr("height", "60")
|
| 458 |
+
.attr("x", downArrowPos.x + leftOffset)
|
| 459 |
+
.attr("y", downArrowPos.y);
|
| 460 |
+
|
| 461 |
+
// 根据文本长度计算背景宽度
|
| 462 |
+
const downLabelWidth = Math.max(groupNames[1].toUpperCase().length * 8 + 5, 100); // 每个字符10px宽度,两边各加20px边距
|
| 463 |
+
g.append("image")
|
| 464 |
+
.attr("xlink:href", 'https://www.yczddgj.com/infographic_assets/line_graph_01_DownLabelBG.png')
|
| 465 |
+
.attr("width", downLabelWidth)
|
| 466 |
+
.attr("height", "30")
|
| 467 |
+
.attr("x", downArrowPos.x + leftOffset - downLabelWidth/2 + 30)
|
| 468 |
+
.attr("y", downArrowPos.y + 65)
|
| 469 |
+
.attr("preserveAspectRatio", "none");
|
| 470 |
+
g.append("text")
|
| 471 |
+
.attr("x", downArrowPos.x + leftOffset + 30)
|
| 472 |
+
.attr("y", downArrowPos.y + 85)
|
| 473 |
+
.attr("text-anchor", "middle")
|
| 474 |
+
.style("font-family", "Century Gothic")
|
| 475 |
+
.style("font-size", "14px")
|
| 476 |
+
.style("font-weight", "bold")
|
| 477 |
+
.style("fill", "#111111")
|
| 478 |
+
.style("opacity", 0.8)
|
| 479 |
+
.text(groupNames[1].toUpperCase());
|
| 480 |
+
g.append("text")
|
| 481 |
+
.attr("x", downArrowPos.x + leftOffset + 30)
|
| 482 |
+
.attr("y", downArrowPos.y + 102)
|
| 483 |
+
.attr("text-anchor", "middle")
|
| 484 |
+
.style("font-family", "Century Gothic")
|
| 485 |
+
.style("font-size", "12px")
|
| 486 |
+
.style("fill", "#111111")
|
| 487 |
+
.style("opacity", 0.8)
|
| 488 |
+
.text("outperform");
|
| 489 |
+
|
| 490 |
+
return svg.node();
|
| 491 |
+
}
|
modules/chart_engine/template/d3-js/line_graph/step_line_graph_01.js
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Step Line Graph",
|
| 5 |
+
"chart_name": "step_line_graph_01",
|
| 6 |
+
"required_fields": ["x", "y"],
|
| 7 |
+
"required_fields_type": [["temporal"], ["numerical"]],
|
| 8 |
+
"required_fields_range": [[4, 20], [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": 800,
|
| 16 |
+
"background": "light",
|
| 17 |
+
"icon_mark": "none",
|
| 18 |
+
"icon_label": "none",
|
| 19 |
+
"has_x_axis": "yes",
|
| 20 |
+
"has_y_axis": "yes",
|
| 21 |
+
"chart_for": "trend"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = 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 chartUtilsTextSample = chartUtils.text.estimate;
|
| 34 |
+
const chartUtilsStandard = {
|
| 35 |
+
schema: chartUtils.schema,
|
| 36 |
+
format: chartUtils.format,
|
| 37 |
+
text: chartUtils.text,
|
| 38 |
+
color: chartUtils.color,
|
| 39 |
+
legendLayout: chartUtils.legend.layout,
|
| 40 |
+
legendDraw: chartUtils.legend.draw,
|
| 41 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 42 |
+
};
|
| 43 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 44 |
+
x: { fallbackIndex: 0 },
|
| 45 |
+
y: { fallbackIndex: 1 },
|
| 46 |
+
y2: { fallbackIndex: 2 },
|
| 47 |
+
y3: { fallbackIndex: 3 },
|
| 48 |
+
size: { fallbackIndex: 2 },
|
| 49 |
+
group: { fallbackIndex: 2 },
|
| 50 |
+
group2: { fallbackIndex: 3 },
|
| 51 |
+
group3: { fallbackIndex: 4 }
|
| 52 |
+
});
|
| 53 |
+
const sourceData = jsonData.data?.data || [];
|
| 54 |
+
|
| 55 |
+
d3.select(containerSelector).html("");
|
| 56 |
+
|
| 57 |
+
const xColumn = dataColumns[0] || {};
|
| 58 |
+
const yColumn = dataColumns[1] || {};
|
| 59 |
+
const xField = xColumn.name;
|
| 60 |
+
const yField = yColumn.name;
|
| 61 |
+
const readableName = value => String(value || "")
|
| 62 |
+
.replace(/_/g, " ")
|
| 63 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 64 |
+
.replace(/\s+/g, " ")
|
| 65 |
+
.trim();
|
| 66 |
+
const xLabel = readableName(xColumn.label || xColumn.name || "Time");
|
| 67 |
+
const yLabel = readableName(yColumn.label || yColumn.name || "Value");
|
| 68 |
+
const yUnit = (yColumn.unit || "").trim();
|
| 69 |
+
|
| 70 |
+
const width = Math.max(420, Number(variables.width) || 560);
|
| 71 |
+
const height = Math.max(460, Number(variables.height) || 560);
|
| 72 |
+
const fontFamily = typography.label?.font_family || typography.title?.font_family || "Arial, sans-serif";
|
| 73 |
+
const titleFontFamily = typography.title?.font_family || fontFamily;
|
| 74 |
+
const textColor = sourceColors.text_color || "#0B2B62";
|
| 75 |
+
const mutedText = "#60728A";
|
| 76 |
+
const axisColor = "#7694B6";
|
| 77 |
+
const gridColor = "#C9DAEA";
|
| 78 |
+
const primary = sourceColors.other?.primary || sourceColors.primary || "#1E71D5";
|
| 79 |
+
const secondary = sourceColors.other?.secondary || "#D76E6E";
|
| 80 |
+
const cardStroke = "#D8E7F3";
|
| 81 |
+
|
| 82 |
+
const svg = d3.select(containerSelector)
|
| 83 |
+
.append("svg")
|
| 84 |
+
.attr("width", "100%")
|
| 85 |
+
.attr("height", height)
|
| 86 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 87 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 88 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 89 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
| 90 |
+
.attr("class", "step-line-chart-root reference-layout");
|
| 91 |
+
|
| 92 |
+
if (!xField || !yField) {
|
| 93 |
+
svg.append("text")
|
| 94 |
+
.attr("x", width / 2)
|
| 95 |
+
.attr("y", height / 2)
|
| 96 |
+
.attr("text-anchor", "middle")
|
| 97 |
+
.attr("fill", textColor)
|
| 98 |
+
.style("font-family", fontFamily)
|
| 99 |
+
.style("font-size", "16px")
|
| 100 |
+
.text("Missing step-line fields");
|
| 101 |
+
return svg.node();
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const plainNumber = value => {
|
| 105 |
+
const abs = Math.abs(value);
|
| 106 |
+
const spec = abs >= 100 ? ",.0f" : abs >= 10 ? ",.1f" : abs >= 1 ? ",.2f" : ",.3f";
|
| 107 |
+
return d3.format(spec)(value).replace(/\.0$/, "").replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "");
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
const compactNumber = value => {
|
| 111 |
+
const abs = Math.abs(value);
|
| 112 |
+
const spec = abs >= 100 ? ".0f" : abs >= 10 ? ".1f" : abs >= 1 ? ".2f" : ".3f";
|
| 113 |
+
if (abs >= 1000000000) return `${d3.format(spec)(value / 1000000000)}B`;
|
| 114 |
+
if (abs >= 1000000) return `${d3.format(spec)(value / 1000000)}M`;
|
| 115 |
+
if (abs >= 1000) return `${d3.format(spec)(value / 1000)}K`;
|
| 116 |
+
return d3.format(spec)(value).replace(/\.0$/, "").replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "");
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
const formatLocalValue = value => {
|
| 120 |
+
const currencyWithSuffix = yUnit.match(/^([£$€¥])\s*([A-Za-z]+)$/);
|
| 121 |
+
if (yUnit === "%") return `${plainNumber(value)}%`;
|
| 122 |
+
if (currencyWithSuffix) return `${currencyWithSuffix[1]}${plainNumber(value)}${currencyWithSuffix[2]}`;
|
| 123 |
+
if (/^[£$€¥]$/.test(yUnit)) return `${yUnit}${compactNumber(value)}`;
|
| 124 |
+
if (yUnit) return `${compactNumber(value)} ${yUnit}`;
|
| 125 |
+
return compactNumber(value);
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const shortLabel = (value, maxChars = 22) => {
|
| 129 |
+
const clean = readableName(value);
|
| 130 |
+
if (clean.length <= maxChars) return clean;
|
| 131 |
+
const words = clean.split(/[\s/,-]+/).filter(Boolean);
|
| 132 |
+
if (words.length > 1) {
|
| 133 |
+
const firstLast = `${words[0]} ${words[words.length - 1]}`;
|
| 134 |
+
if (firstLast.length <= maxChars) return firstLast;
|
| 135 |
+
}
|
| 136 |
+
return `${clean.slice(0, Math.max(1, maxChars - 1)).trim()}.`;
|
| 137 |
+
};
|
| 138 |
+
|
| 139 |
+
const chartData = sourceData
|
| 140 |
+
.map((d, index) => ({
|
| 141 |
+
...d,
|
| 142 |
+
__sourceIndex: index,
|
| 143 |
+
__xDate: chartUtils.format.parseDate(d[xField]),
|
| 144 |
+
__xRaw: d[xField],
|
| 145 |
+
__value: Number(d[yField])
|
| 146 |
+
}))
|
| 147 |
+
.filter(d => !Number.isNaN(d.__xDate.getTime()) && Number.isFinite(d.__value))
|
| 148 |
+
.sort((a, b) => a.__xDate - b.__xDate)
|
| 149 |
+
.map((d, i) => ({ ...d, __rank: i + 1 }));
|
| 150 |
+
|
| 151 |
+
if (chartData.length < 2) {
|
| 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 |
+
const defs = svg.append("defs");
|
| 164 |
+
const bgGradient = defs.append("linearGradient")
|
| 165 |
+
.attr("id", "step-line-ref-bg")
|
| 166 |
+
.attr("x1", "0%")
|
| 167 |
+
.attr("x2", "0%")
|
| 168 |
+
.attr("y1", "0%")
|
| 169 |
+
.attr("y2", "100%");
|
| 170 |
+
bgGradient.append("stop").attr("offset", "0%").attr("stop-color", "#F7FBFF");
|
| 171 |
+
bgGradient.append("stop").attr("offset", "100%").attr("stop-color", "#EAF5FD");
|
| 172 |
+
const areaGradient = defs.append("linearGradient")
|
| 173 |
+
.attr("id", "step-line-area-fill")
|
| 174 |
+
.attr("x1", "0%")
|
| 175 |
+
.attr("x2", "0%")
|
| 176 |
+
.attr("y1", "0%")
|
| 177 |
+
.attr("y2", "100%");
|
| 178 |
+
areaGradient.append("stop").attr("offset", "0%").attr("stop-color", primary).attr("stop-opacity", 0.22);
|
| 179 |
+
areaGradient.append("stop").attr("offset", "100%").attr("stop-color", primary).attr("stop-opacity", 0.03);
|
| 180 |
+
|
| 181 |
+
const root = svg.append("g")
|
| 182 |
+
.attr("class", "step-line-reference-panel")
|
| 183 |
+
.attr("data-tag", "chart");
|
| 184 |
+
root.append("rect")
|
| 185 |
+
.attr("class", "chart-style-panel")
|
| 186 |
+
.attr("x", 2)
|
| 187 |
+
.attr("y", 2)
|
| 188 |
+
.attr("width", width - 4)
|
| 189 |
+
.attr("height", height - 4)
|
| 190 |
+
.attr("rx", 18)
|
| 191 |
+
.attr("fill", "url(#step-line-ref-bg)");
|
| 192 |
+
|
| 193 |
+
const addIconSlot = (group, cx, cy, r, className, fill, glyph) => {
|
| 194 |
+
const icon = group.append("g").attr("class", className);
|
| 195 |
+
icon.append("circle")
|
| 196 |
+
.attr("cx", cx)
|
| 197 |
+
.attr("cy", cy)
|
| 198 |
+
.attr("r", r)
|
| 199 |
+
.attr("fill", fill)
|
| 200 |
+
.attr("opacity", 0.95);
|
| 201 |
+
icon.append("text")
|
| 202 |
+
.attr("x", cx)
|
| 203 |
+
.attr("y", cy + 1)
|
| 204 |
+
.attr("text-anchor", "middle")
|
| 205 |
+
.attr("dominant-baseline", "middle")
|
| 206 |
+
.attr("fill", "#FFFFFF")
|
| 207 |
+
.style("font-family", fontFamily)
|
| 208 |
+
.style("font-size", `${Math.max(9, r * 0.72)}px`)
|
| 209 |
+
.style("font-weight", "900")
|
| 210 |
+
.text(glyph);
|
| 211 |
+
return icon;
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
const panelPad = Math.max(14, Math.min(24, width * 0.036));
|
| 215 |
+
const chipGap = 12;
|
| 216 |
+
const chipY = panelPad + 8;
|
| 217 |
+
const chipH = Math.max(54, Math.min(70, height * 0.12));
|
| 218 |
+
const chipW = (width - panelPad * 2 - chipGap) / 2;
|
| 219 |
+
const startDatum = chartData[0];
|
| 220 |
+
const endDatum = chartData[chartData.length - 1];
|
| 221 |
+
const delta = endDatum.__value - startDatum.__value;
|
| 222 |
+
const deltaText = `${delta >= 0 ? "+" : ""}${formatLocalValue(delta)}`;
|
| 223 |
+
const directionText = delta >= 0 ? "Increase" : "Decrease";
|
| 224 |
+
const formatDate = d => {
|
| 225 |
+
const xExtent = d3.extent(chartData, item => item.__xDate);
|
| 226 |
+
const dateSpanYears = (xExtent[1] - xExtent[0]) / (1000 * 60 * 60 * 24 * 365);
|
| 227 |
+
if (dateSpanYears > 2) return d3.timeFormat("%Y")(d);
|
| 228 |
+
if (dateSpanYears > 1) return `${d.getFullYear().toString().slice(-2)}Q${Math.floor(d.getMonth() / 3) + 1}`;
|
| 229 |
+
return d3.timeFormat("%b %Y")(d);
|
| 230 |
+
};
|
| 231 |
+
|
| 232 |
+
const addMetricChip = (x, color, glyph, title, value, subtitle, className) => {
|
| 233 |
+
const chip = root.append("g")
|
| 234 |
+
.attr("class", `metric-chip ${className}`)
|
| 235 |
+
.attr("transform", `translate(${x}, ${chipY})`);
|
| 236 |
+
chip.append("rect")
|
| 237 |
+
.attr("width", chipW)
|
| 238 |
+
.attr("height", chipH)
|
| 239 |
+
.attr("rx", 14)
|
| 240 |
+
.attr("fill", "#FFFFFF")
|
| 241 |
+
.attr("stroke", cardStroke)
|
| 242 |
+
.attr("stroke-width", 1);
|
| 243 |
+
addIconSlot(chip, 26, chipH / 2, 16, "metric-icon-slot decorative-icon-slot", color, glyph);
|
| 244 |
+
chip.append("text")
|
| 245 |
+
.attr("class", "metric-title")
|
| 246 |
+
.attr("x", 50)
|
| 247 |
+
.attr("y", 19)
|
| 248 |
+
.attr("fill", color)
|
| 249 |
+
.style("font-family", titleFontFamily)
|
| 250 |
+
.style("font-size", "9.5px")
|
| 251 |
+
.style("font-weight", "900")
|
| 252 |
+
.text(title);
|
| 253 |
+
chip.append("text")
|
| 254 |
+
.attr("class", "metric-value")
|
| 255 |
+
.attr("x", 50)
|
| 256 |
+
.attr("y", 39)
|
| 257 |
+
.attr("fill", textColor)
|
| 258 |
+
.style("font-family", titleFontFamily)
|
| 259 |
+
.style("font-size", "15px")
|
| 260 |
+
.style("font-weight", "900")
|
| 261 |
+
.text(shortLabel(value, Math.max(8, Math.floor((chipW - 58) / 7.5))));
|
| 262 |
+
chip.append("text")
|
| 263 |
+
.attr("class", "metric-subtitle")
|
| 264 |
+
.attr("x", 50)
|
| 265 |
+
.attr("y", chipH - 9)
|
| 266 |
+
.attr("fill", mutedText)
|
| 267 |
+
.style("font-family", fontFamily)
|
| 268 |
+
.style("font-size", "8.5px")
|
| 269 |
+
.style("font-weight", "650")
|
| 270 |
+
.text(shortLabel(subtitle, Math.max(10, Math.floor((chipW - 58) / 5.2))));
|
| 271 |
+
};
|
| 272 |
+
addMetricChip(panelPad, primary, "1", "START", formatLocalValue(startDatum.__value), formatDate(startDatum.__xDate), "start-metric-chip");
|
| 273 |
+
addMetricChip(panelPad + chipW + chipGap, secondary, "2", "LATEST", formatLocalValue(endDatum.__value), formatDate(endDatum.__xDate), "end-metric-chip");
|
| 274 |
+
|
| 275 |
+
const footerH = Math.max(74, Math.min(96, height * 0.17));
|
| 276 |
+
const footerY = height - footerH - panelPad;
|
| 277 |
+
const plotX = Math.max(52, width * 0.12);
|
| 278 |
+
const plotY = chipY + chipH + Math.max(26, height * 0.045);
|
| 279 |
+
const plotW = width - plotX - Math.max(24, width * 0.055);
|
| 280 |
+
const plotH = Math.max(190, footerY - plotY - 56);
|
| 281 |
+
|
| 282 |
+
const xExtent = d3.extent(chartData, d => d.__xDate);
|
| 283 |
+
if (+xExtent[0] === +xExtent[1]) {
|
| 284 |
+
xExtent[0] = d3.timeYear.offset(xExtent[0], -1);
|
| 285 |
+
xExtent[1] = d3.timeYear.offset(xExtent[1], 1);
|
| 286 |
+
}
|
| 287 |
+
const xScale = d3.scaleTime()
|
| 288 |
+
.domain(xExtent)
|
| 289 |
+
.range([0, plotW]);
|
| 290 |
+
|
| 291 |
+
const yMin = d3.min(chartData, d => d.__value);
|
| 292 |
+
const yMax = d3.max(chartData, d => d.__value);
|
| 293 |
+
const ySpan = yMax - yMin;
|
| 294 |
+
const yPadding = ySpan > 0 ? ySpan * 0.16 : Math.max(Math.abs(yMax) * 0.1, 1);
|
| 295 |
+
let yDomainMin = yMin - yPadding;
|
| 296 |
+
if (yMin >= 0 && yDomainMin < 0) yDomainMin = 0;
|
| 297 |
+
const yScale = d3.scaleLinear()
|
| 298 |
+
.domain([yDomainMin, yMax + yPadding])
|
| 299 |
+
.range([plotH, 0])
|
| 300 |
+
.nice(5);
|
| 301 |
+
|
| 302 |
+
const maxXTicks = Math.max(3, Math.min(6, Math.floor(plotW / 85)));
|
| 303 |
+
const selectXTicks = values => {
|
| 304 |
+
const step = Math.max(1, Math.ceil(values.length / maxXTicks));
|
| 305 |
+
const selected = values.filter((_, i) => i % step === 0);
|
| 306 |
+
if (+selected[0] !== +values[0]) selected.unshift(values[0]);
|
| 307 |
+
if (+selected[selected.length - 1] !== +values[values.length - 1]) selected.push(values[values.length - 1]);
|
| 308 |
+
const seen = new Set();
|
| 309 |
+
return selected.filter(d => {
|
| 310 |
+
const key = formatDate(d);
|
| 311 |
+
if (seen.has(key)) return false;
|
| 312 |
+
seen.add(key);
|
| 313 |
+
return true;
|
| 314 |
+
});
|
| 315 |
+
};
|
| 316 |
+
const uniqueYTicks = ticks => {
|
| 317 |
+
const seen = new Set();
|
| 318 |
+
return ticks.filter(tick => {
|
| 319 |
+
const key = formatLocalValue(tick);
|
| 320 |
+
if (seen.has(key)) return false;
|
| 321 |
+
seen.add(key);
|
| 322 |
+
return true;
|
| 323 |
+
});
|
| 324 |
+
};
|
| 325 |
+
const xTicks = selectXTicks(chartData.map(d => d.__xDate));
|
| 326 |
+
const yTicks = uniqueYTicks(yScale.ticks(5));
|
| 327 |
+
|
| 328 |
+
const plot = root.append("g")
|
| 329 |
+
.attr("class", "plot-area step-line-plot-area")
|
| 330 |
+
.attr("transform", `translate(${plotX}, ${plotY})`);
|
| 331 |
+
plot.append("rect")
|
| 332 |
+
.attr("class", "plot-card chart-panel-background")
|
| 333 |
+
.attr("x", -12)
|
| 334 |
+
.attr("y", -12)
|
| 335 |
+
.attr("width", plotW + 24)
|
| 336 |
+
.attr("height", plotH + 24)
|
| 337 |
+
.attr("rx", 15)
|
| 338 |
+
.attr("fill", "#FFFFFF")
|
| 339 |
+
.attr("opacity", 0.78)
|
| 340 |
+
.attr("stroke", cardStroke);
|
| 341 |
+
|
| 342 |
+
yTicks.forEach(tick => {
|
| 343 |
+
const y = yScale(tick);
|
| 344 |
+
plot.append("line")
|
| 345 |
+
.attr("class", "gridline y-gridline")
|
| 346 |
+
.attr("x1", 0)
|
| 347 |
+
.attr("y1", y)
|
| 348 |
+
.attr("x2", plotW)
|
| 349 |
+
.attr("y2", y)
|
| 350 |
+
.attr("stroke", gridColor)
|
| 351 |
+
.attr("stroke-width", tick === 0 ? 1.3 : 0.9)
|
| 352 |
+
.attr("stroke-dasharray", "3 3")
|
| 353 |
+
.attr("opacity", 0.85);
|
| 354 |
+
plot.append("text")
|
| 355 |
+
.attr("class", "axis-tick y-axis-tick")
|
| 356 |
+
.attr("x", -10)
|
| 357 |
+
.attr("y", y)
|
| 358 |
+
.attr("text-anchor", "end")
|
| 359 |
+
.attr("dominant-baseline", "middle")
|
| 360 |
+
.attr("fill", mutedText)
|
| 361 |
+
.style("font-family", fontFamily)
|
| 362 |
+
.style("font-size", "9px")
|
| 363 |
+
.style("font-weight", "650")
|
| 364 |
+
.text(formatLocalValue(tick));
|
| 365 |
+
});
|
| 366 |
+
|
| 367 |
+
xTicks.forEach(tick => {
|
| 368 |
+
const x = xScale(tick);
|
| 369 |
+
plot.append("line")
|
| 370 |
+
.attr("class", "gridline x-gridline")
|
| 371 |
+
.attr("x1", x)
|
| 372 |
+
.attr("y1", 0)
|
| 373 |
+
.attr("x2", x)
|
| 374 |
+
.attr("y2", plotH)
|
| 375 |
+
.attr("stroke", gridColor)
|
| 376 |
+
.attr("stroke-width", 0.9)
|
| 377 |
+
.attr("stroke-dasharray", "3 3")
|
| 378 |
+
.attr("opacity", 0.55);
|
| 379 |
+
plot.append("text")
|
| 380 |
+
.attr("class", "axis-tick x-axis-tick")
|
| 381 |
+
.attr("x", x)
|
| 382 |
+
.attr("y", plotH + 23)
|
| 383 |
+
.attr("text-anchor", "middle")
|
| 384 |
+
.attr("fill", mutedText)
|
| 385 |
+
.style("font-family", fontFamily)
|
| 386 |
+
.style("font-size", "9px")
|
| 387 |
+
.style("font-weight", "650")
|
| 388 |
+
.text(formatDate(tick));
|
| 389 |
+
});
|
| 390 |
+
|
| 391 |
+
plot.append("line")
|
| 392 |
+
.attr("class", "axis x-axis-line")
|
| 393 |
+
.attr("x1", 0)
|
| 394 |
+
.attr("y1", plotH)
|
| 395 |
+
.attr("x2", plotW)
|
| 396 |
+
.attr("y2", plotH)
|
| 397 |
+
.attr("stroke", axisColor)
|
| 398 |
+
.attr("stroke-width", 1.1);
|
| 399 |
+
plot.append("line")
|
| 400 |
+
.attr("class", "axis y-axis-line")
|
| 401 |
+
.attr("x1", 0)
|
| 402 |
+
.attr("y1", 0)
|
| 403 |
+
.attr("x2", 0)
|
| 404 |
+
.attr("y2", plotH)
|
| 405 |
+
.attr("stroke", axisColor)
|
| 406 |
+
.attr("stroke-width", 1.1);
|
| 407 |
+
|
| 408 |
+
const line = d3.line()
|
| 409 |
+
.x(d => xScale(d.__xDate))
|
| 410 |
+
.y(d => yScale(d.__value))
|
| 411 |
+
.curve(d3.curveStepAfter);
|
| 412 |
+
const area = d3.area()
|
| 413 |
+
.x(d => xScale(d.__xDate))
|
| 414 |
+
.y0(plotH)
|
| 415 |
+
.y1(d => yScale(d.__value))
|
| 416 |
+
.curve(d3.curveStepAfter);
|
| 417 |
+
|
| 418 |
+
plot.append("path")
|
| 419 |
+
.datum(chartData)
|
| 420 |
+
.attr("class", "step-line-area")
|
| 421 |
+
.attr("fill", "url(#step-line-area-fill)")
|
| 422 |
+
.attr("d", area);
|
| 423 |
+
plot.append("path")
|
| 424 |
+
.datum(chartData)
|
| 425 |
+
.attr("class", "mark step-line-path")
|
| 426 |
+
.attr("data-point-count", chartData.length)
|
| 427 |
+
.attr("fill", "none")
|
| 428 |
+
.attr("stroke", primary)
|
| 429 |
+
.attr("stroke-width", 3.4)
|
| 430 |
+
.attr("stroke-linejoin", "round")
|
| 431 |
+
.attr("stroke-linecap", "round")
|
| 432 |
+
.attr("d", line);
|
| 433 |
+
|
| 434 |
+
const pointGroups = plot.append("g")
|
| 435 |
+
.attr("class", "step-line-point-layer")
|
| 436 |
+
.selectAll(".data-point")
|
| 437 |
+
.data(chartData)
|
| 438 |
+
.enter()
|
| 439 |
+
.append("g")
|
| 440 |
+
.attr("class", "data-point")
|
| 441 |
+
.attr("data-date", d => d.__xDate.toISOString())
|
| 442 |
+
.attr("data-raw-x", d => d.__xRaw)
|
| 443 |
+
.attr("data-value", d => d.__value)
|
| 444 |
+
.attr("data-source-index", d => d.__sourceIndex)
|
| 445 |
+
.attr("data-rank", d => d.__rank)
|
| 446 |
+
.attr("transform", d => `translate(${xScale(d.__xDate)}, ${yScale(d.__value)})`);
|
| 447 |
+
|
| 448 |
+
pointGroups.append("circle")
|
| 449 |
+
.attr("class", d => `mark step-line-point${d.__rank === 1 || d.__rank === chartData.length ? " endpoint-point" : ""}`)
|
| 450 |
+
.attr("data-date", d => d.__xDate.toISOString())
|
| 451 |
+
.attr("data-raw-x", d => d.__xRaw)
|
| 452 |
+
.attr("data-value", d => d.__value)
|
| 453 |
+
.attr("data-source-index", d => d.__sourceIndex)
|
| 454 |
+
.attr("data-rank", d => d.__rank)
|
| 455 |
+
.attr("r", d => d.__rank === 1 || d.__rank === chartData.length ? 5.2 : 3.5)
|
| 456 |
+
.attr("fill", d => d.__rank === 1 || d.__rank === chartData.length ? "#FFFFFF" : primary)
|
| 457 |
+
.attr("stroke", primary)
|
| 458 |
+
.attr("stroke-width", d => d.__rank === 1 || d.__rank === chartData.length ? 2.5 : 1.8);
|
| 459 |
+
|
| 460 |
+
const drawEndpointLabel = (d, side) => {
|
| 461 |
+
const x = xScale(d.__xDate);
|
| 462 |
+
const y = yScale(d.__value);
|
| 463 |
+
const label = formatLocalValue(d.__value);
|
| 464 |
+
const labelWidth = Math.max(50, Math.min(94, label.length * 6.7 + 19));
|
| 465 |
+
const labelHeight = 24;
|
| 466 |
+
const isLeft = side === "left";
|
| 467 |
+
const preferredX = isLeft ? x + 11 : x - labelWidth - 11;
|
| 468 |
+
const labelX = Math.max(4, Math.min(plotW - labelWidth - 4, preferredX));
|
| 469 |
+
const labelY = Math.max(4, Math.min(plotH - labelHeight - 4, y - labelHeight / 2));
|
| 470 |
+
const labelGroup = plot.append("g")
|
| 471 |
+
.attr("class", `endpoint-label ${isLeft ? "start" : "end"}-endpoint-label`)
|
| 472 |
+
.attr("data-date", d.__xDate.toISOString())
|
| 473 |
+
.attr("data-raw-x", d.__xRaw)
|
| 474 |
+
.attr("data-value", d.__value)
|
| 475 |
+
.attr("data-source-index", d.__sourceIndex)
|
| 476 |
+
.attr("data-rank", d.__rank);
|
| 477 |
+
labelGroup.append("line")
|
| 478 |
+
.attr("class", "endpoint-label-leader")
|
| 479 |
+
.attr("x1", x)
|
| 480 |
+
.attr("y1", y)
|
| 481 |
+
.attr("x2", isLeft ? labelX : labelX + labelWidth)
|
| 482 |
+
.attr("y2", labelY + labelHeight / 2)
|
| 483 |
+
.attr("stroke", primary)
|
| 484 |
+
.attr("stroke-width", 1)
|
| 485 |
+
.attr("opacity", 0.52);
|
| 486 |
+
labelGroup.append("rect")
|
| 487 |
+
.attr("class", "endpoint-label-card")
|
| 488 |
+
.attr("x", labelX)
|
| 489 |
+
.attr("y", labelY)
|
| 490 |
+
.attr("width", labelWidth)
|
| 491 |
+
.attr("height", labelHeight)
|
| 492 |
+
.attr("rx", 8)
|
| 493 |
+
.attr("fill", "#FFFFFF")
|
| 494 |
+
.attr("stroke", primary)
|
| 495 |
+
.attr("stroke-width", 1);
|
| 496 |
+
labelGroup.append("text")
|
| 497 |
+
.attr("class", "endpoint-value-label value-label")
|
| 498 |
+
.attr("x", labelX + labelWidth / 2)
|
| 499 |
+
.attr("y", labelY + labelHeight / 2)
|
| 500 |
+
.attr("text-anchor", "middle")
|
| 501 |
+
.attr("dominant-baseline", "middle")
|
| 502 |
+
.attr("fill", textColor)
|
| 503 |
+
.style("font-family", fontFamily)
|
| 504 |
+
.style("font-size", "10px")
|
| 505 |
+
.style("font-weight", "900")
|
| 506 |
+
.text(label);
|
| 507 |
+
};
|
| 508 |
+
drawEndpointLabel(startDatum, "left");
|
| 509 |
+
drawEndpointLabel(endDatum, "right");
|
| 510 |
+
|
| 511 |
+
plot.append("text")
|
| 512 |
+
.attr("class", "axis-title y-axis-title")
|
| 513 |
+
.attr("x", -4)
|
| 514 |
+
.attr("y", -20)
|
| 515 |
+
.attr("text-anchor", "start")
|
| 516 |
+
.attr("fill", textColor)
|
| 517 |
+
.style("font-family", titleFontFamily)
|
| 518 |
+
.style("font-size", "10.5px")
|
| 519 |
+
.style("font-weight", "900")
|
| 520 |
+
.text(shortLabel(yUnit ? `${yLabel} (${yUnit})` : yLabel, Math.max(16, Math.floor(plotW / 15))));
|
| 521 |
+
plot.append("text")
|
| 522 |
+
.attr("class", "axis-title x-axis-title")
|
| 523 |
+
.attr("x", plotW)
|
| 524 |
+
.attr("y", plotH + 43)
|
| 525 |
+
.attr("text-anchor", "end")
|
| 526 |
+
.attr("fill", mutedText)
|
| 527 |
+
.style("font-family", fontFamily)
|
| 528 |
+
.style("font-size", "10px")
|
| 529 |
+
.style("font-weight", "700")
|
| 530 |
+
.text(shortLabel(xLabel, Math.max(12, Math.floor(plotW / 17))));
|
| 531 |
+
|
| 532 |
+
const legend = root.append("g")
|
| 533 |
+
.attr("class", "series-legend")
|
| 534 |
+
.attr("transform", `translate(${plotX}, ${plotY - 24})`);
|
| 535 |
+
legend.append("line")
|
| 536 |
+
.attr("x1", 0)
|
| 537 |
+
.attr("y1", 0)
|
| 538 |
+
.attr("x2", 30)
|
| 539 |
+
.attr("y2", 0)
|
| 540 |
+
.attr("stroke", primary)
|
| 541 |
+
.attr("stroke-width", 3.4)
|
| 542 |
+
.attr("stroke-linecap", "round");
|
| 543 |
+
legend.append("text")
|
| 544 |
+
.attr("x", 39)
|
| 545 |
+
.attr("y", 1)
|
| 546 |
+
.attr("dominant-baseline", "middle")
|
| 547 |
+
.attr("fill", textColor)
|
| 548 |
+
.style("font-family", fontFamily)
|
| 549 |
+
.style("font-size", "10px")
|
| 550 |
+
.style("font-weight", "800")
|
| 551 |
+
.text(shortLabel(yLabel, Math.max(14, Math.floor((plotW - 42) / 6))));
|
| 552 |
+
|
| 553 |
+
const footerGap = 12;
|
| 554 |
+
const footerCardW = (width - panelPad * 2 - footerGap) / 2;
|
| 555 |
+
const addFooterCard = (x, color, glyph, title, value, className) => {
|
| 556 |
+
const card = root.append("g")
|
| 557 |
+
.attr("class", `footer-note-card ${className}`)
|
| 558 |
+
.attr("transform", `translate(${x}, ${footerY})`);
|
| 559 |
+
card.append("rect")
|
| 560 |
+
.attr("width", footerCardW)
|
| 561 |
+
.attr("height", footerH)
|
| 562 |
+
.attr("rx", 15)
|
| 563 |
+
.attr("fill", "#FFFFFF")
|
| 564 |
+
.attr("opacity", 0.82)
|
| 565 |
+
.attr("stroke", cardStroke);
|
| 566 |
+
addIconSlot(card, 25, footerH / 2, 15, "footer-icon-slot decorative-icon-slot", color, glyph);
|
| 567 |
+
card.append("text")
|
| 568 |
+
.attr("class", "footer-title")
|
| 569 |
+
.attr("x", 50)
|
| 570 |
+
.attr("y", footerH / 2 - 8)
|
| 571 |
+
.attr("fill", color)
|
| 572 |
+
.style("font-family", titleFontFamily)
|
| 573 |
+
.style("font-size", "9px")
|
| 574 |
+
.style("font-weight", "900")
|
| 575 |
+
.text(title);
|
| 576 |
+
card.append("text")
|
| 577 |
+
.attr("class", "footer-value")
|
| 578 |
+
.attr("x", 50)
|
| 579 |
+
.attr("y", footerH / 2 + 12)
|
| 580 |
+
.attr("fill", textColor)
|
| 581 |
+
.style("font-family", fontFamily)
|
| 582 |
+
.style("font-size", "10px")
|
| 583 |
+
.style("font-weight", "800")
|
| 584 |
+
.text(shortLabel(value, Math.max(12, Math.floor((footerCardW - 58) / 5.3))));
|
| 585 |
+
};
|
| 586 |
+
addFooterCard(panelPad, secondary, "D", "CHANGE", `${directionText} ${deltaText}`, "change-card");
|
| 587 |
+
addFooterCard(panelPad + footerCardW + footerGap, primary, "T", "PERIOD", `${formatDate(startDatum.__xDate)} to ${formatDate(endDatum.__xDate)}`, "period-card");
|
| 588 |
+
|
| 589 |
+
return svg.node();
|
| 590 |
+
}
|