2026-06-22 10:48:35 +08:00
|
|
|
/**
|
|
|
|
|
* SVG 图表渲染模块。
|
|
|
|
|
* 所有图表都返回字符串,由视图层直接写入 chart-box;交互点通过 data-enterprise/data-project 交给事件代理处理。
|
|
|
|
|
*/
|
|
|
|
|
function renderGroupedRateChart(list, mode, options = {}) {
|
|
|
|
|
const width = Math.max(1180, list.length * 88 + 120);
|
|
|
|
|
const height = options.height || 350;
|
|
|
|
|
const margin = { top: 20, right: 30, bottom: height < 240 ? 58 : 98, left: 54 };
|
|
|
|
|
const chartHeight = height - margin.top - margin.bottom;
|
|
|
|
|
const chartWidth = width - margin.left - margin.right;
|
|
|
|
|
const groupWidth = chartWidth / Math.max(list.length, 1);
|
|
|
|
|
const barWidth = Math.min(height < 240 ? 12 : 16, groupWidth / 5);
|
|
|
|
|
const series = [
|
|
|
|
|
{ key: "onlineRate", label: "上线", color: COLORS.online },
|
|
|
|
|
{ key: "usageRate", label: "使用", color: COLORS.usage },
|
|
|
|
|
{ key: "accuracyRate", label: "准确", color: COLORS.accuracy }
|
|
|
|
|
];
|
|
|
|
|
const grid = [0, 25, 50, 75, 100].map((tick) => {
|
|
|
|
|
const y = margin.top + chartHeight - (tick / 100) * chartHeight;
|
|
|
|
|
return `<line class="grid-line" x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}"></line><text class="axis-label" x="${margin.left - 10}" y="${y + 4}" text-anchor="end">${tick}%</text>`;
|
|
|
|
|
}).join("");
|
|
|
|
|
|
|
|
|
|
const bars = list.map((enterprise, index) => {
|
|
|
|
|
const stats = enterprise.stats[state.dimension];
|
|
|
|
|
const center = margin.left + groupWidth * index + groupWidth / 2;
|
|
|
|
|
const label = enterprise.name;
|
|
|
|
|
const rects = series.map((item, seriesIndex) => {
|
|
|
|
|
const value = stats[item.key];
|
|
|
|
|
const barHeight = (value / 100) * chartHeight;
|
|
|
|
|
const x = center - barWidth * 1.8 + seriesIndex * barWidth * 1.8;
|
|
|
|
|
const y = margin.top + chartHeight - barHeight;
|
|
|
|
|
return `
|
|
|
|
|
<rect class="chart-click" data-enterprise="${enterprise.name}" x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="5" fill="${item.color}" opacity="0.92"></rect>
|
|
|
|
|
<text class="bar-label" x="${x + barWidth / 2}" y="${Math.max(y - 7, 14)}" text-anchor="middle">${Math.round(value)}</text>
|
|
|
|
|
`;
|
|
|
|
|
}).join("");
|
|
|
|
|
return `
|
|
|
|
|
${rects}
|
|
|
|
|
<text class="axis-label chart-click" data-enterprise="${enterprise.name}" x="${center}" y="${height - 20}" transform="rotate(-28 ${center} ${height - 20})" text-anchor="end">${label}</text>
|
|
|
|
|
`;
|
|
|
|
|
}).join("");
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${mode === "enterprise" ? "企业三率对比图" : "三率对比图"}">
|
|
|
|
|
${grid}
|
|
|
|
|
<line class="axis" x1="${margin.left}" y1="${margin.top + chartHeight}" x2="${width - margin.right}" y2="${margin.top + chartHeight}"></line>
|
|
|
|
|
${bars}
|
|
|
|
|
</svg>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 项目使用率/准确率柱状图。
|
|
|
|
|
* 柱子用于进入项目明细,底部圆点表达项目是否已上线,便于在企业层同时查看使用和上线状态。
|
|
|
|
|
*/
|
|
|
|
|
function renderProjectRateChart(projects, options = {}) {
|
|
|
|
|
const width = Math.max(1100, projects.length * 126 + 120);
|
|
|
|
|
const height = options.height || 350;
|
|
|
|
|
const margin = { top: 20, right: 30, bottom: height < 260 ? 66 : 98, left: 54 };
|
|
|
|
|
const chartHeight = height - margin.top - margin.bottom;
|
|
|
|
|
const chartWidth = width - margin.left - margin.right;
|
|
|
|
|
const groupWidth = chartWidth / Math.max(projects.length, 1);
|
|
|
|
|
const barWidth = Math.min(20, groupWidth / 4);
|
|
|
|
|
const series = [
|
|
|
|
|
{ key: "usageRate", color: COLORS.usage },
|
|
|
|
|
{ key: "accuracyRate", color: COLORS.accuracy }
|
|
|
|
|
];
|
|
|
|
|
const grid = [0, 25, 50, 75, 100].map((tick) => {
|
|
|
|
|
const y = margin.top + chartHeight - (tick / 100) * chartHeight;
|
|
|
|
|
return `<line class="grid-line" x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}"></line><text class="axis-label" x="${margin.left - 10}" y="${y + 4}" text-anchor="end">${tick}%</text>`;
|
|
|
|
|
}).join("");
|
|
|
|
|
|
|
|
|
|
const bars = projects.map((project, index) => {
|
|
|
|
|
const center = margin.left + groupWidth * index + groupWidth / 2;
|
|
|
|
|
const shortName = project.name.replace(project.unit, "").replace("项目", "");
|
|
|
|
|
const rects = series.map((item, seriesIndex) => {
|
|
|
|
|
const value = project[item.key];
|
|
|
|
|
const barHeight = (value / 100) * chartHeight;
|
|
|
|
|
const x = center - barWidth * 1.05 + seriesIndex * barWidth * 2.1;
|
|
|
|
|
const y = margin.top + chartHeight - barHeight;
|
|
|
|
|
return `
|
2026-07-12 00:53:38 +08:00
|
|
|
<rect class="chart-click" data-project="${project.id}" data-project-unit-id="${escapeHtmlAttr(project.unitId || "")}" x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="5" fill="${item.color}" opacity="0.92"></rect>
|
2026-06-22 10:48:35 +08:00
|
|
|
<text class="bar-label" x="${x + barWidth / 2}" y="${Math.max(y - 7, 14)}" text-anchor="middle">${Math.round(value)}</text>
|
|
|
|
|
`;
|
|
|
|
|
}).join("");
|
|
|
|
|
const statusY = height - (height < 260 ? 48 : 78);
|
|
|
|
|
const statusColor = project.created ? COLORS.cyan : COLORS.warning;
|
|
|
|
|
const statusText = project.created ? "已上线" : "未上线";
|
|
|
|
|
return `
|
|
|
|
|
${rects}
|
2026-07-12 00:53:38 +08:00
|
|
|
<circle class="chart-click" data-project="${project.id}" data-project-unit-id="${escapeHtmlAttr(project.unitId || "")}" cx="${center - 18}" cy="${statusY}" r="5" fill="${statusColor}"></circle>
|
|
|
|
|
<text class="axis-label chart-click" data-project="${project.id}" data-project-unit-id="${escapeHtmlAttr(project.unitId || "")}" x="${center - 8}" y="${statusY + 4}" text-anchor="start">${statusText}</text>
|
|
|
|
|
<text class="axis-label chart-click" data-project="${project.id}" data-project-unit-id="${escapeHtmlAttr(project.unitId || "")}" x="${center}" y="${height - 24}" transform="rotate(-30 ${center} ${height - 24})" text-anchor="end">${shortName}</text>
|
2026-06-22 10:48:35 +08:00
|
|
|
`;
|
|
|
|
|
}).join("");
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="项目使用率与准确率对比图">
|
|
|
|
|
${grid}
|
|
|
|
|
<line class="axis" x1="${margin.left}" y1="${margin.top + chartHeight}" x2="${width - margin.right}" y2="${margin.top + chartHeight}"></line>
|
|
|
|
|
${bars}
|
|
|
|
|
</svg>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 集团三率趋势图。
|
|
|
|
|
* 先把企业趋势压成集团趋势,再复用通用折线图渲染方法。
|
|
|
|
|
*/
|
|
|
|
|
function renderGroupTrendChart(list) {
|
2026-07-12 00:53:38 +08:00
|
|
|
const trendCurve = projectMasterDataState.trendCurve;
|
|
|
|
|
if (trendCurve && Array.isArray(trendCurve.Date) && trendCurve.Date.length) {
|
|
|
|
|
return renderTrendChart({
|
|
|
|
|
date: trendCurve.Date,
|
|
|
|
|
online: trendCurve.OnlineRate || [],
|
|
|
|
|
usage: trendCurve.UsageRate || [],
|
|
|
|
|
accuracy: trendCurve.AccuracyRate || []
|
|
|
|
|
}, 1840, 112);
|
|
|
|
|
}
|
|
|
|
|
return '<div class="empty-state compact-empty">暂无趋势数据</div>';
|
2026-06-22 10:48:35 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 通用三率折线图。
|
|
|
|
|
* data 需要包含 online/usage/accuracy 三组七日数组,适用于集团趋势和企业趋势。
|
|
|
|
|
*/
|
|
|
|
|
function renderTrendChart(data, width = 720, height = 250) {
|
|
|
|
|
const margin = { top: 14, right: 28, bottom: 28, left: 46 };
|
|
|
|
|
const chartWidth = width - margin.left - margin.right;
|
|
|
|
|
const chartHeight = height - margin.top - margin.bottom;
|
2026-07-12 00:53:38 +08:00
|
|
|
const dates = data.date || ["06-26", "06-27", "06-28", "06-29", "06-30", "07-01", "07-02"];
|
2026-06-22 10:48:35 +08:00
|
|
|
const series = [
|
|
|
|
|
{ key: "online", label: "上线", color: COLORS.online },
|
|
|
|
|
{ key: "usage", label: "使用", color: COLORS.usage },
|
|
|
|
|
{ key: "accuracy", label: "准确", color: COLORS.accuracy }
|
|
|
|
|
];
|
|
|
|
|
const grid = [0, 25, 50, 75, 100].map((tick) => {
|
|
|
|
|
const y = margin.top + chartHeight - (tick / 100) * chartHeight;
|
|
|
|
|
return `<line class="grid-line" x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}"></line><text class="axis-label" x="${margin.left - 8}" y="${y + 4}" text-anchor="end">${tick}%</text>`;
|
|
|
|
|
}).join("");
|
|
|
|
|
const lines = series.map((item) => {
|
|
|
|
|
const values = data[item.key] || [];
|
|
|
|
|
const points = values.map((value, index) => {
|
|
|
|
|
const x = margin.left + (chartWidth / (values.length - 1)) * index;
|
|
|
|
|
const y = margin.top + chartHeight - (value / 100) * chartHeight;
|
|
|
|
|
return `${x},${y}`;
|
|
|
|
|
}).join(" ");
|
|
|
|
|
const circles = values.map((value, index) => {
|
|
|
|
|
const x = margin.left + (chartWidth / (values.length - 1)) * index;
|
|
|
|
|
const y = margin.top + chartHeight - (value / 100) * chartHeight;
|
|
|
|
|
return `<circle cx="${x}" cy="${y}" r="4" fill="${item.color}"></circle>`;
|
|
|
|
|
}).join("");
|
|
|
|
|
return `<polyline points="${points}" fill="none" stroke="${item.color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"></polyline>${circles}`;
|
|
|
|
|
}).join("");
|
|
|
|
|
const labels = dates.map((date, index) => {
|
|
|
|
|
const x = margin.left + (chartWidth / (dates.length - 1)) * index;
|
|
|
|
|
return `<text class="axis-label" x="${x}" y="${height - 10}" text-anchor="middle">${date}</text>`;
|
|
|
|
|
}).join("");
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="三率趋势曲线">
|
|
|
|
|
${grid}
|
|
|
|
|
<line class="axis" x1="${margin.left}" y1="${margin.top + chartHeight}" x2="${width - margin.right}" y2="${margin.top + chartHeight}"></line>
|
|
|
|
|
${lines}
|
|
|
|
|
${labels}
|
|
|
|
|
</svg>
|
|
|
|
|
`;
|
|
|
|
|
}
|