feat: add interactive grafana-style flow chart
This commit is contained in:
parent
863d62fd88
commit
e5392369fc
|
|
@ -291,6 +291,37 @@ function chartPoints(field: "income" | "expense") {
|
|||
});
|
||||
}
|
||||
|
||||
const chartHoverIndex = ref<number | null>(null);
|
||||
const hoveredChartPoint = computed(() => {
|
||||
const index = chartHoverIndex.value;
|
||||
const item = index === null ? null : lineStats.value[index];
|
||||
if (!item || index === null) return null;
|
||||
const x = lineStats.value.length === 1 ? 168 : 32 + (index / (lineStats.value.length - 1)) * 272;
|
||||
return { ...item, x };
|
||||
});
|
||||
|
||||
const chartTooltipStyle = computed(() => {
|
||||
if (!hoveredChartPoint.value) return undefined;
|
||||
const percentage = (hoveredChartPoint.value.x / 320) * 100;
|
||||
return { left: `${Math.max(24, Math.min(76, percentage))}%` };
|
||||
});
|
||||
|
||||
function updateChartHover(event: PointerEvent) {
|
||||
const target = event.currentTarget as SVGElement | null;
|
||||
if (!target || !lineStats.value.length) return;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const viewBoxX = ((event.clientX - rect.left) / rect.width) * 320;
|
||||
const normalizedX = Math.max(32, Math.min(304, viewBoxX));
|
||||
const index = lineStats.value.length === 1
|
||||
? 0
|
||||
: Math.round(((normalizedX - 32) / 272) * (lineStats.value.length - 1));
|
||||
chartHoverIndex.value = Math.max(0, Math.min(lineStats.value.length - 1, index));
|
||||
}
|
||||
|
||||
function clearChartHover(event: PointerEvent) {
|
||||
if (event.pointerType !== "touch") chartHoverIndex.value = null;
|
||||
}
|
||||
|
||||
type CategorySummaryNode = {
|
||||
key: string;
|
||||
label: string;
|
||||
|
|
@ -639,7 +670,25 @@ function shiftCalendarYear(offset: -1 | 1) {
|
|||
<div class="daily-view-legend"><span class="income">收入</span><span class="expense">支出</span></div>
|
||||
</div>
|
||||
<div v-else class="daily-line-chart">
|
||||
<div v-if="lineStats.length" class="line-chart-wrap"><svg viewBox="0 0 320 190" role="img" aria-label="收支折线图"><line x1="32" y1="28" x2="32" y2="154" class="chart-axis-line" /><line x1="32" y1="154" x2="304" y2="154" class="chart-axis-line" /><line v-for="y in [28,70,112,154]" :key="y" x1="32" :y1="y" x2="304" :y2="y" class="chart-grid-line" /><text v-for="(tick, index) in [1, .66, .33, 0]" :key="tick" x="0" :y="[28,70,112,154][index] + 3" class="chart-axis-label">{{ compactDailyAmount(lineMaxAmount * tick) }}</text><polyline :points="linePoints('income')" class="chart-line income-line" /><polyline :points="linePoints('expense')" class="chart-line expense-line" /><circle v-for="point in chartPoints('income')" :key="point.key" :cx="point.x" :cy="point.y" r="3" class="chart-dot income-dot" /><circle v-for="point in chartPoints('expense')" :key="point.key" :cx="point.x" :cy="point.y" r="3" class="chart-dot expense-dot" /></svg><div class="line-chart-labels"><span>{{ lineStats[0]?.label }}</span><span>{{ lineStats.at(-1)?.label }}</span></div></div><div v-else class="daily-view-empty">暂无可绘制的流水</div>
|
||||
<div v-if="lineStats.length" class="line-chart-wrap">
|
||||
<div v-if="hoveredChartPoint" class="chart-tooltip" :style="chartTooltipStyle">
|
||||
<strong>{{ hoveredChartPoint.label }}</strong>
|
||||
<span><i class="income">收入</i><b>¥ {{ money(hoveredChartPoint.income) }}</b></span>
|
||||
<span><i class="expense">支出</i><b>¥ {{ money(hoveredChartPoint.expense) }}</b></span>
|
||||
<small>结余 ¥ {{ money(hoveredChartPoint.income - hoveredChartPoint.expense) }}</small>
|
||||
</div>
|
||||
<svg viewBox="0 0 320 190" role="img" aria-label="收支折线图" @pointermove="updateChartHover" @pointerdown="updateChartHover" @pointerleave="clearChartHover" @pointercancel="clearChartHover">
|
||||
<line x1="32" y1="28" x2="32" y2="154" class="chart-axis-line" /><line x1="32" y1="154" x2="304" y2="154" class="chart-axis-line" />
|
||||
<line v-for="y in [28,70,112,154]" :key="y" x1="32" :y1="y" x2="304" :y2="y" class="chart-grid-line" />
|
||||
<text v-for="(tick, index) in [1, .66, .33, 0]" :key="tick" x="0" :y="[28,70,112,154][index] + 3" class="chart-axis-label">{{ compactDailyAmount(lineMaxAmount * tick) }}</text>
|
||||
<line v-if="hoveredChartPoint" :x1="hoveredChartPoint.x" y1="28" :x2="hoveredChartPoint.x" y2="154" class="chart-crosshair" />
|
||||
<polyline :points="linePoints('income')" class="chart-line income-line" /><polyline :points="linePoints('expense')" class="chart-line expense-line" />
|
||||
<circle v-for="(point, index) in chartPoints('income')" :key="point.key" :cx="point.x" :cy="point.y" :r="chartHoverIndex === index ? 5 : 2.5" class="chart-dot income-dot" :class="{ active: chartHoverIndex === index }" />
|
||||
<circle v-for="(point, index) in chartPoints('expense')" :key="point.key" :cx="point.x" :cy="point.y" :r="chartHoverIndex === index ? 5 : 2.5" class="chart-dot expense-dot" :class="{ active: chartHoverIndex === index }" />
|
||||
<rect x="32" y="28" width="272" height="126" class="chart-hit-area" aria-label="查看详细数据" />
|
||||
</svg>
|
||||
<div class="line-chart-labels"><span>{{ lineStats[0]?.label }}</span><span>{{ lineStats.at(-1)?.label }}</span></div>
|
||||
</div><div v-else class="daily-view-empty">暂无可绘制的流水</div>
|
||||
<div class="daily-view-legend"><span class="income">收入</span><span class="expense">支出</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -761,17 +810,28 @@ function shiftCalendarYear(offset: -1 | 1) {
|
|||
.daily-view-legend { display:flex; justify-content:center; gap:15px; margin-top:10px; font-size:10px; }
|
||||
.daily-view-legend span::before { display:inline-block; width:7px; height:7px; margin-right:4px; border-radius:50%; background:currentColor; content:""; }
|
||||
.daily-line-chart { padding:12px; }
|
||||
.line-chart-wrap { min-width:0; }
|
||||
.line-chart-wrap svg { width:100%; height:auto; overflow:visible; }
|
||||
.line-chart-wrap { position:relative; min-width:0; }
|
||||
.line-chart-wrap svg { width:100%; height:auto; overflow:visible; touch-action:none; }
|
||||
.chart-axis-line { stroke:#aebdb8; stroke-width:1.2; }
|
||||
.chart-grid-line { stroke:#e6eeec; stroke-width:1; }
|
||||
.chart-axis-label { fill:#8a9994; font-size:9px; text-anchor:start; }
|
||||
.chart-crosshair { stroke:#667c75; stroke-width:1; stroke-dasharray:3 3; }
|
||||
.chart-line { fill:none; stroke-width:3; stroke-linecap:round; stroke-linejoin:round; }
|
||||
.income-line { stroke:#20a57d; }
|
||||
.expense-line { stroke:#ef6a4d; }
|
||||
.chart-dot { stroke:#fff; stroke-width:2; }
|
||||
.income-dot { fill:#20a57d; }
|
||||
.expense-dot { fill:#ef6a4d; }
|
||||
.chart-dot:not(.active) { opacity:.5; }
|
||||
.chart-hit-area { fill:transparent; cursor:crosshair; }
|
||||
.chart-tooltip { position:absolute; top:7px; z-index:2; display:grid; min-width:132px; gap:4px; transform:translateX(-50%); border:1px solid #435b54; border-radius:5px; padding:8px 9px; background:#263a35; color:#eaf4f1; box-shadow:0 5px 14px rgba(30,50,44,.2); pointer-events:none; }
|
||||
.chart-tooltip strong { color:#fff; font-size:11px; font-weight:720; white-space:nowrap; }
|
||||
.chart-tooltip span { display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:10px; }
|
||||
.chart-tooltip span i { font-style:normal; }
|
||||
.chart-tooltip span b { color:#fff; font-size:10px; font-variant-numeric:tabular-nums; }
|
||||
.chart-tooltip .income { color:#6ee0b5; }
|
||||
.chart-tooltip .expense { color:#ff9a83; }
|
||||
.chart-tooltip small { border-top:1px solid rgba(231,245,241,.15); padding-top:4px; color:#b9ccc6; font-size:9px; }
|
||||
.line-chart-labels { display:flex; justify-content:space-between; color:#8a9994; font-size:9px; }
|
||||
.daily-view-empty { min-height:180px; display:grid; place-items:center; color:#7b8884; font-size:12px; }
|
||||
.daily-stat-row { min-height: 58px; display: grid; grid-template-columns: 34px minmax(0,1fr) 82px; align-items: center; gap: 9px; border-bottom: 1px solid #edf2f0; padding: 8px 12px; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue