Files
onboard/resources/js/Pages/Analytics/Components/ReportChart.vue
2026-06-22 17:02:36 +09:00

271 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { computed } from 'vue'
import { NSelect, NEmpty, useThemeVars } from 'naive-ui'
import VueApexCharts from 'vue3-apexcharts'
const props = defineProps({
columns: { type: Array, default: () => [] },
rows: { type: Array, default: () => [] },
chart: { type: Object, default: () => ({ metric: null, type: 'bar' }) },
mode: { type: String, default: 'period' },
metricOptions: { type: Array, default: () => [] }, // [{ label, value }]
})
const emit = defineEmits(['update:chart'])
const themeVars = useThemeVars()
// --- Стили графиков, перенесённые со страницы «Статистика» (Headquarters) ---
const FONT = "'v-sans', ui-sans-serif, system-ui, sans-serif"
const cAxis = computed(() => themeVars.value.textColor3)
const cGrid = computed(() => themeVars.value.dividerColor)
// soften(color) = цвет темы, разбавленный фоном карточки (ApexCharts не понимает color-mix)
const SOFT_AMOUNT = 0.35
function parseColor(c) {
if (!c) return { r: 0, g: 0, b: 0 }
c = c.trim()
if (c[0] === '#') {
let h = c.slice(1)
if (h.length === 3) h = h.split('').map((x) => x + x).join('')
const n = parseInt(h, 16)
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
}
const m = c.match(/rgba?\(([^)]+)\)/i)
if (m) {
const [r, g, b] = m[1].split(',').map((s) => parseFloat(s))
return { r, g, b }
}
return { r: 0, g: 0, b: 0 }
}
function toHex({ r, g, b }) {
const h = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')
return `#${h(r)}${h(g)}${h(b)}`
}
function soften(color, amount = SOFT_AMOUNT) {
const a = parseColor(color)
const b = parseColor(themeVars.value.cardColor)
return toHex({
r: a.r * (1 - amount) + b.r * amount,
g: a.g * (1 - amount) + b.g * amount,
b: a.b * (1 - amount) + b.b * amount,
})
}
// Семантические цвета + различимая палитра (без двух зелёных, как было раньше).
// Приглушённая палитра (для заливок: столбцы/донат) и яркая (для тонких линий).
const buildColors = (vivid) => {
const f = vivid ? (c) => c : (c) => soften(c)
return {
green: f(themeVars.value.successColor),
red: f(themeVars.value.errorColor),
gray: f(themeVars.value.textColor3),
blue: f(themeVars.value.infoColor),
orange: f(themeVars.value.warningColor),
purple: f('#8a63d2'),
teal: f('#13c2c2'),
pink: f('#eb6ea5'),
}
}
const SOFT = computed(() => buildColors(false))
const VIVID = computed(() => buildColors(true))
const fallbackPalette = (c) => [c.green, c.blue, c.orange, c.purple, c.teal, c.pink, c.gray, c.red]
// Цвет категории: экстренное/смерть → красный, плановое → серый, иначе — различимый из палитры.
function colorFor(label, index, vivid = false) {
const c = vivid ? VIVID.value : SOFT.value
const t = String(label ?? '').toLowerCase()
if (/экстрен|неотлож|emergency/.test(t)) return c.red
if (/умер|смерт|летал|decease|death/.test(t)) return c.red
if (/планов|plan/.test(t)) return c.gray
const p = fallbackPalette(c)
return p[index % p.length]
}
const baseChart = (type) => ({
type,
background: 'transparent',
toolbar: { show: false },
fontFamily: FONT,
animations: { enabled: true, speed: 600, dynamicAnimation: { enabled: true, speed: 400 } },
})
const baseTooltip = computed(() => ({ theme: 'dark', style: { fontSize: '12px', fontFamily: FONT } }))
const baseGrid = computed(() => ({ borderColor: cGrid.value, strokeDashArray: 4, xaxis: { lines: { show: false } } }))
const typeOptions = [
{ label: 'Столбцы', value: 'bar' },
{ label: 'Линия', value: 'line' },
{ label: 'Круговая', value: 'donut' },
]
const dimensionCols = computed(() => props.columns.filter((c) => c.kind === 'dimension'))
const periodCols = computed(() => props.columns.filter((c) => c.kind === 'period'))
const metric = computed(() => props.chart.metric ?? props.metricOptions[0]?.value ?? null)
const metricLabel = computed(() => props.metricOptions.find((m) => m.value === metric.value)?.label ?? 'Показатель')
// Режим «сравнение показателей»: слайсы/столбцы = сами показатели (Поступило/Выписано/…),
// а не категории измерения. Доступен, когда выбрано ≥2 показателей и это не динамика.
const ALL_METRICS = '__all'
const isAllMetrics = computed(() => metric.value === ALL_METRICS && props.mode !== 'dynamics')
const metricSelectOptions = computed(() => {
const opts = props.metricOptions.map((m) => ({ label: m.label, value: m.value }))
if (opts.length > 1) opts.unshift({ label: 'Все показатели (сравнение)', value: ALL_METRICS })
return opts
})
const setMetric = (value) => emit('update:chart', { ...props.chart, metric: value })
const setType = (value) => emit('update:chart', { ...props.chart, type: value })
const num = (v) => {
const n = Number(v)
return Number.isNaN(n) ? 0 : n
}
const apexType = computed(() => {
if (props.chart.type === 'donut') return props.mode === 'dynamics' ? 'line' : 'donut'
return props.chart.type === 'line' ? 'line' : 'bar'
})
const categories = computed(() => {
if (props.mode === 'dynamics') return periodCols.value.map((c) => c.label)
if (isAllMetrics.value) return props.metricOptions.map((m) => m.label)
const dim = dimensionCols.value[0]
return dim ? props.rows.map((r) => r[dim.key]) : props.rows.map((_, i) => `#${i + 1}`)
})
const series = computed(() => {
if (props.mode === 'dynamics') {
const labelKey = dimensionCols.value[0]?.key ?? '__metric'
return props.rows.map((r) => ({
name: r[labelKey] ?? metricLabel.value,
data: periodCols.value.map((c) => num(r[c.key])),
}))
}
// Сравнение показателей: каждое значение = сумма показателя по всем строкам.
if (isAllMetrics.value) {
const sums = props.metricOptions.map((m) => props.rows.reduce((s, r) => s + num(r[m.value]), 0))
return apexType.value === 'donut' ? sums : [{ name: 'Показатели', data: sums }]
}
const data = props.rows.map((r) => num(r[metric.value]))
return apexType.value === 'donut' ? data : [{ name: metricLabel.value, data }]
})
// Раскрашивать каждый столбец отдельно, когда это одна серия по нескольким категориям
// (напр. срочность/исход/показатели) и категорий немного.
const distributedBar = computed(() =>
props.mode !== 'dynamics' && apexType.value === 'bar' && (isAllMetrics.value || categories.value.length <= 12),
)
const isLine = computed(() => apexType.value === 'line')
const chartColors = computed(() => {
// Линии берут яркие цвета, заливки (столбцы/донат) — приглушённые.
if (props.mode === 'dynamics') {
return series.value.map((s, i) => colorFor(s.name, i, isLine.value))
}
// Круговая и «распределённый» столбчатый: цвет на категорию.
if (apexType.value === 'donut' || distributedBar.value) {
return categories.value.map((c, i) => colorFor(c, i, false))
}
// Одна серия линией/столбцами без распределения — один цвет.
return [colorFor(metricLabel.value, 0, isLine.value)]
})
const options = computed(() => {
const base = {
chart: baseChart(apexType.value),
colors: chartColors.value,
dataLabels: { enabled: false },
tooltip: baseTooltip.value,
legend: { position: 'bottom', labels: { colors: cAxis.value }, fontSize: '12px', fontFamily: FONT, markers: { size: 6, shape: 'circle' } },
noData: { text: 'Нет данных', style: { fontFamily: FONT } },
}
if (apexType.value === 'donut') {
return {
...base,
labels: categories.value,
stroke: { width: 0 },
legend: { ...base.legend, position: 'right' },
plotOptions: { pie: { donut: { size: '62%' } } },
}
}
return {
...base,
grid: baseGrid.value,
plotOptions: { bar: { borderRadius: 5, borderRadiusApplication: 'end', columnWidth: '60%', distributed: distributedBar.value } },
legend: { ...base.legend, show: props.mode === 'dynamics' },
stroke: isLine.value
? { width: 4, curve: 'smooth', lineCap: 'round' }
: { width: 0 },
markers: isLine.value
? { size: 5, strokeWidth: 0, hover: { sizeOffset: 2 } }
: { size: 0 },
fill: { type: 'solid', opacity: 1 },
xaxis: {
categories: categories.value,
labels: {
style: { colors: cAxis.value, fontSize: '11px', fontFamily: FONT },
rotate: -40,
rotateAlways: categories.value.length > 6,
hideOverlappingLabels: true,
trim: true,
},
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: { labels: { style: { colors: cAxis.value, fontFamily: FONT } }, tickAmount: 4 },
}
})
const hasData = computed(() => props.rows.length > 0 && series.value.length > 0)
</script>
<template>
<div class="chart-wrap">
<div class="chart-toolbar">
<NSelect
v-if="metricOptions.length"
:value="metric"
:options="metricSelectOptions"
size="small"
style="width: 240px;"
:disabled="mode === 'dynamics' && metricOptions.length <= 1"
@update:value="setMetric"
/>
<NSelect
:value="chart.type"
:options="typeOptions"
size="small"
style="width: 140px;"
@update:value="setType"
/>
</div>
<NEmpty v-if="!hasData" description="Диаграмма появится после формирования отчёта" style="padding: 32px;" />
<VueApexCharts
v-else
:key="apexType + '|' + mode"
:type="apexType"
height="280"
:options="options"
:series="series"
/>
</div>
</template>
<style scoped>
.chart-wrap { position: relative; }
.chart-toolbar {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
</style>