mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 00:02:28 +08:00
feat: 实现缓存监控仪表板和散点图组件
This commit is contained in:
372
frontend/src/components/charts/ScatterChart.vue
Normal file
372
frontend/src/components/charts/ScatterChart.vue
Normal file
@@ -0,0 +1,372 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<canvas ref="chartRef"></canvas>
|
||||
<div
|
||||
v-if="crosshairStats"
|
||||
class="absolute top-2 right-2 bg-gray-800/90 text-gray-100 px-3 py-2 rounded-lg text-sm shadow-lg border border-gray-600"
|
||||
>
|
||||
<div class="font-medium text-yellow-400">Y = {{ crosshairStats.yValue.toFixed(1) }} 分钟</div>
|
||||
<div class="mt-1">
|
||||
<span class="text-green-400">{{ crosshairStats.belowCount }}</span> / {{ crosshairStats.totalCount }} 点在横线以下
|
||||
<span class="ml-2 text-blue-400">({{ crosshairStats.belowPercent.toFixed(1) }}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
ScatterController,
|
||||
TimeScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
type ChartData,
|
||||
type ChartOptions,
|
||||
type Plugin,
|
||||
type Scale
|
||||
} from 'chart.js'
|
||||
import 'chartjs-adapter-date-fns'
|
||||
|
||||
ChartJS.register(
|
||||
LinearScale,
|
||||
PointElement,
|
||||
ScatterController,
|
||||
TimeScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
)
|
||||
|
||||
interface Props {
|
||||
data: ChartData<'scatter'>
|
||||
options?: ChartOptions<'scatter'>
|
||||
height?: number
|
||||
}
|
||||
|
||||
interface CrosshairStats {
|
||||
yValue: number
|
||||
belowCount: number
|
||||
totalCount: number
|
||||
belowPercent: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 300
|
||||
})
|
||||
|
||||
const chartRef = ref<HTMLCanvasElement>()
|
||||
let chart: ChartJS<'scatter'> | null = null
|
||||
|
||||
const crosshairY = ref<number | null>(null)
|
||||
|
||||
const crosshairStats = computed<CrosshairStats | null>(() => {
|
||||
if (crosshairY.value === null || !props.data.datasets) return null
|
||||
|
||||
let totalCount = 0
|
||||
let belowCount = 0
|
||||
|
||||
for (const dataset of props.data.datasets) {
|
||||
if (!dataset.data) continue
|
||||
for (const point of dataset.data) {
|
||||
const p = point as { x: string; y: number }
|
||||
if (typeof p.y === 'number') {
|
||||
totalCount++
|
||||
if (p.y <= crosshairY.value) {
|
||||
belowCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCount === 0) return null
|
||||
|
||||
return {
|
||||
yValue: crosshairY.value,
|
||||
belowCount,
|
||||
totalCount,
|
||||
belowPercent: (belowCount / totalCount) * 100
|
||||
}
|
||||
})
|
||||
|
||||
const crosshairPlugin: Plugin<'scatter'> = {
|
||||
id: 'crosshairLine',
|
||||
afterDraw: (chartInstance) => {
|
||||
if (crosshairY.value === null) return
|
||||
|
||||
const { ctx, chartArea, scales } = chartInstance
|
||||
const yScale = scales.y
|
||||
if (!yScale || !chartArea) return
|
||||
|
||||
const yPixel = yScale.getPixelForValue(crosshairY.value)
|
||||
|
||||
if (yPixel < chartArea.top || yPixel > chartArea.bottom) return
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(chartArea.left, yPixel)
|
||||
ctx.lineTo(chartArea.right, yPixel)
|
||||
ctx.strokeStyle = 'rgba(250, 204, 21, 0.8)'
|
||||
ctx.lineWidth = 2
|
||||
ctx.setLineDash([6, 4])
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义非线性 Y 轴转换函数
|
||||
// 0-10 分钟占据 70% 的空间,10-120 分钟占据 30% 的空间
|
||||
const BREAKPOINT = 10 // 分界点:10 分钟
|
||||
const LOWER_RATIO = 0.7 // 0-10 分钟占 70% 空间
|
||||
|
||||
// 将实际值转换为显示值(用于绘图)
|
||||
function toDisplayValue(realValue: number): number {
|
||||
if (realValue <= BREAKPOINT) {
|
||||
// 0-10 分钟线性映射到 0-70
|
||||
return realValue * (LOWER_RATIO * 100 / BREAKPOINT)
|
||||
} else {
|
||||
// 10-120 分钟映射到 70-100
|
||||
const upperRange = 120 - BREAKPOINT
|
||||
const displayUpperRange = (1 - LOWER_RATIO) * 100
|
||||
return LOWER_RATIO * 100 + ((realValue - BREAKPOINT) / upperRange) * displayUpperRange
|
||||
}
|
||||
}
|
||||
|
||||
// 将显示值转换回实际值(用于读取鼠标位置)
|
||||
function toRealValue(displayValue: number): number {
|
||||
const breakpointDisplay = LOWER_RATIO * 100
|
||||
if (displayValue <= breakpointDisplay) {
|
||||
return displayValue / (LOWER_RATIO * 100 / BREAKPOINT)
|
||||
} else {
|
||||
const upperRange = 120 - BREAKPOINT
|
||||
const displayUpperRange = (1 - LOWER_RATIO) * 100
|
||||
return BREAKPOINT + ((displayValue - breakpointDisplay) / displayUpperRange) * upperRange
|
||||
}
|
||||
}
|
||||
|
||||
// 转换数据点的 Y 值
|
||||
function transformData(data: ChartData<'scatter'>): ChartData<'scatter'> {
|
||||
return {
|
||||
...data,
|
||||
datasets: data.datasets.map(dataset => ({
|
||||
...dataset,
|
||||
data: (dataset.data as Array<{ x: string; y: number }>).map(point => ({
|
||||
...point,
|
||||
y: toDisplayValue(point.y),
|
||||
_originalY: point.y // 保存原始值用于 tooltip
|
||||
}))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const defaultOptions: ChartOptions<'scatter'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
intersect: true
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'hour',
|
||||
displayFormats: {
|
||||
hour: 'MM-dd HH:mm'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(107, 114, 128)',
|
||||
maxRotation: 45
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: '时间',
|
||||
color: 'rgb(107, 114, 128)'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
min: 0,
|
||||
max: 100, // 显示值范围 0-100
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgb(107, 114, 128)',
|
||||
// 自定义刻度值:在实际值 0, 2, 5, 10, 30, 60, 120 处显示
|
||||
callback: function(this: Scale, tickValue: string | number) {
|
||||
const displayVal = Number(tickValue)
|
||||
const realVal = toRealValue(displayVal)
|
||||
// 只在特定的显示位置显示刻度
|
||||
const targetTicks = [0, 2, 5, 10, 30, 60, 120]
|
||||
for (const target of targetTicks) {
|
||||
const targetDisplay = toDisplayValue(target)
|
||||
if (Math.abs(displayVal - targetDisplay) < 1) {
|
||||
return `${target}`
|
||||
}
|
||||
}
|
||||
return ''
|
||||
},
|
||||
stepSize: 5, // 显示值的步长
|
||||
autoSkip: false
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: '间隔 (分钟)',
|
||||
color: 'rgb(107, 114, 128)'
|
||||
},
|
||||
afterBuildTicks: function(scale: Scale) {
|
||||
// 在特定实际值处设置刻度
|
||||
const targetTicks = [0, 2, 5, 10, 30, 60, 120]
|
||||
scale.ticks = targetTicks.map(val => ({
|
||||
value: toDisplayValue(val),
|
||||
label: `${val}`
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgb(31, 41, 55)',
|
||||
titleColor: 'rgb(243, 244, 246)',
|
||||
bodyColor: 'rgb(243, 244, 246)',
|
||||
borderColor: 'rgb(75, 85, 99)',
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const point = context.raw as { x: string; y: number; _originalY?: number }
|
||||
const realY = point._originalY ?? toRealValue(point.y)
|
||||
return `间隔: ${realY.toFixed(1)} 分钟`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onHover: (event, _elements, chartInstance) => {
|
||||
const canvas = chartInstance.canvas
|
||||
if (!canvas) return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const mouseY = (event.native as MouseEvent)?.clientY
|
||||
|
||||
if (mouseY === undefined) {
|
||||
crosshairY.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const { chartArea, scales } = chartInstance
|
||||
const yScale = scales.y
|
||||
|
||||
if (!chartArea || !yScale) return
|
||||
|
||||
const relativeY = mouseY - rect.top
|
||||
|
||||
if (relativeY < chartArea.top || relativeY > chartArea.bottom) {
|
||||
crosshairY.value = null
|
||||
} else {
|
||||
const displayValue = yScale.getValueForPixel(relativeY)
|
||||
// 转换回实际值
|
||||
crosshairY.value = displayValue !== undefined ? toRealValue(displayValue) : null
|
||||
}
|
||||
|
||||
chartInstance.draw()
|
||||
}
|
||||
}
|
||||
|
||||
// 修改 crosshairPlugin 使用显示值
|
||||
const crosshairPluginWithTransform: Plugin<'scatter'> = {
|
||||
id: 'crosshairLine',
|
||||
afterDraw: (chartInstance) => {
|
||||
if (crosshairY.value === null) return
|
||||
|
||||
const { ctx, chartArea, scales } = chartInstance
|
||||
const yScale = scales.y
|
||||
if (!yScale || !chartArea) return
|
||||
|
||||
// 将实际值转换为显示值再获取像素位置
|
||||
const displayValue = toDisplayValue(crosshairY.value)
|
||||
const yPixel = yScale.getPixelForValue(displayValue)
|
||||
|
||||
if (yPixel < chartArea.top || yPixel > chartArea.bottom) return
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(chartArea.left, yPixel)
|
||||
ctx.lineTo(chartArea.right, yPixel)
|
||||
ctx.strokeStyle = 'rgba(250, 204, 21, 0.8)'
|
||||
ctx.lineWidth = 2
|
||||
ctx.setLineDash([6, 4])
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
crosshairY.value = null
|
||||
if (chart) {
|
||||
chart.draw()
|
||||
}
|
||||
}
|
||||
|
||||
function createChart() {
|
||||
if (!chartRef.value) return
|
||||
|
||||
// 转换数据
|
||||
const transformedData = transformData(props.data)
|
||||
|
||||
chart = new ChartJS(chartRef.value, {
|
||||
type: 'scatter',
|
||||
data: transformedData,
|
||||
options: {
|
||||
...defaultOptions,
|
||||
...props.options
|
||||
},
|
||||
plugins: [crosshairPluginWithTransform]
|
||||
})
|
||||
|
||||
chartRef.value.addEventListener('mouseleave', handleMouseLeave)
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (chart) {
|
||||
chart.data = transformData(props.data)
|
||||
chart.update('none')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
createChart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartRef.value) {
|
||||
chartRef.value.removeEventListener('mouseleave', handleMouseLeave)
|
||||
}
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
chart = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.data, updateChart, { deep: true })
|
||||
watch(() => props.options, () => {
|
||||
if (chart) {
|
||||
chart.options = {
|
||||
...defaultOptions,
|
||||
...props.options
|
||||
}
|
||||
chart.update()
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
Reference in New Issue
Block a user