import { apiFetch } from "./api.js"; import { dom } from "./dom.js"; import { state } from "./state.js"; function normalizeChartItem(item) { let valueNumber = null; if (typeof item?.value_number === "number" && Number.isFinite(item.value_number)) { valueNumber = item.value_number; } else if (typeof item?.value === "number" && Number.isFinite(item.value)) { valueNumber = item.value; } else if (typeof item?.value === "boolean") { valueNumber = item.value ? 1 : 0; } else if (typeof item?.value?.float === "number" && Number.isFinite(item.value.float)) { valueNumber = item.value.float; } else if (typeof item?.value?.int === "number" && Number.isFinite(item.value.int)) { valueNumber = item.value.int; } else if (typeof item?.value?.uint === "number" && Number.isFinite(item.value.uint)) { valueNumber = item.value.uint; } else if (typeof item?.value?.bool === "boolean") { valueNumber = item.value.bool ? 1 : 0; } else if (typeof item?.value_text === "string") { const parsed = Number(item.value_text); if (Number.isFinite(parsed)) { valueNumber = parsed; } } return { timestamp: item?.timestamp || "", valueNumber, valueText: item?.value_text || (valueNumber === null ? "" : String(valueNumber)), }; } function formatAxisValue(value) { if (!Number.isFinite(value)) { return "--"; } if (Math.abs(value) >= 1000 || Math.abs(value) < 0.01) { return value.toExponential(2); } return value.toFixed(2); } function formatTimeLabel(timestamp) { if (!timestamp) { return "--"; } const match = String(timestamp).match(/(\d{2}:\d{2}:\d{2})/); return match ? match[1] : String(timestamp); } export async function openChart(pointId, pointName) { state.chartPointId = pointId; state.chartPointName = pointName || "点位"; dom.chartTitle.textContent = `${state.chartPointName} 趋势图`; const items = await apiFetch(`/api/point/${pointId}/history?limit=120`); state.chartData = (items || []) .map(normalizeChartItem) .filter((item) => item.valueNumber !== null); renderChart(); } export function appendChartPoint(item) { if (!state.chartPointId) { return; } const normalized = normalizeChartItem(item); if (normalized.valueNumber === null) { return; } const last = state.chartData[state.chartData.length - 1]; if ( last && last.timestamp === normalized.timestamp && last.valueText === normalized.valueText && last.valueNumber === normalized.valueNumber ) { return; } state.chartData.push(normalized); if (state.chartData.length > 120) { state.chartData = state.chartData.slice(-120); } renderChart(); } export function renderChart() { const ctx = dom.chartCanvas.getContext("2d"); const width = dom.chartCanvas.width; const height = dom.chartCanvas.height; ctx.clearRect(0, 0, width, height); if (!state.chartData.length) { ctx.fillStyle = "#94a3b8"; ctx.font = "14px Segoe UI"; ctx.fillText("点击点位行查看图表", 24, 40); dom.chartSummary.textContent = "点击点位行查看图表"; return; } const values = state.chartData.map((item) => item.valueNumber); let min = Math.min(...values); let max = Math.max(...values); if (min === max) { min -= 1; max += 1; } const padding = { top: 20, right: 20, bottom: 42, left: 64 }; const plotWidth = width - padding.left - padding.right; const plotHeight = height - padding.top - padding.bottom; ctx.strokeStyle = "#cbd5e1"; ctx.lineWidth = 1; for (let i = 0; i <= 4; i += 1) { const y = padding.top + (plotHeight / 4) * i; ctx.beginPath(); ctx.moveTo(padding.left, y); ctx.lineTo(width - padding.right, y); ctx.stroke(); } ctx.beginPath(); ctx.moveTo(padding.left, padding.top); ctx.lineTo(padding.left, height - padding.bottom); ctx.lineTo(width - padding.right, height - padding.bottom); ctx.strokeStyle = "#94a3b8"; ctx.stroke(); ctx.fillStyle = "#64748b"; ctx.font = "12px Segoe UI"; for (let i = 0; i <= 4; i += 1) { const value = max - ((max - min) / 4) * i; const y = padding.top + (plotHeight / 4) * i; ctx.fillText(formatAxisValue(value), 8, y + 4); } const firstLabel = formatTimeLabel(state.chartData[0]?.timestamp); const middleLabel = formatTimeLabel( state.chartData[Math.floor((state.chartData.length - 1) / 2)]?.timestamp, ); const lastLabel = formatTimeLabel(state.chartData[state.chartData.length - 1]?.timestamp); ctx.fillText(firstLabel, padding.left, height - 12); const middleWidth = ctx.measureText(middleLabel).width; ctx.fillText(middleLabel, padding.left + plotWidth / 2 - middleWidth / 2, height - 12); const lastWidth = ctx.measureText(lastLabel).width; ctx.fillText(lastLabel, width - padding.right - lastWidth, height - 12); ctx.save(); ctx.translate(16, padding.top + plotHeight / 2); ctx.rotate(-Math.PI / 2); ctx.fillStyle = "#64748b"; ctx.fillText("数值", 0, 0); ctx.restore(); ctx.fillText("时间", width / 2 - 12, height - 28); ctx.strokeStyle = "#2563eb"; ctx.lineWidth = 2; ctx.beginPath(); state.chartData.forEach((item, index) => { const x = padding.left + (plotWidth * index) / Math.max(1, state.chartData.length - 1); const y = padding.top + ((max - item.valueNumber) / (max - min)) * plotHeight; if (index === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }); ctx.stroke(); const latest = state.chartData[state.chartData.length - 1]; dom.chartSummary.textContent = `Latest ${state.chartData.length} points, current value ${latest.valueText || latest.valueNumber}`; }