feat(market): graph for fun
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useElementSize } from "@vueuse/core";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { Market, MarketOption } from "../../types";
|
||||
import { SERIES_COLORS, linearScale, niceTicks, formatNumber, monotonePath } from "./chartUtils";
|
||||
|
||||
const props = defineProps<{
|
||||
market: Market;
|
||||
option: MarketOption;
|
||||
// What the user already has on this option (0 if no bet yet)
|
||||
existingAmount: number;
|
||||
// Total stake currently entered in the bet input
|
||||
stake: number;
|
||||
// Highest total stake the user can afford on this option
|
||||
maxStake: number;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const container = ref<HTMLElement | null>(null);
|
||||
const { width } = useElementSize(container);
|
||||
|
||||
const HEIGHT = 200;
|
||||
const MARGIN = { top: 16, right: 16, bottom: 28, left: 52 };
|
||||
const SAMPLES = 60;
|
||||
|
||||
const totalPot = computed(() => props.market.options.reduce((sum, opt) => sum + opt.total_bets, 0));
|
||||
|
||||
// Mirrors resolve_market: payout = stake / option pool * total pot * multiplier,
|
||||
// with the user's existing bet replaced by the given total stake.
|
||||
const payoutFor = (stake: number) => {
|
||||
const optionPool = props.option.total_bets - props.existingAmount + stake;
|
||||
if (optionPool <= 0) return 0;
|
||||
const pot = totalPot.value - props.existingAmount + stake;
|
||||
return Math.round((stake / optionPool) * pot * props.market.multiplier);
|
||||
};
|
||||
|
||||
// Bets can only be increased, so the stake axis starts at the existing bet
|
||||
const xMin = computed(() => props.existingAmount);
|
||||
const xMax = computed(() => Math.max(props.maxStake, props.stake, xMin.value + 1));
|
||||
|
||||
const points = computed(() =>
|
||||
Array.from({ length: SAMPLES + 1 }, (_, i) => {
|
||||
const stake = Math.round(xMin.value + ((xMax.value - xMin.value) * i) / SAMPLES);
|
||||
return { stake, payout: payoutFor(stake) };
|
||||
}),
|
||||
);
|
||||
|
||||
const yTicks = computed(() => niceTicks(Math.max(...points.value.map((p) => p.payout), xMax.value)));
|
||||
const plotWidth = computed(() => Math.max(width.value - MARGIN.left - MARGIN.right, 10));
|
||||
const x = computed(() => linearScale([xMin.value, xMax.value], [MARGIN.left, MARGIN.left + plotWidth.value]));
|
||||
const y = computed(() =>
|
||||
linearScale([0, yTicks.value[yTicks.value.length - 1]], [HEIGHT - MARGIN.bottom, MARGIN.top]),
|
||||
);
|
||||
const xTicks = computed(() => [
|
||||
xMin.value,
|
||||
...niceTicks(xMax.value).filter((v) => v > xMin.value && v <= xMax.value && x.value(v) - x.value(xMin.value) > 40),
|
||||
]);
|
||||
|
||||
const payoutPath = computed(() => monotonePath(points.value.map((p) => [x.value(p.stake), y.value(p.payout)])));
|
||||
const stakePath = computed(
|
||||
() => `M${x.value(xMin.value)},${y.value(xMin.value)}L${x.value(xMax.value)},${y.value(xMax.value)}`,
|
||||
);
|
||||
|
||||
const current = computed(() => {
|
||||
const stake = Math.max(props.stake, props.existingAmount);
|
||||
const payout = payoutFor(stake);
|
||||
return { stake, payout, gain: payout - stake };
|
||||
});
|
||||
|
||||
// Keep the current-stake label inside the plot near either edge
|
||||
const labelAnchor = computed(() => {
|
||||
const ratio = (x.value(current.value.stake) - MARGIN.left) / plotWidth.value;
|
||||
return ratio > 0.7 ? "end" : ratio < 0.15 ? "start" : "middle";
|
||||
});
|
||||
|
||||
// Crosshair snaps to the nearest sampled stake
|
||||
const hover = ref<{ stake: number; payout: number } | null>(null);
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
const rect = (event.currentTarget as SVGElement).getBoundingClientRect();
|
||||
const ratio = (event.clientX - rect.left - MARGIN.left) / plotWidth.value;
|
||||
const index = Math.round(Math.min(Math.max(ratio, 0), 1) * SAMPLES);
|
||||
hover.value = points.value[index];
|
||||
};
|
||||
|
||||
const tableRows = computed(() => {
|
||||
const stakes = [0, 0.25, 0.5, 0.75, 1].map((f) => Math.round(xMin.value + (xMax.value - xMin.value) * f));
|
||||
stakes.push(current.value.stake);
|
||||
return [...new Set(stakes)]
|
||||
.filter((stake) => stake > 0)
|
||||
.sort((a, b) => a - b)
|
||||
.map((stake) => ({ stake, payout: payoutFor(stake), gain: payoutFor(stake) - stake }));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="viz-root">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2 mb-2">
|
||||
<h4 class="text-sm font-semibold">{{ t("components.marketCharts.payoutTitle", { option: option.text }) }}</h4>
|
||||
<div class="flex gap-4 text-xs text-base-content/70">
|
||||
<span class="flex items-center gap-1.5"><span class="legend-line" :style="{ background: SERIES_COLORS[0] }"></span>{{ t("components.marketCharts.payout") }}</span>
|
||||
<span class="flex items-center gap-1.5"><span class="legend-line legend-line--muted"></span>{{ t("components.marketCharts.stake") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="container" class="relative">
|
||||
<svg v-if="width > 0" :width="width" :height="HEIGHT" class="block" @pointermove="onPointerMove" @pointerleave="hover = null">
|
||||
<!-- Grid + y axis -->
|
||||
<g v-for="tick in yTicks" :key="`y${tick}`">
|
||||
<line :x1="MARGIN.left" :x2="MARGIN.left + plotWidth" :y1="y(tick)" :y2="y(tick)" class="grid" />
|
||||
<text :x="MARGIN.left - 8" :y="y(tick)" text-anchor="end" dominant-baseline="middle" class="tick">{{ formatNumber(tick) }}</text>
|
||||
</g>
|
||||
<text v-for="tick in xTicks" :key="`x${tick}`" :x="x(tick)" :y="HEIGHT - 8" text-anchor="middle" class="tick">{{ formatNumber(tick) }}</text>
|
||||
|
||||
<path :d="stakePath" class="stake-line" />
|
||||
<path :d="payoutPath" class="series-line" :stroke="SERIES_COLORS[0]" />
|
||||
|
||||
<!-- Current stake -->
|
||||
<g v-if="current.stake > 0">
|
||||
<circle :cx="x(current.stake)" :cy="y(current.payout)" r="4" :fill="SERIES_COLORS[0]" class="marker" />
|
||||
<text :x="x(current.stake)" :y="y(current.payout) - 10" :text-anchor="labelAnchor" class="label">
|
||||
{{ formatNumber(current.payout) }} {{ t("common.pts") }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Hover -->
|
||||
<g v-if="hover">
|
||||
<line :x1="x(hover.stake)" :x2="x(hover.stake)" :y1="MARGIN.top" :y2="HEIGHT - MARGIN.bottom" class="crosshair" />
|
||||
<circle :cx="x(hover.stake)" :cy="y(hover.payout)" r="4" :fill="SERIES_COLORS[0]" class="marker" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div v-if="hover" class="tooltip-box" :style="{ left: `${Math.min(x(hover.stake) + 12, width - 170)}px`, top: `${MARGIN.top}px` }">
|
||||
<div class="text-xs text-base-content/60 mb-1">{{ t("components.marketCharts.ifYouBet", { n: formatNumber(hover.stake) }) }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="legend-line" :style="{ background: SERIES_COLORS[0] }"></span>
|
||||
<strong>{{ formatNumber(hover.payout) }}</strong>
|
||||
<span class="text-base-content/60">{{ t("components.marketCharts.payout") }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="legend-line legend-line--muted"></span>
|
||||
<strong>{{ hover.payout - hover.stake >= 0 ? "+" : "" }}{{ formatNumber(hover.payout - hover.stake) }}</strong>
|
||||
<span class="text-base-content/60">{{ t("components.marketCharts.netGain") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-base-content/60 mt-1">{{ t("components.marketCharts.payoutHint") }}</p>
|
||||
|
||||
<details class="mt-2 text-xs">
|
||||
<summary class="cursor-pointer text-base-content/60">{{ t("components.marketCharts.showTable") }}</summary>
|
||||
<table class="table table-xs mt-1 tabular-nums">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-right">{{ t("components.marketCharts.stake") }}</th>
|
||||
<th class="text-right">{{ t("components.marketCharts.payout") }}</th>
|
||||
<th class="text-right">{{ t("components.marketCharts.netGain") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in tableRows" :key="row.stake" :class="{ 'font-semibold': row.stake === current.stake }">
|
||||
<td class="text-right">{{ formatNumber(row.stake) }}</td>
|
||||
<td class="text-right">{{ formatNumber(row.payout) }}</td>
|
||||
<td class="text-right">{{ row.gain >= 0 ? "+" : "" }}{{ formatNumber(row.gain) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped src="./chart.css"></style>
|
||||
Reference in New Issue
Block a user