fix: 折线图纵轴优化——默认约80%头空间、取整到nice刻度,支持手动设置纵轴上限

- 纵轴自动刻度改为精细步进(1/1.5/2/2.5/3/4/5/6/8/10),数据最大值落在约80%处
- ChartInput 新增 yMax 字段(JSON 存储、可拓展),图表弹窗新增「纵轴上限」输入
- 新增 yMax 往返测试
This commit is contained in:
MobKBK
2026-09-04 15:07:10 +08:00
parent 8340d1dbbd
commit ff10e7f065
4 changed files with 28 additions and 6 deletions

View File

@@ -109,20 +109,32 @@ function periodText(task) { const metrics = task.metrics || []; return [...new S
function summaryFor(taskId) { return state.summary?.tasks?.[taskId] || null; }
function metricSummary(taskId, metricId) { return summaryFor(taskId)?.metrics?.find((metric) => metric.metricId === metricId) || null; }
function niceCeil(value) {
if (!(value > 0)) return 1;
const magnitude = Math.pow(10, Math.floor(Math.log10(value)));
const steps = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10];
for (const step of steps) {
const candidate = step * magnitude;
if (value <= candidate) return Number(candidate.toFixed(6));
}
return magnitude * 10;
}
function renderChartSvg(chart) {
const points = chart.data?.points || [];
if (!points.length || points.every((point) => Number(point.value) === 0)) return `<div class="chart-empty">暂无数据</div>`;
const width = 600, height = 200, pad = { left: 34, right: 12, top: 14, bottom: 30 };
const max = Math.max(...points.map((point) => Number(point.value) || 0), 1);
const width = 600, height = 200, pad = { left: 34, right: 12, top: 16, bottom: 30 };
const dataMax = Math.max(...points.map((point) => Number(point.value) || 0), 1);
const scaleMax = chart.yMax ? Math.max(Number(chart.yMax), dataMax) : niceCeil(dataMax * 1.25);
const x = (index) => pad.left + (points.length === 1 ? 0 : index * (width - pad.left - pad.right) / (points.length - 1));
const y = (value) => pad.top + (height - pad.top - pad.bottom) * (1 - value / max);
const y = (value) => pad.top + (height - pad.top - pad.bottom) * (1 - value / scaleMax);
const coords = points.map((point, index) => `${x(index).toFixed(1)},${y(Number(point.value) || 0).toFixed(1)}`).join(" ");
const areaPath = `M ${pad.left.toFixed(1)} ${y(0).toFixed(1)} L ${coords.replace(/ /g, " L ")} L ${x(points.length - 1).toFixed(1)} ${y(0).toFixed(1)} Z`;
const labels = [points[0], points[Math.floor((points.length - 1) / 2)], points[points.length - 1]].filter((point, index, list) => point && list.findIndex((item) => item.date === point.date) === index).map((point) => {
const index = points.indexOf(point); return `<text x="${x(index)}" y="${height - 7}" text-anchor="middle">${escapeHtml(point.date.slice(5))}</text>`;
}).join("");
const circles = points.map((point, index) => `<circle cx="${x(index).toFixed(1)}" cy="${y(Number(point.value) || 0).toFixed(1)}" r="2.2"/>`).join("");
return `<svg class="chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escapeHtml(chart.title)}"><line class="chart-guide" x1="${pad.left}" y1="${y(0)}" x2="${width - pad.right}" y2="${y(0)}"/><line class="chart-guide" x1="${pad.left}" y1="${y(max)}" x2="${width - pad.right}" y2="${y(max)}"/><text class="chart-max" x="4" y="${y(max) + 4}">${max}</text><path class="chart-area" d="${areaPath}"/><polyline points="${coords}"/><g class="chart-points">${circles}</g><g class="chart-labels">${labels}</g></svg>`;
return `<svg class="chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escapeHtml(chart.title)}"><line class="chart-guide" x1="${pad.left}" y1="${y(0)}" x2="${width - pad.right}" y2="${y(0)}"/><line class="chart-guide" x1="${pad.left}" y1="${y(scaleMax)}" x2="${width - pad.right}" y2="${y(scaleMax)}"/><text class="chart-max" x="4" y="${y(scaleMax) + 4}">${scaleMax}</text><path class="chart-area" d="${areaPath}"/><polyline points="${coords}"/><g class="chart-points">${circles}</g><g class="chart-labels">${labels}</g></svg>`;
}
function renderReviewCharts() {
@@ -147,7 +159,7 @@ function openChartDialog(chart = null) {
state.editingChartId = chart?.chartId || null;
const form = $("#chartForm"); form.reset(); $("#chartDialogTitle").textContent = chart ? "编辑图表" : "添加图表";
$("#chartTaskSelect").innerHTML = `<option value="">全部任务</option>${state.tasks.map((task) => `<option value="${escapeHtml(task.taskId)}">${escapeHtml(task.name)}</option>`).join("")}`;
form.title.value = chart?.title || ""; form.days.value = chart?.days || 30; $("#chartTaskSelect").value = chart?.taskId || ""; fillChartMetrics($("#chartTaskSelect").value, chart?.metricId || ""); $("#chartDialog").showModal();
form.title.value = chart?.title || ""; form.days.value = chart?.days || 30; form.yMax.value = chart?.yMax || ""; $("#chartTaskSelect").value = chart?.taskId || ""; fillChartMetrics($("#chartTaskSelect").value, chart?.metricId || ""); $("#chartDialog").showModal();
}
function bindReviewCharts() {
@@ -565,7 +577,7 @@ $("#chartTaskSelect").addEventListener("change", (event) => fillChartMetrics(eve
$("#chartForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const payload = { title: form.title.value, taskId: form.taskId.value || null, metricId: form.metricId.value || null, days: Number(form.days.value) };
const payload = { title: form.title.value, taskId: form.taskId.value || null, metricId: form.metricId.value || null, days: Number(form.days.value), yMax: form.yMax.value ? Number(form.yMax.value) : null };
try {
const editing = state.editingChartId ? state.charts.find((chart) => chart.chartId === state.editingChartId) : null;
if (editing) payload.expectedVersion = editing.version;