271 lines
11 KiB
Vue
271 lines
11 KiB
Vue
<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>
|