184 lines
5.5 KiB
JavaScript
184 lines
5.5 KiB
JavaScript
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}`;
|
|
}
|