diff --git a/frontend/src/views/apps/CryptoApp.contract.test.ts b/frontend/src/views/apps/CryptoApp.contract.test.ts index 97064bd..11f59c1 100644 --- a/frontend/src/views/apps/CryptoApp.contract.test.ts +++ b/frontend/src/views/apps/CryptoApp.contract.test.ts @@ -51,7 +51,13 @@ describe('VaultX crypto app contracts', () => { }) it('includes advanced market detail and persistent profile controls', () => { - expect(source).toContain('detail.sparkline') + expect(source).toContain('selected.sparkline') + expect(source).toContain('const detailChart = computed') + expect(source).toContain('CHART_PERIOD_CONFIG[period.value]') + expect(source).toContain('class="detail-chart__marker"') + expect(source).toContain('v-for="tick in detailChart.yTicks"') + expect(source).toContain('v-for="value in CHART_PERIODS"') + expect(source).toContain(':aria-pressed="period === value"') expect(source).toContain("t('marketDetail.statistics')") expect(source).toContain('class="detail-trade-dock"') expect(source).toContain('class="vault-view vault-detail-scroll"') diff --git a/frontend/src/views/apps/CryptoApp.vue b/frontend/src/views/apps/CryptoApp.vue index 82b5e0b..2457dc9 100644 --- a/frontend/src/views/apps/CryptoApp.vue +++ b/frontend/src/views/apps/CryptoApp.vue @@ -45,6 +45,39 @@ import { type Tab = 'portfolio' | 'markets' | 'activity' | 'profile' type Sheet = 'trade' | 'deposit' | 'withdraw' | 'profile' | null +type ChartPeriod = '1D' | '1W' | '1M' | '6M' | '1Y' + +const CHART_PERIODS: ChartPeriod[] = ['1D', '1W', '1M', '6M', '1Y'] +const CHART_PERIOD_CONFIG: Record< + ChartPeriod, + { duration: number; multiplier: number; noise: number; samples: number } +> = { + '1D': { duration: 86_400_000, multiplier: 1, noise: 0.025, samples: 16 }, + '1W': { + duration: 7 * 86_400_000, + multiplier: 1.4, + noise: 0.05, + samples: 18, + }, + '1M': { + duration: 30 * 86_400_000, + multiplier: 2.2, + noise: 0.09, + samples: 20, + }, + '6M': { + duration: 183 * 86_400_000, + multiplier: 3.5, + noise: 0.16, + samples: 22, + }, + '1Y': { + duration: 365 * 86_400_000, + multiplier: 5, + noise: 0.24, + samples: 24, + }, +} const crypto = useCryptoStore() const phone = usePhoneStore() const tab = ref('portfolio') @@ -67,7 +100,7 @@ const priceAlerts = ref(true) const confirmations = ref(true) const hideBalances = ref(false) const saved = ref(false) -const period = ref('1D') +const period = ref('1D') const locale = computed(() => phone.lang || 'de') const authenticated = computed(() => crypto.data?.authenticated === true) @@ -211,6 +244,97 @@ const detailHolding = computed(() => const selectedHolding = computed(() => holdings.value.find((item) => item.assetId === selectedMarket.value?.id), ) +const detailChart = computed(() => { + const left = 52 + const right = 307 + const top = 14 + const bottom = 137 + const config = CHART_PERIOD_CONFIG[period.value] + const selected = detail.value + + if (!selected) { + return { + areaPoints: '', + changePercent: 0, + marker: { labelY: top, x: right, y: bottom }, + points: '', + xTicks: [], + yTicks: [], + } + } + + const currentPrice = Number(selected.price) + const seed = [...selected.id].reduce( + (total, character) => total + character.charCodeAt(0), + 0, + ) + const periodBias = ((seed % 13) - 6) * (config.multiplier - 1) * 0.24 + const periodReturn = Math.max( + -72, + Math.min(180, selected.changePercent * config.multiplier + periodBias), + ) + const startPrice = currentPrice / (1 + periodReturn / 100) + const sparkline = selected.sparkline.length ? selected.sparkline : [0.5, 0.5] + const values = Array.from({ length: config.samples }, (_, index) => { + const ratio = index / (config.samples - 1) + const sourcePosition = ratio * (sparkline.length - 1) + const sourceIndex = Math.floor(sourcePosition) + const sourceRatio = sourcePosition - sourceIndex + const sourceValue = + (sparkline[sourceIndex] ?? 0.5) * (1 - sourceRatio) + + (sparkline[Math.min(sourceIndex + 1, sparkline.length - 1)] ?? 0.5) * + sourceRatio + const trend = startPrice + (currentPrice - startPrice) * ratio + const wave = + (sourceValue - 0.5) * 0.72 + + Math.sin((ratio * (1.4 + config.multiplier * 0.28) + seed) * Math.PI) * + 0.2 + + return Math.max( + currentPrice * 0.01, + trend + currentPrice * config.noise * wave * Math.sin(Math.PI * ratio), + ) + }) + values[0] = startPrice + values[values.length - 1] = currentPrice + + const minimum = Math.min(...values) + const maximum = Math.max(...values) + const padding = Math.max((maximum - minimum) * 0.16, currentPrice * 0.012) + const axisMinimum = Math.max(0, minimum - padding) + const axisMaximum = maximum + padding + const axisRange = Math.max(axisMaximum - axisMinimum, currentPrice * 0.02) + const x = (ratio: number) => left + ratio * (right - left) + const y = (value: number) => + top + ((axisMaximum - value) / axisRange) * (bottom - top) + const linePoints = values + .map((value, index) => `${x(index / (values.length - 1))},${y(value)}`) + .join(' ') + const markerY = y(currentPrice) + + return { + areaPoints: `${left},${bottom} ${linePoints} ${right},${bottom}`, + changePercent: ((currentPrice - startPrice) / startPrice) * 100, + marker: { + labelY: markerY < 31 ? markerY + 19 : markerY - 10, + x: right, + y: markerY, + }, + points: linePoints, + xTicks: Array.from({ length: 5 }, (_, index) => { + const ratio = index / 4 + return { + label: chartTimeLabel(period.value, ratio, config.duration), + x: x(ratio), + } + }), + yTicks: Array.from({ length: 4 }, (_, index) => { + const ratio = index / 3 + const value = axisMaximum - axisRange * ratio + return { label: chartMoney(value), value, y: y(value) } + }), + } +}) const estimatedTradeValue = computed( () => Number(amount.value || 0) * Number(selectedMarket.value?.price || 0), ) @@ -248,6 +372,33 @@ function compact(value: string | number) { notation: 'compact', }).format(Number(value) || 0) } +function chartMoney(value: number) { + const absolute = Math.abs(value) + return new Intl.NumberFormat(locale.value, { + currency: 'USD', + maximumFractionDigits: + absolute >= 1_000 ? 0 : absolute >= 1 ? 2 : absolute >= 0.01 ? 3 : 5, + notation: absolute >= 1_000_000 ? 'compact' : 'standard', + style: 'currency', + }).format(value) +} +function chartTimeLabel( + selectedPeriod: ChartPeriod, + ratio: number, + duration: number, +) { + const date = new Date(Date.now() - duration * (1 - ratio)) + const options: Intl.DateTimeFormatOptions = + selectedPeriod === '1D' + ? { hour: '2-digit', minute: '2-digit' } + : selectedPeriod === '1W' + ? { weekday: 'short' } + : selectedPeriod === '1M' + ? { day: '2-digit', month: '2-digit' } + : { month: 'short' } + + return new Intl.DateTimeFormat(locale.value, options).format(date) +} function points(values: number[], width = 320, height = 110) { return values .map( @@ -539,41 +690,96 @@ onMounted(() => void crypto.load())

{{ detail.symbol }} · {{ detail.name }}

{{ money(detail.price) }}{{ detail.changePercent >= 0 ? '▲' : '▼' }} - {{ Math.abs(detail.changePercent).toFixed(2) }}% · - {{ t('marketDetail.today') }}{{ detailChart.changePercent >= 0 ? '▲' : '▼' }} + {{ Math.abs(detailChart.changePercent).toFixed(2) }}% · + {{ period === '1D' ? t('marketDetail.today') : period }} + > - + + - - + + + {{ tick.label }} + + + {{ tick.label }} + + + + + + + + + {{ chartMoney(Number(detail.price)) }} + +