<script setup>
// Обёртка chart.js: линейный график в стиле кита. Одна ось, тонкие линии,
// hover-тултип по индексу, легенда только при >=2 сериях (одиночную
// серию называет заголовок карточки).
//
// Серия может нести `band: { min, max, n?, exp? }` — полосу min–max из дайджеста
// схлопнутых бакетов (история метрик разряжается): линия показывает последний
// замер интервала, полоса — что было внутри (всплески и провалы не теряются).
// В тултипе диапазон пишется рядом с числом замеров `n`/`exp` из `bandHint`.
import { Chart } from 'chart.js/auto'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { CHART_COLORS, INK } from '../chartTheme.js'
const props = defineProps({
labels: { type: Array, default: () => [] },
// [{ label, data: number[], color, fill?, band?: { min, max, n?, exp? } }]
datasets: { type: Array, default: () => [] },
yMax: { type: Number, default: null },
yUnit: { type: String, default: '' },
height: { type: Number, default: 220 },
// подпись счётчика замеров в тултипе полосы (i18n приходит со страницы)
bandHint: { type: String, default: '' },
})
// цвет не задан — берём первую позицию палитры: дефолт chart.js —
// почти чёрная rgba(0,0,0,.1), на тёмной поверхности линия не видна
const colorOf = (ds) => ds.color || CHART_COLORS[0]
const canvas = ref(null)
let chart = null
// мета плоского списка датасетов (полоса — это две серии на одну легенду)
let meta = []
/** Разложить серии с полосами в плоский список датасетов chart.js. */
function buildDatasets(list) {
const datasets = []
meta = []
for (const ds of list) {
const color = colorOf(ds)
if (ds.band) {
// нижняя граница — невидимая линия, верхняя заливается к ней (fill: '-1')
datasets.push({
label: `${ds.label} min`,
data: ds.band.min,
borderWidth: 0,
borderColor: 'transparent',
pointRadius: 0,
pointHoverRadius: 0,
fill: false,
tension: 0.25,
})
meta.push({ hideLegend: true, skipTooltip: true })
datasets.push({
label: ds.label,
data: ds.band.max,
borderWidth: 0,
pointRadius: 0,
pointHoverRadius: 0,
backgroundColor: color + '1f',
fill: '-1',
tension: 0.25,
})
meta.push({ hideLegend: true, band: ds.band, label: ds.label })
}
datasets.push({
label: ds.label,
data: ds.data,
borderColor: color,
backgroundColor: color + '26',
borderWidth: 2,
// мало точек — линии нет, показываем точку, чтобы график не был пустым
pointRadius: (ctx) => (ctx.dataset.data.length < 3 ? 3 : 0),
pointHoverRadius: 5,
fill: Boolean(ds.fill),
tension: 0.25,
})
meta.push({ label: ds.label })
}
return datasets
}
onMounted(() => {
chart = new Chart(canvas.value, {
type: 'line',
data: {
labels: props.labels,
datasets: buildDatasets(props.datasets),
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
display: props.datasets.length > 1,
labels: {
color: INK.muted,
usePointStyle: true,
boxWidth: 6,
boxHeight: 6,
// полоса — та же серия, в легенде не дублируется
filter: (item) => !meta[item.datasetIndex]?.hideLegend,
},
},
tooltip: {
backgroundColor: INK.panel,
borderColor: INK.border,
borderWidth: 1,
titleColor: INK.text,
bodyColor: INK.text,
padding: 10,
// нижняя граница полосы в тултипе не нужна — диапазон даёт верхняя
filter: (item) => !meta[item.datasetIndex]?.skipTooltip,
callbacks: {
label: (ctx) => {
const m = meta[ctx.datasetIndex]
if (m?.band) return ` ${bandLabel(m, ctx.dataIndex)}`
return ` ${ctx.dataset.label}: ${round(ctx.parsed.y)}${props.yUnit}`
},
},
},
},
scales: {
x: {
ticks: { color: INK.muted, maxTicksLimit: 8, maxRotation: 0 },
grid: { color: INK.grid },
},
y: {
beginAtZero: true,
max: props.yMax ?? undefined,
ticks: {
color: INK.muted,
callback: (v) => `${round(v)}${props.yUnit}`,
},
grid: { color: INK.grid },
},
},
},
})
})
watch(
() => [props.labels, props.datasets],
() => {
if (!chart) return
chart.data.labels = props.labels
// серии пересобираем целиком: у полос число датасетов не совпадает с числом серий
chart.data.datasets = buildDatasets(props.datasets)
chart.update('none')
},
{ deep: true },
)
onBeforeUnmount(() => {
chart?.destroy()
chart = null
})
/** Тултип полосы: `CPU: 3.2–98.4% · замеров 114/120`. */
function bandLabel(m, i) {
const lo = m.band.min?.[i]
const hi = m.band.max?.[i]
if (lo == null || hi == null) return ''
const n = m.band.n?.[i]
const exp = m.band.exp?.[i]
const coverage = props.bandHint && n != null && exp ? ` · ${props.bandHint} ${n}/${exp}` : ''
return `${m.label}: ${round(lo)}–${round(hi)}${props.yUnit}${coverage}`
}
function round(v) {
return Math.abs(v) >= 100 ? Math.round(v) : Math.round(v * 10) / 10
}
</script>
<template>
<div class="chart-box" :style="{ height: height + 'px' }">
<canvas ref="canvas" />
</div>
</template>
<style scoped>
.chart-box {
position: relative;
width: 100%;
}
</style>