Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const metadata: Metadata = {
export const viewport: Viewport = appViewport;

import { ToastProvider } from "../components/ToastProvider";
import { ChartThemeProvider } from "../components/ChartThemeProvider";

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/components/ChartThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, { createContext, useContext, ReactNode, useMemo } from "react";
import { useChartColors, type ChartColors } from "../hooks/useChartColors";

/**
* ChartThemeContext — Provides theme-aware colors to all descendant charts.
* Wrap root layout or page: <ChartThemeProvider><MarketDetailPage /></ChartThemeProvider>
*
* Issue #429: Centralizes color management, auto-re-renders charts on theme toggle.
*/
interface ChartThemeContextType {
colors: ChartColors;
}

const ChartThemeContext = createContext<ChartThemeContextType | undefined>(undefined);

interface Props {
children: ReactNode;
}

export function ChartThemeProvider({ children }: Props) {
const rawColors = useChartColors();

// Memoize to prevent unnecessary re-renders
const value = useMemo(
() => ({
colors: rawColors,
}),
[rawColors]
);

return <ChartThemeContext.Provider value={value}>{children}</ChartThemeContext.Provider>;
}

/**
* useChartTheme — Consuming hook for charts. Throws if used outside provider.
*/
export function useChartTheme(): ChartColors {
const context = useContext(ChartThemeContext);
if (context === undefined) {
throw new Error("useChartTheme must be used within ChartThemeProvider");
}
return context.colors;
}
23 changes: 14 additions & 9 deletions frontend/src/components/OddsChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
TooltipProps,
} from "recharts";
import Skeleton from "./Skeleton";
import { useChartTheme } from "./ChartThemeProvider";

// ── Types ─────────────────────────────────────────────────────────────────────

