Newer
Older
hard-panel / panel / frontend / src / components / ChartLine.vue
<script setup>
// Обёртка chart.js: линейный график в стиле кита. Одна ось, тонкие линии,
// hover-тултип по индексу, легенда только при >=2 сериях (одиночную
// серию называет заголовок карточки).

import { Chart } from 'chart.js/auto'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'

import { INK } from '../chartTheme.js'

const props = defineProps({
  labels: { type: Array, default: () => [] },
  // [{ label, data: number[], color, fill? }]
  datasets: { type: Array, default: () => [] },
  yMax: { type: Number, default: null },
  yUnit: { type: String, default: '' },
  height: { type: Number, default: 220 },
})

const canvas = ref(null)
let chart = null

onMounted(() => {
  chart = new Chart(canvas.value, {
    type: 'line',
    data: {
      labels: props.labels,
      datasets: props.datasets.map((ds) => ({
        label: ds.label,
        data: ds.data,
        borderColor: ds.color,
        backgroundColor: ds.color + '26',
        borderWidth: 2,
        // мало точек — линии нет, показываем точку, чтобы график не был пустым
        pointRadius: (ctx) => (ctx.dataset.data.length < 3 ? 3 : 0),
        pointHoverRadius: 5,
        fill: Boolean(ds.fill),
        tension: 0.25,
      })),
    },
    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 },
        },
        tooltip: {
          backgroundColor: INK.panel,
          borderColor: INK.border,
          borderWidth: 1,
          titleColor: INK.text,
          bodyColor: INK.text,
          padding: 10,
          callbacks: {
            label: (ctx) => ` ${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
    props.datasets.forEach((ds, i) => {
      if (chart.data.datasets[i]) {
        chart.data.datasets[i].label = ds.label
        chart.data.datasets[i].data = ds.data
      }
    })
    chart.update('none')
  },
  { deep: true },
)

onBeforeUnmount(() => {
  chart?.destroy()
  chart = null
})

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>