优化:折线图的显示宽度优化

This commit is contained in:
ivan 2026-03-31 21:31:38 +08:00
parent 062e370bea
commit fef3d86fc2
5 changed files with 742 additions and 384 deletions

View File

@ -278,11 +278,21 @@ interface BinanceAggTradeMsg {
const CRYPTO_UPDATE_THROTTLE_MS = 80
/** 加密货币分时折线图 X 轴 / 补点统一时间步长ms与图表 timeScale 一致 */
export const CRYPTO_CHART_TIME_STEP_MS = 100
/** 将毫秒时间戳对齐到 {@link CRYPTO_CHART_TIME_STEP_MS} 网格(向下取整,单调递增友好) */
export function alignCryptoChartTimeMs(tsMs: number): number {
const step = CRYPTO_CHART_TIME_STEP_MS
return Math.floor(tsMs / step) * step
}
/**
* Binance WebSocketaggTrade
* https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/web-socket-streams#归集交易
* - aggTrade kline_1m 1s
* - setOption
* - {@link CRYPTO_CHART_TIME_STEP_MS}ms aggTrade
*/
export function subscribeCryptoRealtime(
symbol: string,
@ -294,14 +304,23 @@ export function subscribeCryptoRealtime(
const stream = `${binanceSymbol.toLowerCase()}@aggTrade`
const ws = new WebSocket(`${BINANCE_WS}/${stream}`)
let disposed = false
let lastUpdateTs = 0
let pendingPoint: CryptoChartPoint | null = null
let rafId: number | null = null
let throttleTimer: ReturnType<typeof setTimeout> | null = null
/** 本 hold 周期内是否收到过真实 aggTrade用于持价补点 */
let hadAggTradeSinceLastHoldTick = false
/** 最近一笔有效成交价,供无推送时重复输出 */
let lastTradedPrice: number | null = null
const flush = () => {
rafId = null
throttleTimer = null
if (disposed) {
pendingPoint = null
return
}
if (pendingPoint) {
onPoint(pendingPoint)
pendingPoint = null
@ -323,7 +342,16 @@ export function subscribeCryptoRealtime(
}
}
const holdTimer = setInterval(() => {
if (disposed) return
if (!hadAggTradeSinceLastHoldTick && lastTradedPrice != null) {
onPoint([alignCryptoChartTimeMs(Date.now()), lastTradedPrice])
}
hadAggTradeSinceLastHoldTick = false
}, CRYPTO_CHART_TIME_STEP_MS)
ws.onmessage = (event) => {
if (disposed) return
try {
const msg = JSON.parse((event as MessageEvent).data) as BinanceAggTradeMsg & { e?: string }
if (msg.e === 'ping') {
@ -335,9 +363,11 @@ export function subscribeCryptoRealtime(
if (p == null || T == null) return
const price = parseFloat(p)
if (!Number.isFinite(price)) return
const ts = T < 1e12 ? T * 1000 : T
const point: CryptoChartPoint = [ts, price]
const rawTs = T < 1e12 ? T * 1000 : T
const point: CryptoChartPoint = [alignCryptoChartTimeMs(rawTs), price]
hadAggTradeSinceLastHoldTick = true
lastTradedPrice = price
pendingPoint = point
const now = Date.now()
if (now - lastUpdateTs >= CRYPTO_UPDATE_THROTTLE_MS) {
@ -352,6 +382,9 @@ export function subscribeCryptoRealtime(
}
return () => {
disposed = true
pendingPoint = null
clearInterval(holdTimer)
if (rafId) cancelAnimationFrame(rafId)
if (throttleTimer) clearTimeout(throttleTimer)
ws.close()

View File

@ -0,0 +1,160 @@
/**
* Y
* lightweight-charts plugin-examples/overlay-price-scale
*/
import type { CanvasRenderingTarget2D } from 'fancy-canvas'
import type {
BarPrice,
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
IPriceFormatter,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts'
export interface InPanePriceScaleOptions {
textColor: string
side: 'left' | 'right'
/** 相邻刻度最小间距(像素) */
tickSpacingPx: number
fontSizePx: number
/** 与绘图区边缘的水平间距 */
sideMarginPx: number
}
const defaultOptions: InPanePriceScaleOptions = {
textColor: 'rgba(107, 114, 128, 0.65)',
side: 'right',
tickSpacingPx: 44,
fontSizePx: 9,
sideMarginPx: 8,
}
interface Label {
label: string
y: number
}
interface RendererData {
priceFormatter: IPriceFormatter
coordinateToPrice: (y: number) => BarPrice | null
options: InPanePriceScaleOptions
}
function barPriceToNumber(p: BarPrice | null): number | null {
if (p == null) return null
const n = typeof p === 'number' ? p : Number(p)
return Number.isFinite(n) ? n : null
}
class InPanePriceScaleRenderer implements IPrimitivePaneRenderer {
private _data: RendererData | null = null
update(data: RendererData) {
this._data = data
}
draw(target: CanvasRenderingTarget2D) {
target.useMediaCoordinateSpace((scope) => {
if (!this._data) return
const { options } = this._data
const labels = this._labelsForHeight(scope.mediaSize.height, this._data)
if (labels.length === 0) return
const ctx = scope.context
ctx.font = `${options.fontSizePx}px inherit`
const isLeft = options.side === 'left'
ctx.textAlign = isLeft ? 'left' : 'right'
ctx.textBaseline = 'middle'
for (const item of labels) {
const textX = isLeft
? options.sideMarginPx
: scope.mediaSize.width - options.sideMarginPx
ctx.fillStyle = options.textColor
ctx.fillText(item.label, textX, item.y)
}
})
}
private _labelsForHeight(height: number, data: RendererData): Label[] {
const { coordinateToPrice, priceFormatter, options } = data
const spacing = options.tickSpacingPx
const half = Math.max(4, Math.round(spacing / 4))
const ys: number[] = []
for (let y = half; y <= height - half; y += spacing) {
ys.push(y)
}
const out: Label[] = []
for (const y of ys) {
const price = barPriceToNumber(coordinateToPrice(y))
if (price == null) continue
out.push({ label: priceFormatter.format(price), y })
}
return out
}
}
class InPanePriceScalePaneView implements IPrimitivePaneView {
private readonly _renderer: InPanePriceScaleRenderer
constructor() {
this._renderer = new InPanePriceScaleRenderer()
}
zOrder(): 'top' {
return 'top'
}
renderer(): IPrimitivePaneRenderer {
return this._renderer
}
update(data: RendererData) {
this._renderer.update(data)
}
}
export class InPanePriceScalePrimitive implements ISeriesPrimitive<Time> {
private readonly _paneViews: InPanePriceScalePaneView[]
private _options: InPanePriceScaleOptions
private _series: SeriesAttachedParameter<Time>['series'] | null = null
private _requestUpdate: (() => void) | undefined
constructor(options?: Partial<InPanePriceScaleOptions>) {
this._options = { ...defaultOptions, ...options }
this._paneViews = [new InPanePriceScalePaneView()]
}
applyOptions(options: Partial<InPanePriceScaleOptions>) {
this._options = { ...this._options, ...options }
this._requestUpdate?.()
}
attached(param: SeriesAttachedParameter<Time>): void {
this._series = param.series
this._requestUpdate = param.requestUpdate
}
detached(): void {
this._series = null
this._requestUpdate = undefined
}
updateAllViews(): void {
const s = this._series
if (!s) return
const data: RendererData = {
priceFormatter: s.priceFormatter(),
coordinateToPrice: (y) => s.coordinateToPrice(y),
options: this._options,
}
this._paneViews[0]!.update(data)
}
paneViews(): readonly IPrimitivePaneView[] {
return this._paneViews
}
}

View File

@ -30,10 +30,14 @@
{{ categoryText }}
</p>
<div class="chart-controls-row">
<v-btn variant="text" size="small" class="past-btn">{{ t('eventMarkets.past') }}</v-btn>
<v-btn variant="text" size="small" class="past-btn">{{
t('eventMarkets.past')
}}</v-btn>
<v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
</div>
<p class="chart-legend-hint">{{ t('eventMarkets.marketsCount', { n: markets.length }) }}</p>
<p class="chart-legend-hint">
{{ t('eventMarkets.marketsCount', { n: markets.length }) }}
</p>
</div>
<div class="chart-wrapper">
<div v-if="chartLoading" class="chart-loading-overlay">
@ -101,7 +105,9 @@
@click="goToTradeDetail(market)"
>
<div class="market-row-main">
<span class="market-question">{{ market.question || t('eventMarkets.marketPlaceholder') }}</span>
<span class="market-question">{{
market.question || t('eventMarkets.marketPlaceholder')
}}</span>
<span class="market-chance">{{ marketChance(market) }}%</span>
</div>
<div class="market-row-vol">{{ formatVolume(market.volume) }}</div>
@ -152,7 +158,8 @@
rounded="sm"
@click="openSheetWithOption('yes')"
>
{{ barMarket ? yesLabel(barMarket) : t('eventMarkets.yes') }} {{ barMarket ? yesPrice(barMarket) : t('eventMarkets.priceZero') }}
{{ barMarket ? yesLabel(barMarket) : t('eventMarkets.yes') }}
{{ barMarket ? yesPrice(barMarket) : t('eventMarkets.priceZero') }}
</v-btn>
<v-btn
class="mobile-bar-btn mobile-bar-no"
@ -160,7 +167,8 @@
rounded="sm"
@click="openSheetWithOption('no')"
>
{{ barMarket ? noLabel(barMarket) : t('eventMarkets.no') }} {{ barMarket ? noPrice(barMarket) : t('eventMarkets.priceZero') }}
{{ barMarket ? noLabel(barMarket) : t('eventMarkets.no') }}
{{ barMarket ? noPrice(barMarket) : t('eventMarkets.priceZero') }}
</v-btn>
<v-menu
v-model="mobileMenuOpen"
@ -215,7 +223,14 @@ defineOptions({ name: 'EventMarkets' })
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDisplay } from 'vuetify'
import { createChart, LineSeries, LineType, LastPriceAnimationMode, type IChartApi, type ISeriesApi } from 'lightweight-charts'
import {
createChart,
LineSeries,
LineType,
LastPriceAnimationMode,
type IChartApi,
type ISeriesApi,
} from 'lightweight-charts'
import { toLwcData } from '../composables/useLightweightChart'
import TradeComponent from '../components/TradeComponent.vue'
import {
@ -226,7 +241,11 @@ import {
type PmEventListItem,
type PmEventMarketItem,
} from '../api/event'
import { getPmPriceHistoryPublic, priceHistoryToChartData, getTimeRangeSeconds } from '../api/priceHistory'
import {
getPmPriceHistoryPublic,
priceHistoryToChartData,
getTimeRangeSeconds,
} from '../api/priceHistory'
import { getMockEventById } from '../api/mockData'
import { USE_MOCK_EVENT } from '../config/mock'
import { useI18n } from 'vue-i18n'
@ -391,7 +410,10 @@ async function loadChartFromApi(): Promise<ChartSeriesItem[]> {
}
try {
const ev = eventDetail.value
const timeRange = getTimeRangeSeconds(range, ev ? { startDate: ev.startDate, endDate: ev.endDate } : undefined)
const timeRange = getTimeRangeSeconds(
range,
ev ? { startDate: ev.startDate, endDate: ev.endDate } : undefined,
)
const res = await getPmPriceHistoryPublic({
market: yesTokenId,
page: 1,
@ -416,7 +438,7 @@ function setChartSeries(seriesArr: ChartSeriesItem[]) {
const series = chartInstance!.addSeries(LineSeries, {
color,
lineWidth: 2,
lineType: LineType.Simple,
lineType: LineType.Curved,
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
crosshairMarkerVisible: true,
lastValueVisible: true,
@ -630,7 +652,9 @@ onMounted(() => {
})
let tradeSheetUnmountTimer: ReturnType<typeof setTimeout> | undefined
let tradeSheetMountTimer: ReturnType<typeof setTimeout> | undefined
watch(tradeSheetOpen, (open) => {
watch(
tradeSheetOpen,
(open) => {
if (open) {
if (tradeSheetUnmountTimer) {
clearTimeout(tradeSheetUnmountTimer)
@ -662,7 +686,9 @@ watch(tradeSheetOpen, (open) => {
tradeSheetUnmountTimer = undefined
}, 350)
}
}, { immediate: true })
},
{ immediate: true },
)
onUnmounted(() => {
window.removeEventListener('resize', handleResize)

View File

@ -3,7 +3,12 @@
<v-pull-to-refresh class="trade-detail-pull-refresh" @load="onRefresh">
<div class="trade-detail-pull-refresh-inner">
<!-- findPmEvent 请求中仅显示 loading -->
<v-card v-if="detailLoading && !eventDetail" class="trade-detail-loading-card" elevation="0" rounded="lg">
<v-card
v-if="detailLoading && !eventDetail"
class="trade-detail-loading-card"
elevation="0"
rounded="lg"
>
<div class="trade-detail-loading-placeholder">
<v-progress-circular indeterminate color="primary" size="48" />
<p>{{ t('common.loading') }}</p>
@ -90,7 +95,11 @@
<!-- 持仓 / 限价订单簿上方 -->
<v-card class="positions-orders-card" elevation="0" rounded="lg">
<v-tabs v-model="positionsOrdersTab" class="positions-orders-tabs" density="comfortable">
<v-tabs
v-model="positionsOrdersTab"
class="positions-orders-tabs"
density="comfortable"
>
<v-tab value="positions">{{ t('activity.myPositions') }}</v-tab>
<v-tab value="orders">{{ t('activity.openOrders') }}</v-tab>
</v-tabs>
@ -104,7 +113,11 @@
{{ t('activity.noPositionsInMarket') }}
</div>
<div v-else class="positions-list">
<div v-for="pos in marketPositionsFiltered" :key="pos.id" class="position-row-item">
<div
v-for="pos in marketPositionsFiltered"
:key="pos.id"
class="position-row-item"
>
<div class="position-row-header">
<div class="position-row-icon" :class="pos.iconClass">
<img
@ -113,7 +126,9 @@
alt=""
class="position-row-icon-img"
/>
<span v-else class="position-row-icon-char">{{ pos.iconChar || '•' }}</span>
<span v-else class="position-row-icon-char">{{
pos.iconChar || '•'
}}</span>
</div>
<span class="position-row-title">{{ pos.market }}</span>
</div>
@ -135,7 +150,9 @@
size="small"
color="primary"
class="position-sell-btn"
:disabled="!(pos.availableSharesNum != null && pos.availableSharesNum > 0)"
:disabled="
!(pos.availableSharesNum != null && pos.availableSharesNum > 0)
"
@click="openSellFromPosition(pos)"
>
{{ t('trade.sell') }}
@ -156,7 +173,9 @@
<div v-else class="orders-list">
<div v-for="ord in marketOpenOrders" :key="ord.id" class="order-row-item">
<div class="order-row-main">
<span :class="['order-side-pill', ord.side === 'Yes' ? 'side-yes' : 'side-no']">
<span
:class="['order-side-pill', ord.side === 'Yes' ? 'side-yes' : 'side-no']"
>
{{ ord.actionLabel || `Buy ${ord.outcome}` }}
</span>
<span class="order-price">{{ ord.price }}</span>
@ -207,7 +226,10 @@
<!-- Comments / Top Holders / Activity与左侧图表订单簿同宽 -->
<v-card class="activity-card" elevation="0" rounded="lg">
<div class="rules-pane">
<div v-if="!eventDetail?.description && !eventDetail?.resolutionSource" class="placeholder-pane">
<div
v-if="!eventDetail?.description && !eventDetail?.resolutionSource"
class="placeholder-pane"
>
{{ t('activity.rulesEmpty') }}
</div>
<template v-else>
@ -346,10 +368,13 @@ import {
LineSeries,
LineType,
LastPriceAnimationMode,
TickMarkType,
type IChartApi,
type ISeriesApi,
type Time,
} from 'lightweight-charts'
import { toLwcData, toLwcPoint } from '../composables/useLightweightChart'
import { InPanePriceScalePrimitive } from '../composables/inPanePriceScalePrimitive'
import OrderBook from '../components/OrderBook.vue'
import TradeComponent, { type TradePositionItem } from '../components/TradeComponent.vue'
import {
@ -387,6 +412,8 @@ import {
inferCryptoSymbol,
fetchCryptoChart,
subscribeCryptoRealtime,
alignCryptoChartTimeMs,
CRYPTO_CHART_TIME_STEP_MS,
} from '../api/cryptoChart'
const { t } = useI18n()
@ -502,11 +529,9 @@ async function loadEventDetail() {
}
function onRefresh({ done }: { done: () => void }) {
Promise.all([
loadEventDetail(),
loadMarketPositions(),
loadMarketOpenOrders(),
]).finally(() => done())
Promise.all([loadEventDetail(), loadMarketPositions(), loadMarketOpenOrders()]).finally(() =>
done(),
)
}
// query
@ -1174,6 +1199,8 @@ const cryptoChartLoading = ref(false)
const chartYesNoLoading = ref(false)
let chartInstance: IChartApi | null = null
let chartSeries: ISeriesApi<'Line'> | null = null
/** 主图内自绘 Y 轴刻度(不占用右侧价轴条带) */
let inPanePriceScale: InPanePriceScalePrimitive | null = null
const currentChance = computed(() => {
const ev = eventDetail.value
@ -1195,11 +1222,18 @@ const currentChance = computed(() => {
const lineColor = '#2563eb'
const MOBILE_BREAKPOINT = 600
/** 叠加价格轴 ID曲线绑定在 overlay scale主图占满宽Y 轴数字由 InPanePriceScalePrimitive 画在图内 */
const CHART_OVERLAY_PRICE_SCALE_ID = 'overlay-price'
/** 右侧留白pxfitContent 时压缩可用宽度线头略离开物理右缘lastPrice 水波能露出一半且不依赖超大 rightOffsetbars */
const CHART_RIPPLE_RIGHT_PAD_PX = 28
function ensureChartSeries() {
if (!chartInstance || !chartContainerRef.value) return
if (chartSeries) {
if (inPanePriceScale) {
chartSeries.detachPrimitive(inPanePriceScale)
inPanePriceScale = null
}
chartInstance.removeSeries(chartSeries)
chartSeries = null
}
@ -1207,12 +1241,26 @@ function ensureChartSeries() {
chartSeries = chartInstance.addSeries(LineSeries, {
color: lineColor,
lineWidth: 2,
lineType: LineType.Simple,
lineType: LineType.Curved,
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
crosshairMarkerVisible: true,
lastValueVisible: true,
priceFormat: isCrypto ? { type: 'price', precision: 2 } : { type: 'percent', precision: 1 },
priceScaleId: CHART_OVERLAY_PRICE_SCALE_ID,
})
chartInstance.priceScale(CHART_OVERLAY_PRICE_SCALE_ID).applyOptions({
borderVisible: false,
scaleMargins: { top: 0.1, bottom: 0.1 },
})
inPanePriceScale = new InPanePriceScalePrimitive({
textColor: 'rgba(107, 114, 128, 0.6)',
side: 'right',
tickSpacingPx: isCrypto ? 40 : 48,
fontSizePx: 9,
sideMarginPx: 6,
})
chartSeries.attachPrimitive(inPanePriceScale)
applyCryptoChartTimeScaleOptions()
}
function setChartData(chartData: [number, number][]) {
@ -1226,7 +1274,7 @@ function initChart() {
const el = chartContainerRef.value
chartInstance = createChart(el, {
width: el.clientWidth,
height: 320,
height: el.clientHeight || 320,
layout: {
background: { color: '#ffffff' },
textColor: '#6b7280',
@ -1235,8 +1283,19 @@ function initChart() {
attributionLogo: false,
},
grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } },
rightPriceScale: { borderColor: '#e5e7eb', scaleMargins: { top: 0.1, bottom: 0.1 } },
timeScale: { borderColor: '#e5e7eb', timeVisible: true, secondsVisible: false },
leftPriceScale: { visible: false },
// Y InPanePriceScalePrimitive
rightPriceScale: { visible: false },
overlayPriceScales: {
borderVisible: false,
scaleMargins: { top: 0.1, bottom: 0.1 },
},
timeScale: {
borderColor: '#e5e7eb',
timeVisible: true,
secondsVisible: false,
rightOffsetPixels: CHART_RIPPLE_RIGHT_PAD_PX,
},
handleScroll: false,
handleScale: false,
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
@ -1273,17 +1332,72 @@ async function loadChartFromApi(marketParam: string, range: string): Promise<Cha
const MINUTE_MS = 60 * 1000
let cryptoWsUnsubscribe: (() => void) | null = null
/** 防止重叠的 updateChartData旧请求若在晚请求已 subscribe 之后才结束,会再挂一层 WS/hold 定时器,插值越来越快 */
let cryptoLoadGeneration = 0
let cryptoScrollToRtRaf = 0
/** 每帧最多跟一次盘避免插值过密时线头跑出视口shiftVisibleRangeOnNewBar 对 update 新点偶发不跟) */
function scheduleCryptoScrollToRealtime() {
if (!chartInstance || chartMode.value !== 'crypto') return
if (cryptoScrollToRtRaf) return
cryptoScrollToRtRaf = requestAnimationFrame(() => {
cryptoScrollToRtRaf = 0
chartInstance?.timeScale().scrollToRealTime()
})
}
/** 加密货币细粒度推送:显示到秒+毫秒;新点推入时视窗右移并每帧 scrollToRealTime 咬住线头 */
function applyCryptoChartTimeScaleOptions() {
if (!chartInstance) return
if (chartMode.value === 'crypto') {
chartInstance.applyOptions({
timeScale: {
rightOffsetPixels: CHART_RIPPLE_RIGHT_PAD_PX,
secondsVisible: true,
shiftVisibleRangeOnNewBar: true,
tickMarkFormatter: (time: Time, tickMarkType: TickMarkType) => {
if (typeof time !== 'number') return null
if (tickMarkType !== TickMarkType.TimeWithSeconds) return null
const d = new Date(time * 1000)
const pad2 = (n: number) => String(n).padStart(2, '0')
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`
},
},
})
} else {
chartInstance.applyOptions({
timeScale: {
rightOffsetPixels: CHART_RIPPLE_RIGHT_PAD_PX,
secondsVisible: false,
shiftVisibleRangeOnNewBar: true,
tickMarkFormatter: () => null,
},
})
}
}
function applyCryptoRealtimePoint(point: [number, number]) {
if (chartMode.value !== 'crypto') return
const list = [...data.value]
const [ts, price] = point
let ts = alignCryptoChartTimeMs(point[0])
const price = point[1]
const prevLastT = list.length > 0 ? list[list.length - 1]![0] : null
// now Binance TT update() Cannot update oldest data
if (prevLastT != null && ts < prevLastT) {
ts = alignCryptoChartTimeMs(prevLastT + CRYPTO_CHART_TIME_STEP_MS)
}
const range = selectedTimeRange.value
let useIncrementalUpdate = false
let updatePoint: [number, number] | null = null
if (range === '30S') {
const cutoff = Date.now() - 30 * 1000
if (prevLastT != null && ts === prevLastT) {
list[list.length - 1] = [ts, price]
} else {
list.push([ts, price])
}
const filtered = list.filter(([t]) => t >= cutoff).sort((a, b) => a[0] - b[0])
data.value = filtered
// update
@ -1293,7 +1407,8 @@ function applyCryptoRealtimePoint(point: [number, number]) {
const last = list[list.length - 1]
const minuteStart = Math.floor(ts / MINUTE_MS) * MINUTE_MS
const sameMinute = last && Math.floor(last[0] / MINUTE_MS) === Math.floor(ts / MINUTE_MS)
const baseMax = { '1H': 60, '6H': 360, '1D': 1440, '1W': 1680, '1M': 43200, ALL: 10080 }[range] ?? 60
const baseMax =
{ '1H': 60, '6H': 360, '1D': 1440, '1W': 1680, '1M': 43200, ALL: 10080 }[range] ?? 60
const extra = Math.min(200, Math.round(baseMax * 0.1))
const max = baseMax + extra
if (sameMinute) {
@ -1316,11 +1431,13 @@ function applyCryptoRealtimePoint(point: [number, number]) {
} else {
setChartData(data.value)
}
scheduleCryptoScrollToRealtime()
}
async function updateChartData() {
if (chartMode.value === 'crypto') {
const sym = cryptoSymbol.value ?? 'btc'
const gen = ++cryptoLoadGeneration
cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null
cryptoChartLoading.value = true
@ -1329,14 +1446,23 @@ async function updateChartData() {
symbol: sym,
range: selectedTimeRange.value,
})
if (gen !== cryptoLoadGeneration || chartMode.value !== 'crypto') {
return
}
data.value = (res.data ?? []) as [number, number][]
ensureChartSeries()
setChartData(data.value)
nextTick(() => scheduleCryptoScrollToRealtime())
cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null
cryptoWsUnsubscribe = subscribeCryptoRealtime(sym, applyCryptoRealtimePoint)
} finally {
if (gen === cryptoLoadGeneration) {
cryptoChartLoading.value = false
}
}
} else {
cryptoLoadGeneration++
cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null
chartYesNoLoading.value = true
@ -1388,7 +1514,8 @@ watch(
const handleResize = () => {
if (!chartInstance || !chartContainerRef.value) return
chartInstance.resize(chartContainerRef.value.clientWidth, 320)
const el = chartContainerRef.value
chartInstance.resize(el.clientWidth, el.clientHeight || 320)
}
watch(
@ -1443,10 +1570,15 @@ onUnmounted(() => {
unsubscribePositionUpdate()
cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null
if (cryptoScrollToRtRaf) {
cancelAnimationFrame(cryptoScrollToRtRaf)
cryptoScrollToRtRaf = 0
}
window.removeEventListener('resize', handleResize)
chartInstance?.remove()
chartInstance = null
chartSeries = null
inPanePriceScale = null
disconnectClob()
})
</script>

View File

@ -164,12 +164,7 @@
>
<div class="design-order-top">
<div class="order-mobile-icon" :class="ord.iconClass">
<img
v-if="ord.imageUrl"
:src="ord.imageUrl"
alt=""
class="position-icon-img"
/>
<img v-if="ord.imageUrl" :src="ord.imageUrl" alt="" class="position-icon-img" />
<span v-else class="position-icon-char">{{ ord.iconChar || '•' }}</span>
</div>
<div class="order-mobile-main design-order-title-col">
@ -182,9 +177,11 @@
<span class="order-side-pill" :class="getOrderSideClass(ord)">{{
getOrderActionLabel(ord)
}}</span>
<span class="order-outcome-pill" :class="ord.side === 'Yes' ? 'outcome-yes' : 'outcome-no'">{{
ord.side
}}</span>
<span
class="order-outcome-pill"
:class="ord.side === 'Yes' ? 'outcome-yes' : 'outcome-no'"
>{{ ord.side }}</span
>
</div>
</div>
<v-btn
@ -267,9 +264,8 @@
{{ getHistoryTagLabel(h) }}
</span>
<span class="design-history-meta"
>{{ t('wallet.priceLabel') }}: {{ h.avgPrice || '—' }} · {{ t('wallet.sharesLabel') }}: {{
h.shares || '—'
}}</span
>{{ t('wallet.priceLabel') }}: {{ h.avgPrice || '—' }} ·
{{ t('wallet.sharesLabel') }}: {{ h.shares || '—' }}</span
>
</div>
</template>
@ -280,7 +276,11 @@
<span v-else class="position-icon-char">{{ getFundingIconText(h) }}</span>
</div>
<div class="history-mobile-main">
<MarqueeTitle :text="getFundingTitle(h)" title-class="history-mobile-title" fast />
<MarqueeTitle
:text="getFundingTitle(h)"
title-class="history-mobile-title"
fast
/>
<div class="history-mobile-activity">{{ h.timeAgo || '—' }}</div>
</div>
</div>
@ -441,7 +441,11 @@
</div>
</div>
<h3 class="sell-dialog-title">
{{ t('wallet.sellDialogTitle', { outcome: sellPositionItem.sellOutcome || t('wallet.position') }) }}
{{
t('wallet.sellDialogTitle', {
outcome: sellPositionItem.sellOutcome || t('wallet.position'),
})
}}
</h3>
<p class="sell-dialog-market">{{ sellPositionItem.market }}</p>
<div class="sell-receive-box">
@ -456,7 +460,9 @@
<v-btn color="success" variant="flat" block class="sell-redeem-btn" @click="redeemSell">
{{ t('wallet.sellDialogRedeem') }}
</v-btn>
<a href="#" class="sell-edit-link" @click.prevent="editSellOrder">{{ t('wallet.sellDialogEditOrder') }}</a>
<a href="#" class="sell-edit-link" @click.prevent="editSellOrder">{{
t('wallet.sellDialogEditOrder')
}}</a>
</v-card-actions>
</v-card>
</v-dialog>
@ -687,10 +693,7 @@ interface HistoryItem {
detailMarketId?: string
}
function canOpenTradeDetail(opts: {
tradeEventId?: string
tradeEventSlug?: string
}): boolean {
function canOpenTradeDetail(opts: { tradeEventId?: string; tradeEventSlug?: string }): boolean {
return !!(opts.tradeEventId?.trim() || opts.tradeEventSlug?.trim())
}
@ -792,7 +795,9 @@ function isFundingHistory(h: HistoryItem): boolean {
function getFundingTitle(h: HistoryItem): string {
if (h.market?.trim()) return h.market
return isWithdrawalHistory(h) ? t('wallet.btcWithdrawHistoryLabel') : t('wallet.usdtDepositHistoryLabel')
return isWithdrawalHistory(h)
? t('wallet.btcWithdrawHistoryLabel')
: t('wallet.usdtDepositHistoryLabel')
}
function getFundingIconText(h: HistoryItem): string {
@ -1062,8 +1067,10 @@ function getWithdrawAmountText(w: SettlementRequestClientItem): string {
function getWithdrawFeeText(w: SettlementRequestClientItem): string {
const feeRaw = Number(
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number }).fee ??
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number }).gasFee ??
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number }).serviceFee ??
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number })
.gasFee ??
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number })
.serviceFee ??
0,
)
const feeText = Number.isFinite(feeRaw)
@ -1375,7 +1382,7 @@ function initPlChart() {
bottomColor: color + '08',
lineColor: color,
lineWidth: 2,
lineType: LineType.Simple,
lineType: LineType.Curved,
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
priceFormat: { type: 'price', precision: 2 },
})