Newer
Older
hard-panel / panel / frontend / src / components / ChartLine.vue
<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>