Files
portal/app/resources/js/components/charts/ActivityChart.vue
T

239 lines
7.7 KiB
Vue
Raw Normal View History

<script setup lang="ts">
/**
* Линейный SVG-чарт «Активность по дням».
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html секция .panel
* с #chart-title. Native SVG (как в handoff) — без chart-library, чтобы избежать
* +400KB зависимости. Для статичных дашборд-чартов native-SVG достаточно;
* интерактив (tooltip-on-hover) добавим позже как Vuetify-overlay.
*
* Props:
* - points: 7 значений (пн..сб, сегодня) для активного метрика-tab.
* - max: верхняя граница оси Y (default 60).
*
* Внутри 3 tabs (Принято/Оплачено/Отказ) — на MVP только активный tab,
* данные приходят через prop. На API-привязке tabs будут менять props.points.
*/
import { computed, ref } from 'vue';
interface Props {
title?: string;
subtitle?: string;
points?: number[];
max?: number;
labels?: string[];
}
const props = withDefaults(defineProps<Props>(), {
title: 'Активность по дням',
subtitle: 'принято · оплачено · отказ — последние 7 дней',
points: () => [16, 31, 27, 47, 39, 56, 50],
max: 60,
labels: () => ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'сегодня'],
});
const activeTab = ref<'received' | 'paid' | 'refused'>('received');
// SVG viewBox: 800x220, отступы 50/30/180/200 (left/top/bottom-padding-y).
const VIEW_W = 800;
const VIEW_H = 220;
const X_LEFT = 36;
const X_RIGHT = 790;
const Y_TOP = 20;
const Y_BOTTOM = 200;
const PLOT_W = X_RIGHT - X_LEFT;
const PLOT_H = Y_BOTTOM - Y_TOP;
// Координаты точек.
const coords = computed(() => {
const n = props.points.length;
return props.points.map((value, i) => {
const x = X_LEFT + (PLOT_W * i) / (n - 1);
const y = Y_TOP + PLOT_H - (PLOT_H * Math.max(0, Math.min(value, props.max))) / props.max;
return { x, y, value };
});
});
const linePath = computed(() => coords.value.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' '));
const areaPath = computed(() => `${linePath.value} L${X_RIGHT},${Y_BOTTOM} L${X_LEFT},${Y_BOTTOM} Z`);
// Y-сетка: 4 линии (0/15/30/45/60).
const ySteps = [0, 15, 30, 45, 60];
const yGrid = computed(() =>
ySteps.map((step) => ({
y: Y_TOP + PLOT_H - (PLOT_H * step) / props.max,
label: String(step),
})),
);
// Точка-«сегодня» — последняя, выделяется крупнее и в цвете primary.
const todayIndex = computed(() => coords.value.length - 2); // предпоследняя как «сегодня (мах)» по handoff
</script>
<template>
<v-card variant="outlined" class="activity-chart pa-0">
<div class="panel-head pa-4">
<div>
<h2 class="text-h6 panel-title ma-0">{{ props.title }}</h2>
<div class="text-body-2 text-medium-emphasis">{{ props.subtitle }}</div>
</div>
<v-btn-toggle v-model="activeTab" mandatory density="comfortable" variant="text" color="primary">
<v-btn value="received" size="small">Принято</v-btn>
<v-btn value="paid" size="small">Оплачено</v-btn>
<v-btn value="refused" size="small">Отказ</v-btn>
</v-btn-toggle>
</div>
<div class="chart-wrap px-4 pb-2">
<svg :viewBox="`0 0 ${VIEW_W} ${VIEW_H}`" preserveAspectRatio="none" :aria-label="props.title">
<!-- Y-сетка -->
<g class="y-grid">
<line
v-for="step in yGrid"
:key="step.label"
:x1="X_LEFT"
:y1="step.y"
:x2="X_RIGHT"
:y2="step.y"
stroke="#E8EBED"
/>
<text
v-for="step in yGrid"
:key="`l-${step.label}`"
:x="X_LEFT - 8"
:y="step.y + 4"
text-anchor="end"
class="chart-axis-y"
>
{{ step.label }}
</text>
</g>
<!-- Заливка под линией -->
<defs>
<linearGradient id="activity-area-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0A1319" stop-opacity="0.10" />
<stop offset="100%" stop-color="#0A1319" stop-opacity="0" />
</linearGradient>
</defs>
<path :d="areaPath" fill="url(#activity-area-grad)" />
<!-- Линия -->
<path
:d="linePath"
fill="none"
stroke="#0A1319"
stroke-width="1.7"
stroke-linejoin="round"
stroke-linecap="round"
/>
<!-- Точки -->
<g class="points">
<circle
v-for="(p, i) in coords"
:key="i"
:cx="p.x"
:cy="p.y"
:r="i === todayIndex ? 4.2 : 2.6"
:fill="i === todayIndex ? '#0F6E56' : '#FFFFFF'"
:stroke="i === todayIndex ? '#FFFFFF' : '#0A1319'"
:stroke-width="i === todayIndex ? 2 : 1.6"
>
<title>{{ props.labels[i] }}: {{ p.value }}</title>
</circle>
</g>
<!-- X-подписи -->
<g class="x-axis">
<text
v-for="(label, i) in props.labels"
:key="i"
:x="X_LEFT + (PLOT_W * i) / (props.labels.length - 1)"
:y="VIEW_H - 6"
text-anchor="middle"
class="chart-axis-x"
:class="{ today: i === todayIndex }"
>
{{ label }}
</text>
</g>
</svg>
</div>
<div class="chart-legend px-4 pb-3" aria-hidden="true">
<span><span class="ldot received" />принято</span>
<span><span class="ldot paid" />оплачено</span>
<span><span class="ldot refused" />отказ</span>
</div>
</v-card>
</template>
<style scoped>
.activity-chart {
background: #fff;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.panel-title {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.01em;
color: #081319;
}
.chart-wrap {
width: 100%;
}
.chart-wrap svg {
width: 100%;
height: 220px;
display: block;
}
.chart-axis-y,
.chart-axis-x {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
fill: #66635c;
}
.chart-axis-x.today {
fill: #0f6e56;
font-weight: 600;
}
.chart-legend {
display: flex;
gap: 20px;
color: #66635c;
font-size: 12px;
border-top: 1px solid #f0ede4;
padding-top: 12px;
}
.ldot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
vertical-align: -1px;
}
.ldot.received {
background: #0a1319;
}
.ldot.paid {
background: #0f6e56;
}
.ldot.refused {
background: #b94837;
}
</style>