diff --git a/app/models.py b/app/models.py index b593dfa..1dc6b54 100644 --- a/app/models.py +++ b/app/models.py @@ -173,6 +173,7 @@ class ChartInput(BaseModel): taskId: str | None = None metricId: str | None = None days: Annotated[int, Field(ge=7, le=365)] = 30 + yMax: Annotated[int | None, Field(ge=1)] = None class ChartUpdate(ChartInput): diff --git a/app/static/app.js b/app/static/app.js index 1ab70e8..b1d8308 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -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 `
暂无数据
`; - 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 `${escapeHtml(point.date.slice(5))}`; }).join(""); const circles = points.map((point, index) => ``).join(""); - return `${max}${circles}${labels}`; + return `${scaleMax}${circles}${labels}`; } 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 = `${state.tasks.map((task) => ``).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; diff --git a/app/static/index.html b/app/static/index.html index 0011f12..6523183 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -70,6 +70,7 @@ +
diff --git a/tests/test_charts.py b/tests/test_charts.py index 978fff4..23cefe4 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -29,6 +29,14 @@ def test_chart_crud_and_conflict(tmp_path): assert repo.delete_chart(chart["chartId"])["deleted"] is True +def test_chart_yMax_roundtrip(tmp_path): + repo = JsonRepository(tmp_path) + bounded = repo.create_chart(ChartInput(title="带上限", yMax=10)) + assert repo.get_chart(bounded["chartId"])["yMax"] == 10 + auto = repo.create_chart(ChartInput(title="自动")) + assert repo.get_chart(auto["chartId"])["yMax"] is None + + def test_chart_data_modes_and_continuous_points(tmp_path): repo = JsonRepository(tmp_path) task = _task(repo)