Expand All @@ -35,9 +36,6 @@ interface Props {

const RANGES: TimeRange[] = ["1H", "6H", "1D", "All"];

const YES_COLOR = "#22c55e"; // green-500
const NO_COLOR = "#f97316"; // orange-500

// ── Mock data generator (used until real API exists) ──────────────────────────

function generateMockData(range: TimeRange): OddsPoint[] {
Expand Down Expand Up @@ -81,22 +79,28 @@ async function defaultFetcher(marketId: number, range: TimeRange): Promise<OddsP
// ── Crosshair Tooltip ─────────────────────────────────────────────────────────

function CrosshairTooltip({ active, payload, label }: TooltipProps<number, string>) {
const colors = useChartTheme();
if (!active || !payload?.length) return null;
const yes = payload.find((p) => p.dataKey === "yes");
const no = payload.find((p) => p.dataKey === "no");
return (
<div
data-testid="odds-tooltip"
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-xs shadow-xl space-y-1"
style={{
backgroundColor: colors.tooltipBg,
borderColor: colors.tooltipBorder,
color: "white",
}}
className="rounded-lg px-3 py-2 text-xs shadow-xl space-y-1"
>
<p className="text-gray-400">{label}</p>
{yes && (
<p style={{ color: YES_COLOR }} className="font-semibold">
<p style={{ color: colors.yes }} className="font-semibold">
YES: {yes.value}%
</p>
)}
{no && (
<p style={{ color: NO_COLOR }} className="font-semibold">
<p style={{ color: colors.no }} className="font-semibold">
NO: {no.value}%
</p>
)}
Expand All @@ -110,6 +114,7 @@ export default function OddsChart({ marketId, initialData, fetcher = defaultFetc
const [range, setRange] = useState<TimeRange>("1D");
const [data, setData] = useState<OddsPoint[]>(initialData ?? []);
const [loading, setLoading] = useState(!initialData);
const colors = useChartTheme();

const loadData = useCallback(
async (r: TimeRange) => {
Expand Down Expand Up @@ -170,14 +175,14 @@ export default function OddsChart({ marketId, initialData, fetcher = defaultFetc
<span className="flex items-center gap-1.5">
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{ background: YES_COLOR }}
style={{ backgroundColor: colors.yes }}
/>
<span className="text-gray-300">YES</span>
</span>
<span className="flex items-center gap-1.5">
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{ background: NO_COLOR }}
style={{ backgroundColor: colors.no }}
/>
<span className="text-gray-300">NO</span>
</span>
Expand Down Expand Up @@ -205,7 +210,7 @@ export default function OddsChart({ marketId, initialData, fetcher = defaultFetc
</linearGradient>
</defs>

<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
<CartesianGrid strokeDasharray="3 3" stroke={colors.grid} />

<XAxis
dataKey="timestamp"
Expand Down
44 changes: 18 additions & 26 deletions frontend/src/components/PoolOwnershipChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,42 +10,31 @@
*/
// Named imports — webpack tree-shakes unused recharts components via the
// package's sideEffects:false declaration, keeping the bundle lean.
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from "recharts";
} from "recharts";
import { usePoolOwnership } from "../hooks/usePoolOwnership";
import { useChartTheme } from "./ChartThemeProvider";
import { OwnershipSlice } from "../utils/poolOwnership";

interface Props {
marketId: number;
}

/** Stella Polymarket design token palette for pie slices */
const SLICE_COLORS = [
"#3b82f6", // blue-500
"#22c55e", // green-500
"#a855f7", // purple-500
"#f59e0b", // amber-500
"#ef4444", // red-500
"#06b6d4", // cyan-500
"#ec4899", // pink-500
"#84cc16", // lime-500
"#6366f1", // indigo-500
"#f97316", // orange-500
];
const OTHERS_COLOR = "#4b5563"; // gray-600

function sliceColor(index: number, label: string): string {
if (label === "Others") return OTHERS_COLOR;
return SLICE_COLORS[index % SLICE_COLORS.length];
function sliceColor(index: number, label: string, colors: any): string {
if (label === "Others") return colors.others;
return colors.slices[index % colors.slices.length];
}

/** Custom tooltip shown on hover (desktop) and tap (mobile) */
function CustomTooltip({ active, payload }: any) {
const colors = useChartTheme();
if (!active || !payload?.length) return null;
const slice: OwnershipSlice = payload[0].payload;
return (
<div
data-testid="chart-tooltip"
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-xs shadow-lg"
style={{ backgroundColor: colors.tooltipBg, borderColor: colors.tooltipBorder }}
className="rounded-lg px-3 py-2 text-xs shadow-lg"
>
<p className="text-white font-semibold">{slice.label}</p>
{slice.wallet && (
Expand Down Expand Up @@ -107,13 +96,16 @@ export default function PoolOwnershipChart({ marketId }: Props) {
paddingAngle={2}
strokeWidth={0}
>
{slices.map((slice, i) => (
<Cell
key={slice.label}
fill={sliceColor(i, slice.label)}
aria-label={`${slice.label}: ${slice.percentage.toFixed(1)}%`}
/>
))}
{slices.map((slice, i) => {
const colors = useChartTheme();
return (
<Cell
key={slice.label}
fill={sliceColor(i, slice.label, colors)}
aria-label={`${slice.label}: ${slice.percentage.toFixed(1)}%`}
/>
);
})}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend
Expand Down
21 changes: 11 additions & 10 deletions frontend/src/components/ProbabilityChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
ResponsiveContainer,
} from "recharts";
import { useEffect, useState, useRef } from "react";
import { useChartTheme } from "../ChartThemeProvider";

interface PricePoint {
time: string;
Expand All @@ -36,8 +37,6 @@ interface Props {
initialData?: PricePoint[];
}

const OUTCOME_COLORS = ["#3b82f6", "#22c55e", "#a855f7", "#f59e0b", "#ef4444"];

/** Generate mock probability history until real API endpoint exists */
function generateMockHistory(outcomes: string[], points = 48): PricePoint[] {
const now = Date.now();
Expand All @@ -64,9 +63,10 @@ function generateMockHistory(outcomes: string[], points = 48): PricePoint[] {

/** Custom tooltip */
function ChartTooltip({ active, payload, label }: any) {
const colors = useChartTheme();
if (!active || !payload?.length) return null;
return (
<div className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-xs shadow-xl">
<div style={{ backgroundColor: colors.tooltipBg, borderColor: colors.tooltipBorder }} className="border rounded-lg px-3 py-2 text-xs shadow-xl">
<p className="text-gray-400 mb-1">{label}</p>
{payload.map((p: any) => (
<p key={p.name} style={{ color: p.color }} className="font-semibold">
Expand All @@ -82,6 +82,7 @@ type TimeRange = "1H" | "6H" | "24H" | "7D" | "ALL";
export default function ProbabilityChart({ marketId, outcomes, initialData }: Props) {
const [data, setData] = useState<PricePoint[]>(initialData ?? []);
const [range, setRange] = useState<TimeRange>("24H");
const colors = useChartTheme();
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
Expand Down Expand Up @@ -124,24 +125,24 @@ export default function ProbabilityChart({ marketId, outcomes, initialData }: Pr
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={data} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<defs>
{outcomes.map((o, i) => (
{outcomes.map((o, i) => (
<linearGradient key={o} id={`grad-${i}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={OUTCOME_COLORS[i % OUTCOME_COLORS.length]} stopOpacity={0.3} />
<stop offset="95%" stopColor={OUTCOME_COLORS[i % OUTCOME_COLORS.length]} stopOpacity={0} />
<stop offset="5%" stopColor={colors.slices[i % colors.slices.length]} stopOpacity={0.3} />
<stop offset="95%" stopColor={colors.slices[i % colors.slices.length]} stopOpacity={0} />
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
<CartesianGrid strokeDasharray="3 3" stroke={colors.grid} />
<XAxis
dataKey="time"
tick={{ fill: "#6b7280", fontSize: 10 }}
tick={{ fill: colors.axis, fontSize: 10 }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
domain={[0, 100]}
tick={{ fill: "#6b7280", fontSize: 10 }}
tick={{ fill: colors.axis, fontSize: 10 }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => `${v}%`}
Expand All @@ -157,7 +158,7 @@ export default function ProbabilityChart({ marketId, outcomes, initialData }: Pr
key={o}
type="monotone"
dataKey={o}
stroke={OUTCOME_COLORS[i % OUTCOME_COLORS.length]}
stroke={colors.slices[i % colors.slices.length]}
strokeWidth={2}
fill={`url(#grad-${i})`}
dot={false}
Expand Down
11 changes: 7 additions & 4 deletions frontend/src/components/SimulatorPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import React, { useState, useEffect, useMemo } from "react";
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from "recharts";
} from "recharts";
import { useChartTheme } from "./ChartThemeProvider";

interface SimulatorPanelProps {
market: {
Expand Down Expand Up @@ -43,9 +44,11 @@ export default function SimulatorPanel({ market, selectedOutcomeIndex = 0 }: Sim
const payoutXlm = Number(payoutBig) / 1e7;
const impliedProb = (100 / market.outcomes.length).toFixed(1);

const colors = useChartTheme();

const chartData = [
{ name: "Profit if Correct", value: Math.max(0, payoutXlm - parseFloat(stake || "0")), color: "#4ade80" },
{ name: "Stake at Risk", value: parseFloat(stake || "0"), color: "#f87171" },
{ name: "Profit if Correct", value: Math.max(0, payoutXlm - parseFloat(stake || "0")), color: colors.profit },
{ name: "Stake at Risk", value: parseFloat(stake || "0"), color: colors.risk },
];

const handleStakeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -115,7 +118,7 @@ export default function SimulatorPanel({ market, selectedOutcomeIndex = 0 }: Sim
<XAxis dataKey="name" hide />
<YAxis hide />
<Tooltip
contentStyle={{ backgroundColor: '#111827', border: '1px solid #374151', borderRadius: '8px' }}
contentStyle={{ backgroundColor: colors.tooltipBg, border: `1px solid ${colors.tooltipBorder}`, borderRadius: '8px' }}
itemStyle={{ color: '#fff' }}
/>
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
Expand Down
31 changes: 17 additions & 14 deletions frontend/src/components/WhatIfSimulator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,8 @@
import { useState, useEffect, useRef } from "react";
// Named imports — webpack tree-shakes unused recharts components via the
// package's sideEffects:false declaration, keeping the bundle lean.
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from "recharts";
import { useChartTheme } from "./ChartThemeProvider";
import { calculateSimulator } from "../utils/simulatorCalc";

interface Props {
Expand All @@ -27,13 +20,13 @@ const DEBOUNCE_MS = 200;
export default function WhatIfSimulator({ poolForOutcome, totalPool, maxStake }: Props) {
const [open, setOpen] = useState(false);
const [stake, setStake] = useState(10);
const [result, setResult] = useState(() =>
calculateSimulator(10, poolForOutcome, totalPool)
);
const [result, setResult] = useState(() => calculateSimulator(10, poolForOutcome, totalPool));
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const sliderMax = maxStake ?? Math.max(totalPool * 2, 1000);

const colors = useChartTheme();

// Recalculate with debounce on every stake or pool change
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
Expand Down Expand Up @@ -149,10 +142,20 @@ export default function WhatIfSimulator({ poolForOutcome, totalPool, maxStake }:
<div data-testid="simulator-chart" className="h-40">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<XAxis dataKey="name" tick={{ fill: "#9ca3af", fontSize: 11 }} axisLine={false} tickLine={false} />
<XAxis
dataKey="name"
tick={{ fill: "#9ca3af", fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<YAxis tick={{ fill: "#9ca3af", fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ background: "#1f2937", border: "none", borderRadius: 8, fontSize: 12 }}
contentStyle={{
background: "#1f2937",
border: "none",
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: "#e5e7eb" }}
formatter={(v) => [`${Number(v ?? 0).toFixed(2)} XLM`] as [string]}
/>
Expand Down
Loading
Loading