Files
lhbfx/frontend/src/components/StockActionTimelineChart.vue
2026-04-20 21:41:31 +08:00

511 lines
14 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 lang="ts">
import { computed, onMounted, onUnmounted, shallowRef, useTemplateRef } from 'vue'
import type { TraderAction } from '../types'
import { formatSignedWanAmount, formatWanAmount, numberFromText } from '../utils/format'
type AggregatedActionRow = {
trade_date: string
buyTotalWan: number
sellTotalWan: number
netTotalWan: number
cumulativeNetWan: number
traderCount: number
}
const props = defineProps<{
actions: TraderAction[]
}>()
const chartContainerRef = useTemplateRef<HTMLDivElement>('chartContainerRef')
const hoveredIndex = shallowRef<number | null>(null)
const containerWidth = shallowRef(0)
const aggregatedRows = computed<AggregatedActionRow[]>(() => {
const grouped = new Map<
string,
{
trade_date: string
buyTotalWan: number
sellTotalWan: number
netTotalWan: number
traders: Set<string>
}
>()
for (const action of props.actions) {
const buyTotalWan = numberFromText(action.buy_amount_wan) ?? 0
const sellTotalWan = numberFromText(action.sell_amount_wan) ?? 0
const netTotalWan = numberFromText(action.net_amount_wan) ?? buyTotalWan - sellTotalWan
const current = grouped.get(action.trade_date) ?? {
trade_date: action.trade_date,
buyTotalWan: 0,
sellTotalWan: 0,
netTotalWan: 0,
traders: new Set<string>(),
}
current.buyTotalWan += buyTotalWan
current.sellTotalWan += sellTotalWan
current.netTotalWan += netTotalWan
if (action.matched_trader_name) {
current.traders.add(action.matched_trader_name)
}
grouped.set(action.trade_date, current)
}
let cumulativeNetWan = 0
return [...grouped.values()]
.sort((left, right) => left.trade_date.localeCompare(right.trade_date))
.map((item) => {
cumulativeNetWan += item.netTotalWan
return {
trade_date: item.trade_date,
buyTotalWan: item.buyTotalWan,
sellTotalWan: item.sellTotalWan,
netTotalWan: item.netTotalWan,
cumulativeNetWan,
traderCount: item.traders.size,
}
})
})
const chartModel = computed(() => {
const rows = aggregatedRows.value
const measuredWidth = containerWidth.value || 0
const width = Math.max(measuredWidth, 320, rows.length * 54)
const height = 260
const left = 56
const right = 64
const top = 18
const bottom = 34
const innerWidth = width - left - right
const innerHeight = height - top - bottom
const emptyModel = {
width,
height,
left,
right,
top,
bottom,
zeroY: top + innerHeight / 2,
stepX: 0,
buyBars: [] as Array<{ x: number; y: number; height: number; title: string }>,
sellBars: [] as Array<{ x: number; y: number; height: number; title: string }>,
netPoints: [] as Array<{ x: number; y: number }>,
cumulativePoints: [] as Array<{ x: number; y: number }>,
labels: [] as Array<{ x: number; label: string; visible: boolean }>,
leftAxis: [] as Array<{ y: number; label: string }>,
hoverColumns: [] as Array<{ x: number; width: number }>,
}
if (!rows.length) {
return emptyModel
}
const valueMax = Math.max(
...rows.flatMap((row) => [
Math.abs(row.buyTotalWan),
Math.abs(row.sellTotalWan),
Math.abs(row.netTotalWan),
Math.abs(row.cumulativeNetWan),
]),
1,
)
const zeroY = top + innerHeight / 2
const yOfValue = (value: number) => zeroY - (value / valueMax) * (innerHeight / 2)
const stepX = rows.length === 1 ? innerWidth / 2 : innerWidth / (rows.length - 1)
const groupWidth = Math.max(20, Math.min(28, stepX * 0.58))
const barWidth = Math.max(7, groupWidth / 2 - 2)
const labelStep = Math.max(1, Math.ceil(rows.length / 6))
return {
width,
height,
left,
right,
top,
bottom,
zeroY,
stepX,
buyBars: rows.map((row, index) => {
const x = left + (rows.length === 1 ? innerWidth / 2 : index * stepX)
const y = yOfValue(row.buyTotalWan)
return {
x: x - barWidth - 2,
y,
height: Math.max(2, zeroY - y),
title: `${row.trade_date}\n买入 ${formatWanAmount(row.buyTotalWan)}\n卖出 ${formatWanAmount(row.sellTotalWan)}\n净额 ${formatSignedWanAmount(row.netTotalWan)}`,
}
}),
sellBars: rows.map((row, index) => {
const x = left + (rows.length === 1 ? innerWidth / 2 : index * stepX)
const y = zeroY
const sellY = yOfValue(-row.sellTotalWan)
return {
x: x + 2,
y,
height: Math.max(2, sellY - zeroY),
title: `${row.trade_date}\n买入 ${formatWanAmount(row.buyTotalWan)}\n卖出 ${formatWanAmount(row.sellTotalWan)}\n净额 ${formatSignedWanAmount(row.netTotalWan)}`,
}
}),
netPoints: rows.map((row, index) => ({
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX),
y: yOfValue(row.netTotalWan),
})),
cumulativePoints: rows.map((row, index) => ({
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX),
y: yOfValue(row.cumulativeNetWan),
})),
labels: rows.map((row, index) => ({
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX),
label: row.trade_date.slice(5),
visible: index % labelStep === 0 || index === rows.length - 1,
})),
leftAxis: Array.from({ length: 5 }, (_, index) => {
const value = valueMax - (valueMax * 2 * index) / 4
return { y: yOfValue(value), label: formatSignedWanAmount(value) }
}),
hoverColumns: rows.map((_row, index) => ({
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX) - Math.max(stepX, 20) / 2,
width: Math.max(stepX, 20),
})),
}
})
const netLinePoints = computed(() => chartModel.value.netPoints.map((point) => `${point.x},${point.y}`).join(' '))
const cumulativeLinePoints = computed(() =>
chartModel.value.cumulativePoints.map((point) => `${point.x},${point.y}`).join(' '),
)
const activeRow = computed(() => {
if (!aggregatedRows.value.length) return null
if (hoveredIndex.value === null) return null
return aggregatedRows.value[hoveredIndex.value] ?? null
})
const activeTooltip = computed(() => {
if (hoveredIndex.value === null || !activeRow.value) return null
const point = chartModel.value.netPoints[hoveredIndex.value]
if (!point) return null
const tooltipWidth = 168
const tooltipHeight = 90
const x = Math.min(
Math.max(point.x - tooltipWidth / 2, chartModel.value.left + 6),
chartModel.value.width - chartModel.value.right - tooltipWidth - 6,
)
const prefersBelow = point.y < chartModel.value.top + tooltipHeight + 24
const y = prefersBelow
? Math.min(point.y + 12, chartModel.value.height - chartModel.value.bottom - tooltipHeight - 6)
: Math.max(point.y - tooltipHeight - 12, chartModel.value.top + 6)
return {
x,
y,
width: tooltipWidth,
height: tooltipHeight,
}
})
function setHoveredIndex(index: number) {
hoveredIndex.value = index
}
function clearHoveredIndex() {
hoveredIndex.value = null
}
let resizeObserver: ResizeObserver | null = null
function syncContainerWidth() {
containerWidth.value = chartContainerRef.value?.clientWidth ?? 0
}
onMounted(() => {
syncContainerWidth()
if (chartContainerRef.value) {
resizeObserver = new ResizeObserver(() => {
syncContainerWidth()
})
resizeObserver.observe(chartContainerRef.value)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
resizeObserver = null
})
</script>
<template>
<div class="action-chart-panel">
<div class="action-chart-head">
<div>
<h4 class="action-chart-title">买卖力度趋势</h4>
<p class="action-chart-note">柱形图显示买卖折线显示单日净额和累计净额</p>
</div>
<div class="action-chart-legend">
<span class="legend-chip">
<span class="legend-swatch bar buy" />
买入
</span>
<span class="legend-chip">
<span class="legend-swatch bar sell" />
卖出
</span>
<span class="legend-chip">
<span class="legend-swatch line net" />
当日净额
</span>
<span class="legend-chip">
<span class="legend-swatch line cumulative" />
累计净额
</span>
</div>
</div>
<div v-if="aggregatedRows.length" class="action-chart-body">
<div ref="chartContainerRef" class="action-chart-scroll">
<svg
class="action-chart-svg"
:viewBox="`0 0 ${chartModel.width} ${chartModel.height}`"
:style="{ width: `${chartModel.width}px`, height: `${chartModel.height}px` }"
preserveAspectRatio="none"
@mouseleave="clearHoveredIndex"
>
<g opacity="0.08" stroke="#ffffff">
<line
v-for="tick in chartModel.leftAxis"
:key="`left-grid-${tick.label}`"
:x1="chartModel.left"
:y1="tick.y"
:x2="chartModel.width - chartModel.right"
:y2="tick.y"
/>
</g>
<line
:x1="chartModel.left"
:y1="chartModel.zeroY"
:x2="chartModel.width - chartModel.right"
:y2="chartModel.zeroY"
stroke="rgba(255,255,255,0.22)"
stroke-width="1.2"
/>
<g v-for="(tick, index) in chartModel.leftAxis" :key="`left-axis-${index}`">
<text x="0" :y="tick.y + 4" fill="#93a2b5" font-size="10">{{ tick.label }}</text>
</g>
<g v-for="(column, index) in chartModel.hoverColumns" :key="`hover-${index}`">
<rect
:x="column.x"
y="0"
:width="column.width"
:height="chartModel.height"
:fill="hoveredIndex === index ? 'rgba(255,255,255,0.04)' : 'transparent'"
@mouseenter="setHoveredIndex(index)"
/>
</g>
<g v-for="(bar, index) in chartModel.buyBars" :key="`buy-${index}`">
<rect
:x="bar.x"
:y="bar.y"
width="10"
:height="bar.height"
rx="2"
fill="#ff7b7b"
>
<title>{{ bar.title }}</title>
</rect>
</g>
<g v-for="(bar, index) in chartModel.sellBars" :key="`sell-${index}`">
<rect
:x="bar.x"
:y="bar.y"
width="10"
:height="bar.height"
rx="2"
fill="#4ca8ff"
>
<title>{{ bar.title }}</title>
</rect>
</g>
<polyline
fill="none"
stroke="#f0c96a"
stroke-width="2.4"
:points="cumulativeLinePoints"
/>
<polyline
fill="none"
stroke="#7de1b2"
stroke-width="2.2"
:points="netLinePoints"
/>
<g v-for="(point, index) in chartModel.netPoints" :key="`net-point-${index}`">
<circle :cx="point.x" :cy="point.y" r="3.4" fill="#7de1b2" />
</g>
<g v-for="(point, index) in chartModel.cumulativePoints" :key="`cumulative-point-${index}`">
<circle :cx="point.x" :cy="point.y" r="3.2" fill="#f0c96a" />
</g>
<g v-if="activeTooltip && activeRow">
<rect
:x="activeTooltip.x"
:y="activeTooltip.y"
:width="activeTooltip.width"
:height="activeTooltip.height"
rx="10"
fill="rgba(8, 12, 18, 0.96)"
stroke="rgba(255,255,255,0.08)"
/>
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 18" fill="#ffffff" font-size="11" font-weight="700">
{{ activeRow.trade_date }}
</text>
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 36" fill="#ff7b7b" font-size="11">
买入 {{ formatWanAmount(activeRow.buyTotalWan) }}
</text>
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 52" fill="#4ca8ff" font-size="11">
卖出 {{ formatWanAmount(activeRow.sellTotalWan) }}
</text>
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 68" fill="#7de1b2" font-size="11">
当日净额 {{ formatSignedWanAmount(activeRow.netTotalWan) }}
</text>
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 84" fill="#f0c96a" font-size="11">
累计净额 {{ formatSignedWanAmount(activeRow.cumulativeNetWan) }}
</text>
</g>
<g v-for="(label, index) in chartModel.labels" :key="`label-${index}`">
<text
v-if="label.visible"
:x="label.x"
:y="chartModel.height - 10"
text-anchor="middle"
fill="#93a2b5"
font-size="10"
>
{{ label.label }}
</text>
</g>
</svg>
</div>
</div>
<p v-else class="action-chart-empty">暂无可展示的买卖明细</p>
</div>
</template>
<style scoped>
.action-chart-panel {
display: grid;
gap: 10px;
height: 100%;
min-height: 0;
}
.action-chart-head {
display: grid;
gap: 10px;
}
.action-chart-title {
margin: 0;
font-size: 15px;
}
.action-chart-note {
margin: 6px 0 0;
color: var(--color-muted);
font-size: 12px;
line-height: 1.55;
}
.action-chart-legend {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.legend-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--color-muted);
font-size: 11px;
background: rgba(255, 255, 255, 0.03);
}
.legend-swatch {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
width: 18px;
height: 10px;
}
.legend-swatch.bar {
border-radius: 999px;
}
.legend-swatch.buy {
background: #ff7b7b;
}
.legend-swatch.sell {
background: #4ca8ff;
}
.legend-swatch.line::before {
content: '';
width: 18px;
height: 2px;
border-radius: 999px;
}
.legend-swatch.line.net::before {
background: #7de1b2;
}
.legend-swatch.line.cumulative::before {
background: #f0c96a;
}
.action-chart-body {
display: grid;
gap: 8px;
min-height: 0;
}
.action-chart-scroll {
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 2px;
min-height: 228px;
}
.action-chart-svg {
display: block;
min-height: 228px;
}
.action-chart-empty {
margin: 0;
color: var(--color-muted);
font-size: 12px;
line-height: 1.65;
}
</style>