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 ``;
+ return ``;
}
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)