feat(market): graph for fun
This commit is contained in:
@@ -14,6 +14,7 @@ from market.models import (
|
|||||||
get_event_points,
|
get_event_points,
|
||||||
)
|
)
|
||||||
from market.schemas import (
|
from market.schemas import (
|
||||||
|
BetHistoryPointSchema,
|
||||||
MarketListSchema,
|
MarketListSchema,
|
||||||
ResolveMarketSchema,
|
ResolveMarketSchema,
|
||||||
UserBetCreateSchema,
|
UserBetCreateSchema,
|
||||||
@@ -61,6 +62,36 @@ def get_user_points(request, event_path: str):
|
|||||||
return {"points": get_event_points(request.user, request.event)}
|
return {"points": get_event_points(request.user, request.event)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/{{market_uuid}}/history", response=List[BetHistoryPointSchema])
|
||||||
|
def bet_history(request, event_path: str, market_uuid: str):
|
||||||
|
"""Points added to each option over time (anonymous, one entry per bet placed/increased)."""
|
||||||
|
market = get_object_or_404(
|
||||||
|
Market.objects.exclude(status=Market.Status.DRAFT),
|
||||||
|
uuid=market_uuid,
|
||||||
|
event=request.event,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A user can only bet on one option per market, so their option is unique
|
||||||
|
option_by_user = dict(
|
||||||
|
UserBet.objects.filter(option__market=market).values_list(
|
||||||
|
"user_id", "option__uuid"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
changes = UserPointChange.objects.filter(
|
||||||
|
market=market, reason=UserPointChange.Reason.BET_PLACED
|
||||||
|
).order_by("created_at")
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"option_uuid": str(option_by_user[change.user_id]),
|
||||||
|
"created_at": change.created_at,
|
||||||
|
"amount": -change.amount,
|
||||||
|
}
|
||||||
|
for change in changes
|
||||||
|
if change.user_id in option_by_user
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post(f"{PREFIX}/{{market_uuid}}/actions/close")
|
@router.post(f"{PREFIX}/{{market_uuid}}/actions/close")
|
||||||
def close_market(request, event_path: str, market_uuid: str):
|
def close_market(request, event_path: str, market_uuid: str):
|
||||||
"""Close a market. Admin only."""
|
"""Close a market. Admin only."""
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ class UserPointsSchema(Schema):
|
|||||||
points: int
|
points: int
|
||||||
|
|
||||||
|
|
||||||
|
class BetHistoryPointSchema(Schema):
|
||||||
|
option_uuid: str
|
||||||
|
created_at: datetime
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
|
||||||
class UserBetCreateSchema(Schema):
|
class UserBetCreateSchema(Schema):
|
||||||
option_uuid: str
|
option_uuid: str
|
||||||
amount: int
|
amount: int
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
|
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
import { client } from './client.gen';
|
||||||
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
|
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
|
||||||
/**
|
/**
|
||||||
@@ -326,6 +326,13 @@ export const marketApiListUserBets = <ThrowOnError extends boolean = false>(opti
|
|||||||
*/
|
*/
|
||||||
export const marketApiGetUserPoints = <ThrowOnError extends boolean = false>(options: Options<MarketApiGetUserPointsData, ThrowOnError>) => (options.client ?? client).get<MarketApiGetUserPointsResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/market/user/points', ...options });
|
export const marketApiGetUserPoints = <ThrowOnError extends boolean = false>(options: Options<MarketApiGetUserPointsData, ThrowOnError>) => (options.client ?? client).get<MarketApiGetUserPointsResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/market/user/points', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bet History
|
||||||
|
*
|
||||||
|
* Points added to each option over time (anonymous, one entry per bet placed/increased).
|
||||||
|
*/
|
||||||
|
export const marketApiBetHistory = <ThrowOnError extends boolean = false>(options: Options<MarketApiBetHistoryData, ThrowOnError>) => (options.client ?? client).get<MarketApiBetHistoryResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/market/{market_uuid}/history', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close Market
|
* Close Market
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1163,6 +1163,24 @@ export type UserPointsSchema = {
|
|||||||
points: number;
|
points: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BetHistoryPointSchema
|
||||||
|
*/
|
||||||
|
export type BetHistoryPointSchema = {
|
||||||
|
/**
|
||||||
|
* Option Uuid
|
||||||
|
*/
|
||||||
|
option_uuid: string;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
/**
|
||||||
|
* Amount
|
||||||
|
*/
|
||||||
|
amount: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ResolveMarketSchema
|
* ResolveMarketSchema
|
||||||
*/
|
*/
|
||||||
@@ -2204,6 +2222,33 @@ export type MarketApiGetUserPointsResponses = {
|
|||||||
|
|
||||||
export type MarketApiGetUserPointsResponse = MarketApiGetUserPointsResponses[keyof MarketApiGetUserPointsResponses];
|
export type MarketApiGetUserPointsResponse = MarketApiGetUserPointsResponses[keyof MarketApiGetUserPointsResponses];
|
||||||
|
|
||||||
|
export type MarketApiBetHistoryData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Event Path
|
||||||
|
*/
|
||||||
|
event_path: string;
|
||||||
|
/**
|
||||||
|
* Market Uuid
|
||||||
|
*/
|
||||||
|
market_uuid: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/events/{event_path}/market/{market_uuid}/history';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarketApiBetHistoryResponses = {
|
||||||
|
/**
|
||||||
|
* Response
|
||||||
|
*
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: Array<BetHistoryPointSchema>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarketApiBetHistoryResponse = MarketApiBetHistoryResponses[keyof MarketApiBetHistoryResponses];
|
||||||
|
|
||||||
export type MarketApiCloseMarketData = {
|
export type MarketApiCloseMarketData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useMarketStore } from "../stores/market";
|
|||||||
import { useEventPath } from "../composables/useEventPath";
|
import { useEventPath } from "../composables/useEventPath";
|
||||||
import type { Market, MarketOption } from "../types";
|
import type { Market, MarketOption } from "../types";
|
||||||
import type { UserBetSchema } from "../api/types.gen";
|
import type { UserBetSchema } from "../api/types.gen";
|
||||||
|
import PayoutChart from "./charts/PayoutChart.vue";
|
||||||
|
import BetHistoryChart from "./charts/BetHistoryChart.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
market: Market;
|
market: Market;
|
||||||
@@ -19,7 +21,7 @@ const emit = defineEmits<{
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const eventPath = useEventPath();
|
const eventPath = useEventPath();
|
||||||
const marketStore = useMarketStore();
|
const marketStore = useMarketStore();
|
||||||
const { userInfo, userBets } = storeToRefs(marketStore);
|
const { userInfo, userBets, userPoints } = storeToRefs(marketStore);
|
||||||
|
|
||||||
const selectedOption = ref<string | null>(null);
|
const selectedOption = ref<string | null>(null);
|
||||||
const betAmount = ref<number>(0);
|
const betAmount = ref<number>(0);
|
||||||
@@ -29,6 +31,7 @@ const existingBet = ref<UserBetSchema | null>(null);
|
|||||||
const showResolveModal = ref(false);
|
const showResolveModal = ref(false);
|
||||||
const selectedWinningOption = ref<string | null>(null);
|
const selectedWinningOption = ref<string | null>(null);
|
||||||
const resolveLoading = ref(false);
|
const resolveLoading = ref(false);
|
||||||
|
const showCharts = ref(true);
|
||||||
|
|
||||||
const timeRemaining = computed(() => {
|
const timeRemaining = computed(() => {
|
||||||
const endDate = new Date(props.market.end_date).getTime();
|
const endDate = new Date(props.market.end_date).getTime();
|
||||||
@@ -80,6 +83,19 @@ const getPotentialGain = (option: MarketOption) => {
|
|||||||
return Math.round(existingBet.value.amount * multiplier * props.market.multiplier);
|
return Math.round(existingBet.value.amount * multiplier * props.market.multiplier);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedMarketOption = computed(() =>
|
||||||
|
props.market.options.find((opt) => opt.uuid === selectedOption.value) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Payout chart only makes sense while the user can still bet on the selected option
|
||||||
|
const showPayoutChart = computed(
|
||||||
|
() => props.market.status === "open" && !!userInfo.value?.is_authenticated && !!selectedMarketOption.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingAmountOnSelected = computed(() =>
|
||||||
|
existingBet.value && existingBet.value.option.uuid === selectedOption.value ? existingBet.value.amount : 0,
|
||||||
|
);
|
||||||
|
|
||||||
const closeMarket = async () => {
|
const closeMarket = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -147,7 +163,7 @@ const placeBet = async () => {
|
|||||||
|
|
||||||
if (!response.error) {
|
if (!response.error) {
|
||||||
// Reload user bets to reflect the new bet
|
// Reload user bets to reflect the new bet
|
||||||
await marketStore.loadUserBets(eventPath);
|
await Promise.all([marketStore.loadUserBets(eventPath), marketStore.loadUserPoints(eventPath)]);
|
||||||
updateExistingBet();
|
updateExistingBet();
|
||||||
betAmount.value = 0;
|
betAmount.value = 0;
|
||||||
} else {
|
} else {
|
||||||
@@ -286,6 +302,20 @@ const updateExistingBet = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts -->
|
||||||
|
<div class="px-8 pb-2">
|
||||||
|
<button @click="showCharts = !showCharts" class="btn btn-ghost btn-xs">
|
||||||
|
<i :class="['mdi', showCharts ? 'mdi-chevron-up' : 'mdi-chart-line']"></i>
|
||||||
|
{{ showCharts ? t("components.marketCharts.hideCharts") : t("components.marketCharts.showCharts") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="showCharts" class="card-body py-4 gap-6">
|
||||||
|
<PayoutChart v-if="showPayoutChart && selectedMarketOption" :market="market" :option="selectedMarketOption"
|
||||||
|
:existing-amount="existingAmountOnSelected" :stake="betAmount || 0"
|
||||||
|
:max-stake="existingAmountOnSelected + userPoints" />
|
||||||
|
<BetHistoryChart :market="market" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Bet Input -->
|
<!-- Bet Input -->
|
||||||
<div v-if="market.status === 'open' && userInfo?.is_authenticated && selectedOption" class="card-body py-4">
|
<div v-if="market.status === 'open' && userInfo?.is_authenticated && selectedOption" class="card-body py-4">
|
||||||
<div class="form-control gap-3">
|
<div class="form-control gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from "vue";
|
||||||
|
import { useElementSize } from "@vueuse/core";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { marketApiBetHistory } from "../../api";
|
||||||
|
import type { BetHistoryPointSchema } from "../../api/types.gen";
|
||||||
|
import { useEventPath } from "../../composables/useEventPath";
|
||||||
|
import type { Market } from "../../types";
|
||||||
|
import { SERIES_COLORS, MAX_SERIES, linearScale, niceTicks, formatNumber, monotonePath } from "./chartUtils";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
market: Market;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const eventPath = useEventPath();
|
||||||
|
|
||||||
|
const container = ref<HTMLElement | null>(null);
|
||||||
|
const { width } = useElementSize(container);
|
||||||
|
|
||||||
|
const HEIGHT = 200;
|
||||||
|
const MARGIN = { top: 16, right: 56, bottom: 28, left: 52 };
|
||||||
|
const OTHER_KEY = "__other__";
|
||||||
|
|
||||||
|
const history = ref<BetHistoryPointSchema[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const loaded = ref(false);
|
||||||
|
|
||||||
|
const loadHistory = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await marketApiBetHistory({
|
||||||
|
path: { event_path: eventPath, market_uuid: props.market.uuid },
|
||||||
|
});
|
||||||
|
if (response.data) history.value = response.data;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
loaded.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refetch whenever the pools change (bet placed, page refresh)
|
||||||
|
const totalPot = computed(() => props.market.options.reduce((sum, opt) => sum + opt.total_bets, 0));
|
||||||
|
watch(totalPot, loadHistory, { immediate: true });
|
||||||
|
|
||||||
|
// Colors follow the option (sorted by text), not its current rank, so they stay
|
||||||
|
// stable as pools change. Past the palette size the tail folds into "Other".
|
||||||
|
const series = computed(() => {
|
||||||
|
const options = [...props.market.options].sort((a, b) => a.text.localeCompare(b.text));
|
||||||
|
const fold = options.length > MAX_SERIES;
|
||||||
|
const kept = fold ? options.slice(0, MAX_SERIES - 1) : options;
|
||||||
|
const list = kept.map((opt, i) => ({ key: opt.uuid, label: opt.text, color: SERIES_COLORS[i] }));
|
||||||
|
if (fold) list.push({ key: OTHER_KEY, label: t("components.marketCharts.other"), color: SERIES_COLORS[MAX_SERIES - 1] });
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const seriesKeyFor = (optionUuid: string) =>
|
||||||
|
series.value.some((s) => s.key === optionUuid) ? optionUuid : OTHER_KEY;
|
||||||
|
|
||||||
|
// One step per bet: cumulative pool of every series after that bet
|
||||||
|
const steps = computed(() => {
|
||||||
|
const totals: Record<string, number> = Object.fromEntries(series.value.map((s) => [s.key, 0]));
|
||||||
|
return history.value.map((entry) => {
|
||||||
|
totals[seriesKeyFor(entry.option_uuid)] += entry.amount;
|
||||||
|
return {
|
||||||
|
time: new Date(entry.created_at).getTime(),
|
||||||
|
entry,
|
||||||
|
totals: { ...totals },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeDomain = computed<[number, number]>(() => {
|
||||||
|
const first = steps.value[0]?.time ?? Date.now();
|
||||||
|
const last = steps.value[steps.value.length - 1]?.time ?? first;
|
||||||
|
const end = Math.max(last, Math.min(Date.now(), new Date(props.market.end_date).getTime()), first + 60_000);
|
||||||
|
// Small lead-in so every curve visibly rises from 0
|
||||||
|
return [first - (end - first) * 0.04, end];
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalTotals = computed(() => steps.value[steps.value.length - 1]?.totals ?? {});
|
||||||
|
|
||||||
|
const yTicks = computed(() => niceTicks(Math.max(0, ...Object.values(finalTotals.value))));
|
||||||
|
const plotWidth = computed(() => Math.max(width.value - MARGIN.left - MARGIN.right, 10));
|
||||||
|
const x = computed(() => linearScale(timeDomain.value, [MARGIN.left, MARGIN.left + plotWidth.value]));
|
||||||
|
const y = computed(() =>
|
||||||
|
linearScale([0, yTicks.value[yTicks.value.length - 1]], [HEIGHT - MARGIN.bottom, MARGIN.top]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeFormat = computed(() =>
|
||||||
|
timeDomain.value[1] - timeDomain.value[0] < 24 * 3600_000 ? "HH:mm" : "DD/MM HH:mm",
|
||||||
|
);
|
||||||
|
const xTicks = computed(() => {
|
||||||
|
const [t0, t1] = timeDomain.value;
|
||||||
|
return [0, 1 / 3, 2 / 3, 1].map((f) => t0 + (t1 - t0) * f);
|
||||||
|
});
|
||||||
|
|
||||||
|
const paths = computed(() =>
|
||||||
|
series.value.map((s) => {
|
||||||
|
// A point at every bet (not just this option's) so the curve goes through
|
||||||
|
// the real totals wherever the crosshair can land
|
||||||
|
const points: [number, number][] = [
|
||||||
|
[x.value(timeDomain.value[0]), y.value(0)],
|
||||||
|
...steps.value.map((step): [number, number] => [x.value(step.time), y.value(step.totals[s.key])]),
|
||||||
|
[x.value(timeDomain.value[1]), y.value(finalTotals.value[s.key] ?? 0)],
|
||||||
|
];
|
||||||
|
return { ...s, d: monotonePath(points) };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Direct end labels for up to 4 series, dropping any that would collide
|
||||||
|
const endLabels = computed(() => {
|
||||||
|
if (series.value.length > 4) return [];
|
||||||
|
const placed: { key: string; y: number; value: number }[] = [];
|
||||||
|
const candidates = series.value
|
||||||
|
.map((s) => ({ key: s.key, value: finalTotals.value[s.key] ?? 0 }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
for (const c of candidates) {
|
||||||
|
const cy = y.value(c.value);
|
||||||
|
if (placed.every((p) => Math.abs(p.y - cy) >= 12)) placed.push({ ...c, y: cy });
|
||||||
|
}
|
||||||
|
return placed;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Crosshair snaps to the nearest bet
|
||||||
|
const hoverIndex = ref<number | null>(null);
|
||||||
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
|
if (!steps.value.length) return;
|
||||||
|
const rect = (event.currentTarget as SVGElement).getBoundingClientRect();
|
||||||
|
const px = event.clientX - rect.left;
|
||||||
|
let best = 0;
|
||||||
|
steps.value.forEach((step, i) => {
|
||||||
|
if (Math.abs(x.value(step.time) - px) < Math.abs(x.value(steps.value[best].time) - px)) best = i;
|
||||||
|
});
|
||||||
|
hoverIndex.value = best;
|
||||||
|
};
|
||||||
|
const hoverStep = computed(() => (hoverIndex.value === null ? null : steps.value[hoverIndex.value]));
|
||||||
|
|
||||||
|
const optionLabel = (optionUuid: string) =>
|
||||||
|
props.market.options.find((o) => o.uuid === optionUuid)?.text ?? "";
|
||||||
|
</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.historyTitle") }}</h4>
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-base-content/70">
|
||||||
|
<span v-for="s in series" :key="s.key" class="flex items-center gap-1.5">
|
||||||
|
<span class="legend-line" :style="{ background: s.color }"></span>{{ s.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="container" class="relative transition-opacity" :class="{ 'opacity-50': loading && loaded }">
|
||||||
|
<div v-if="!loaded" class="flex justify-center items-center" :style="{ height: `${HEIGHT}px` }">
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!steps.length" class="flex justify-center items-center text-sm text-base-content/60" :style="{ height: `${HEIGHT}px` }">
|
||||||
|
{{ t("components.marketCharts.noBets") }}
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<svg v-if="width > 0" :width="width" :height="HEIGHT" class="block" @pointermove="onPointerMove" @pointerleave="hoverIndex = null">
|
||||||
|
<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, i) in xTicks" :key="`x${i}`" :x="x(tick)" :y="HEIGHT - 8"
|
||||||
|
:text-anchor="i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle'" class="tick">
|
||||||
|
{{ dayjs(tick).format(timeFormat) }}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<path v-for="p in paths" :key="p.key" :d="p.d" class="series-line" :stroke="p.color" />
|
||||||
|
|
||||||
|
<text v-for="label in endLabels" :key="`l${label.key}`" :x="MARGIN.left + plotWidth + 6" :y="label.y" dominant-baseline="middle" class="label">
|
||||||
|
{{ formatNumber(label.value) }}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<g v-if="hoverStep">
|
||||||
|
<line :x1="x(hoverStep.time)" :x2="x(hoverStep.time)" :y1="MARGIN.top" :y2="HEIGHT - MARGIN.bottom" class="crosshair" />
|
||||||
|
<circle v-for="s in series" :key="`h${s.key}`" :cx="x(hoverStep.time)" :cy="y(hoverStep.totals[s.key])" r="4" :fill="s.color" class="marker" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div v-if="hoverStep" class="tooltip-box" :style="{ left: `${Math.min(x(hoverStep.time) + 12, width - 170)}px`, top: `${MARGIN.top}px` }">
|
||||||
|
<div class="text-xs text-base-content/60 mb-1">{{ dayjs(hoverStep.time).format("DD/MM HH:mm") }}</div>
|
||||||
|
<div v-for="s in series" :key="`t${s.key}`" class="flex items-center gap-2">
|
||||||
|
<span class="legend-line" :style="{ background: s.color }"></span>
|
||||||
|
<strong>{{ formatNumber(hoverStep.totals[s.key]) }}</strong>
|
||||||
|
<span class="text-base-content/60 truncate">{{ s.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details v-if="steps.length" class="mt-2 text-xs">
|
||||||
|
<summary class="cursor-pointer text-base-content/60">{{ t("components.marketCharts.showTable") }}</summary>
|
||||||
|
<div class="max-h-48 overflow-y-auto mt-1">
|
||||||
|
<table class="table table-xs tabular-nums">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t("components.marketCharts.time") }}</th>
|
||||||
|
<th>{{ t("components.marketCharts.option") }}</th>
|
||||||
|
<th class="text-right">{{ t("components.marketCharts.amount") }}</th>
|
||||||
|
<th class="text-right">{{ t("components.marketCharts.optionPool") }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(step, i) in steps" :key="i">
|
||||||
|
<td>{{ dayjs(step.time).format("DD/MM HH:mm") }}</td>
|
||||||
|
<td>{{ optionLabel(step.entry.option_uuid) }}</td>
|
||||||
|
<td class="text-right">+{{ formatNumber(step.entry.amount) }}</td>
|
||||||
|
<td class="text-right">{{ formatNumber(step.totals[seriesKeyFor(step.entry.option_uuid)]) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped src="./chart.css"></style>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/* Shared chart chrome. Marks carry series colors; text and chrome use theme ink. */
|
||||||
|
.grid {
|
||||||
|
stroke: color-mix(in oklab, var(--color-base-content) 12%, transparent);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick {
|
||||||
|
fill: color-mix(in oklab, var(--color-base-content) 55%, transparent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
fill: var(--color-base-content);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-line {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stake-line {
|
||||||
|
fill: none;
|
||||||
|
stroke: color-mix(in oklab, var(--color-base-content) 35%, transparent);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker {
|
||||||
|
stroke: var(--color-base-100);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair {
|
||||||
|
stroke: color-mix(in oklab, var(--color-base-content) 35%, transparent);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-line {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-line--muted {
|
||||||
|
background: color-mix(in oklab, var(--color-base-content) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-box {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
background: var(--color-base-200);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Categorical slots, dark steps (the site always runs the dark "dim" theme).
|
||||||
|
// Validated against the dim card surface (#2a303c): adjacent CVD ΔE >= 8.4.
|
||||||
|
// Order matters for colorblind safety - assign in order, never cycle.
|
||||||
|
export const SERIES_COLORS = [
|
||||||
|
'#3987e5', // blue
|
||||||
|
'#d95926', // orange
|
||||||
|
'#199e70', // aqua
|
||||||
|
'#c98500', // yellow
|
||||||
|
'#d55181', // magenta
|
||||||
|
'#008300', // green
|
||||||
|
'#9085e9', // violet
|
||||||
|
'#e66767', // red
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MAX_SERIES = SERIES_COLORS.length
|
||||||
|
|
||||||
|
export const linearScale = (domain: [number, number], range: [number, number]) => {
|
||||||
|
const [d0, d1] = domain
|
||||||
|
const [r0, r1] = range
|
||||||
|
const span = d1 - d0 || 1
|
||||||
|
return (v: number) => r0 + ((v - d0) / span) * (r1 - r0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round tick values (0 / 250 / 500 ...) covering [0, max]
|
||||||
|
export const niceTicks = (max: number, count = 4): number[] => {
|
||||||
|
if (max <= 0) return [0]
|
||||||
|
const raw = max / count
|
||||||
|
const mag = 10 ** Math.floor(Math.log10(raw))
|
||||||
|
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw
|
||||||
|
const ticks: number[] = []
|
||||||
|
for (let v = 0; v <= max + step * 0.001; v += step) ticks.push(Math.round(v))
|
||||||
|
if (ticks[ticks.length - 1] < max) ticks.push(Math.round(ticks[ticks.length - 1] + step))
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatNumber = (n: number) => Math.round(n).toLocaleString()
|
||||||
|
|
||||||
|
// Smooth SVG path through the points (monotone cubic, like d3.curveMonotoneX):
|
||||||
|
// it never overshoots, so a growing total never appears to dip.
|
||||||
|
export const monotonePath = (points: [number, number][]): string => {
|
||||||
|
const n = points.length
|
||||||
|
if (n === 0) return ''
|
||||||
|
if (n < 3) return points.map(([px, py], i) => `${i ? 'L' : 'M'}${px},${py}`).join('')
|
||||||
|
|
||||||
|
const slopes: number[] = []
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
const dx = points[i + 1][0] - points[i][0]
|
||||||
|
slopes.push(dx === 0 ? 0 : (points[i + 1][1] - points[i][1]) / dx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tangents: zero at local extrema, harmonic mean elsewhere
|
||||||
|
const tangents = points.map((_, i) => {
|
||||||
|
if (i === 0) return slopes[0]
|
||||||
|
if (i === n - 1) return slopes[n - 2]
|
||||||
|
const a = slopes[i - 1]
|
||||||
|
const b = slopes[i]
|
||||||
|
return a * b <= 0 ? 0 : (2 * a * b) / (a + b)
|
||||||
|
})
|
||||||
|
|
||||||
|
let d = `M${points[0][0]},${points[0][1]}`
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
const [x0, y0] = points[i]
|
||||||
|
const [x1, y1] = points[i + 1]
|
||||||
|
const h = (x1 - x0) / 3
|
||||||
|
d += `C${x0 + h},${y0 + h * tangents[i]},${x1 - h},${y1 - h * tangents[i + 1]},${x1},${y1}`
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
@@ -302,6 +302,24 @@
|
|||||||
"errorPlacingBet": "Error placing bet",
|
"errorPlacingBet": "Error placing bet",
|
||||||
"ended": "Ended"
|
"ended": "Ended"
|
||||||
},
|
},
|
||||||
|
"marketCharts": {
|
||||||
|
"showCharts": "Show charts",
|
||||||
|
"hideCharts": "Hide charts",
|
||||||
|
"payoutTitle": "Potential payout if “{option}” wins",
|
||||||
|
"payout": "Payout",
|
||||||
|
"stake": "Your stake",
|
||||||
|
"netGain": "Net gain",
|
||||||
|
"ifYouBet": "If you bet {n} pts",
|
||||||
|
"payoutHint": "Based on the current pools. Payouts shift as others bet.",
|
||||||
|
"historyTitle": "Points bet over time",
|
||||||
|
"noBets": "No bets yet",
|
||||||
|
"other": "Other",
|
||||||
|
"showTable": "Show data table",
|
||||||
|
"time": "Time",
|
||||||
|
"option": "Option",
|
||||||
|
"amount": "Amount",
|
||||||
|
"optionPool": "Option pool"
|
||||||
|
},
|
||||||
"puzzleCard": {
|
"puzzleCard": {
|
||||||
"byAuthor": "by {author}",
|
"byAuthor": "by {author}",
|
||||||
"moreTags": "+{n} more",
|
"moreTags": "+{n} more",
|
||||||
|
|||||||
Reference in New Issue
Block a user