264 lines
7.3 KiB
JavaScript
264 lines
7.3 KiB
JavaScript
import { formatAxis, formatDay, formatNumber } from "./format.js";
|
||
|
||
const charts = new Map();
|
||
let resizeObserver = null;
|
||
|
||
function colors() {
|
||
const styles = getComputedStyle(document.documentElement);
|
||
return {
|
||
text: styles.getPropertyValue("--text").trim() || "#2a201a",
|
||
muted: styles.getPropertyValue("--muted").trim() || "#7b6a58",
|
||
grid: styles.getPropertyValue("--grid").trim() || "#eadfce",
|
||
accent: styles.getPropertyValue("--accent").trim() || "#a9702f",
|
||
blue: styles.getPropertyValue("--blue").trim() || "#5f7f96",
|
||
success: styles.getPropertyValue("--success").trim() || "#5f7a4e",
|
||
danger: styles.getPropertyValue("--danger").trim() || "#b4553f",
|
||
surface: styles.getPropertyValue("--surface").trim() || "#fffdf9",
|
||
};
|
||
}
|
||
|
||
export function themeColor(name) {
|
||
return colors()[name] || colors().accent;
|
||
}
|
||
|
||
function ensureEcharts() {
|
||
if (!window.echarts) {
|
||
throw new Error("ECharts 未加载,请检查 /static/vendor/echarts.min.js");
|
||
}
|
||
return window.echarts;
|
||
}
|
||
|
||
function mount(element, option) {
|
||
const echarts = ensureEcharts();
|
||
let chart = charts.get(element);
|
||
if (!chart) {
|
||
chart = echarts.init(element, null, { renderer: "canvas" });
|
||
charts.set(element, chart);
|
||
}
|
||
chart.setOption(option, true);
|
||
if (!resizeObserver && typeof ResizeObserver !== "undefined") {
|
||
resizeObserver = new ResizeObserver((entries) => {
|
||
for (const entry of entries) {
|
||
charts.get(entry.target)?.resize();
|
||
}
|
||
});
|
||
}
|
||
resizeObserver?.observe(element);
|
||
return chart;
|
||
}
|
||
|
||
export function disposeAll() {
|
||
for (const chart of charts.values()) {
|
||
try {
|
||
chart.dispose();
|
||
} catch {
|
||
// 页面切换时忽略已销毁实例
|
||
}
|
||
}
|
||
charts.clear();
|
||
resizeObserver?.disconnect();
|
||
resizeObserver = null;
|
||
}
|
||
|
||
export function lineChart(element, { dates, values, name = "数值", color, scale = false }) {
|
||
if (!element) return null;
|
||
const theme = colors();
|
||
const lineColor = color || theme.accent;
|
||
const option = {
|
||
animationDuration: 300,
|
||
backgroundColor: "transparent",
|
||
grid: { left: 8, right: 12, top: 22, bottom: 8, containLabel: true },
|
||
tooltip: {
|
||
trigger: "axis",
|
||
backgroundColor: theme.surface,
|
||
borderColor: theme.grid,
|
||
textStyle: { color: theme.text, fontSize: 12 },
|
||
formatter(params) {
|
||
const point = params[0];
|
||
if (!point) return "";
|
||
const value = point.value;
|
||
return `<strong>${point.axisValue}</strong><br>${name}:${
|
||
value === null || value === undefined ? "缺失" : formatNumber(value)
|
||
}`;
|
||
},
|
||
},
|
||
xAxis: {
|
||
type: "category",
|
||
data: dates,
|
||
boundaryGap: false,
|
||
axisLine: { lineStyle: { color: theme.grid } },
|
||
axisTick: { show: false },
|
||
axisLabel: {
|
||
color: theme.muted,
|
||
fontSize: 11,
|
||
formatter: (value) => formatDay(value),
|
||
hideOverlap: true,
|
||
},
|
||
},
|
||
yAxis: {
|
||
type: "value",
|
||
scale,
|
||
minInterval: 1,
|
||
splitLine: { lineStyle: { color: theme.grid, type: "dashed" } },
|
||
axisLabel: {
|
||
color: theme.muted,
|
||
fontSize: 11,
|
||
formatter: (value) => formatAxis(value),
|
||
},
|
||
},
|
||
series: [{
|
||
name,
|
||
type: "line",
|
||
data: values,
|
||
smooth: false,
|
||
connectNulls: false,
|
||
symbol: "circle",
|
||
symbolSize: 5,
|
||
showSymbol: values.length <= 45,
|
||
lineStyle: { width: 2.2, color: lineColor },
|
||
itemStyle: { color: lineColor },
|
||
areaStyle: {
|
||
color: {
|
||
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
|
||
colorStops: [
|
||
{ offset: 0, color: `${lineColor}55` },
|
||
{ offset: 1, color: `${lineColor}00` },
|
||
],
|
||
},
|
||
},
|
||
}],
|
||
};
|
||
return mount(element, option);
|
||
}
|
||
|
||
export function barChart(element, { labels, values, name = "数值", color }) {
|
||
if (!element) return null;
|
||
const theme = colors();
|
||
const barColor = color || theme.blue;
|
||
const option = {
|
||
animationDuration: 300,
|
||
backgroundColor: "transparent",
|
||
grid: { left: 8, right: 12, top: 22, bottom: 8, containLabel: true },
|
||
tooltip: {
|
||
trigger: "axis",
|
||
axisPointer: { type: "shadow" },
|
||
backgroundColor: theme.surface,
|
||
borderColor: theme.grid,
|
||
textStyle: { color: theme.text, fontSize: 12 },
|
||
formatter(params) {
|
||
const point = params[0];
|
||
if (!point) return "";
|
||
return `<strong>${point.name}</strong><br>${name}:${formatNumber(point.value)}`;
|
||
},
|
||
},
|
||
xAxis: {
|
||
type: "category",
|
||
data: labels,
|
||
axisLine: { lineStyle: { color: theme.grid } },
|
||
axisTick: { show: false },
|
||
axisLabel: {
|
||
color: theme.muted,
|
||
fontSize: 10,
|
||
interval: 0,
|
||
rotate: labels.length > 8 ? 32 : 0,
|
||
formatter: (value) => String(value).slice(0, 10),
|
||
},
|
||
},
|
||
yAxis: {
|
||
type: "value",
|
||
minInterval: 1,
|
||
splitLine: { lineStyle: { color: theme.grid, type: "dashed" } },
|
||
axisLabel: {
|
||
color: theme.muted,
|
||
fontSize: 11,
|
||
formatter: (value) => formatAxis(value),
|
||
},
|
||
},
|
||
series: [{
|
||
name,
|
||
type: "bar",
|
||
data: values,
|
||
barMaxWidth: 26,
|
||
itemStyle: { color: barColor, borderRadius: [4, 4, 0, 0] },
|
||
}],
|
||
};
|
||
return mount(element, option);
|
||
}
|
||
|
||
export function emptyChart(element, message = "暂无数据") {
|
||
if (!element) return;
|
||
element.innerHTML = `<div class="chart-empty">${message}</div>`;
|
||
}
|
||
|
||
export function mixedChart(element, { dates, bars, line, barName = "PV", lineName = "UV" }) {
|
||
if (!element) return null;
|
||
const theme = colors();
|
||
const option = {
|
||
animationDuration: 300,
|
||
backgroundColor: "transparent",
|
||
grid: { left: 8, right: 12, top: 34, bottom: 8, containLabel: true },
|
||
legend: {
|
||
data: [barName, lineName],
|
||
top: 0,
|
||
textStyle: { color: theme.muted, fontSize: 11 },
|
||
},
|
||
tooltip: {
|
||
trigger: "axis",
|
||
axisPointer: { type: "shadow" },
|
||
backgroundColor: theme.surface,
|
||
borderColor: theme.grid,
|
||
textStyle: { color: theme.text, fontSize: 12 },
|
||
},
|
||
xAxis: {
|
||
type: "category",
|
||
data: dates,
|
||
axisLine: { lineStyle: { color: theme.grid } },
|
||
axisTick: { show: false },
|
||
axisLabel: {
|
||
color: theme.muted,
|
||
fontSize: 11,
|
||
formatter: (value) => formatDay(value),
|
||
hideOverlap: true,
|
||
},
|
||
},
|
||
yAxis: [
|
||
{
|
||
type: "value",
|
||
name: barName,
|
||
minInterval: 1,
|
||
splitLine: { lineStyle: { color: theme.grid, type: "dashed" } },
|
||
axisLabel: { color: theme.muted, fontSize: 11, formatter: (value) => formatAxis(value) },
|
||
},
|
||
{
|
||
type: "value",
|
||
name: lineName,
|
||
minInterval: 1,
|
||
splitLine: { show: false },
|
||
axisLabel: { color: theme.muted, fontSize: 11, formatter: (value) => formatAxis(value) },
|
||
},
|
||
],
|
||
series: [
|
||
{
|
||
name: barName,
|
||
type: "bar",
|
||
data: bars,
|
||
barMaxWidth: 22,
|
||
itemStyle: { color: theme.blue, borderRadius: [4, 4, 0, 0] },
|
||
},
|
||
{
|
||
name: lineName,
|
||
type: "line",
|
||
yAxisIndex: 1,
|
||
data: line,
|
||
smooth: false,
|
||
connectNulls: false,
|
||
symbol: "circle",
|
||
symbolSize: 5,
|
||
lineStyle: { width: 2.2, color: theme.accent },
|
||
itemStyle: { color: theme.accent },
|
||
},
|
||
],
|
||
};
|
||
return mount(element, option);
|
||
}
|