updated Mapvote

This commit is contained in:
Linrador 2025-09-27 15:53:05 +02:00
parent 5100844e77
commit 8ae14cc2b9
3 changed files with 477 additions and 184 deletions

View File

@ -12,6 +12,7 @@ import {
Tooltip,
Legend,
Title,
Filler,
} from 'chart.js'
import { Line, Bar, Radar, Doughnut, PolarArea, Bubble, Pie, Scatter } from 'react-chartjs-2'
import { useMemo } from 'react'
@ -26,24 +27,60 @@ ChartJS.register(
ArcElement,
Tooltip,
Legend,
Title
Title,
Filler
)
type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar'
type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polararea' | 'bubble' | 'scatter'
type ChartProps = {
type: ChartType
labels: string[]
datasets: {
type BaseDataset = {
label: string
data: number[]
backgroundColor?: string | string[]
borderColor?: string
borderWidth?: number
}[]
fill?: boolean | number | 'origin' | 'start' | 'end'
tension?: number
spanGaps?: boolean
}
type ChartProps = {
type: ChartType
labels: string[]
datasets: BaseDataset[]
title?: string
height?: number
/** Fixe Höhe in px ODER 'auto' (Höhe aus aspectRatio) */
height?: number | 'auto'
/** Wird genutzt, wenn height='auto'. Standard: 2 (Breite/Höhe = 2:1) */
aspectRatio?: number
/** Legend & Achsen (nicht Radar) ausblenden */
hideLabels?: boolean
className?: string
style?: React.CSSProperties
/** Radar-spezifisch */
radarMin?: number // default 0
radarMax?: number // default 100
radarStepSize?: number // default 20 (erster Ring)
radarZeroToFirstTick?: boolean // default true 0% auf ersten Tick zeichnen
radarHideTicks?: boolean // default true 100%, 80%, … ausblenden
/** Feintuning Radar */
radarFillMode?: boolean | number | 'origin' | 'start' | 'end' // default: true
radarTension?: number // default: 0.2
radarSpanGaps?: boolean // default: false
/**
* Werte im Intervall (0..ersterTick) werden in
* [ersterTick .. ersterTick+radarSubStepBand] gemappt,
* damit sie sichtbar ÜBER 0% liegen.
* Einheit: Prozentpunkte. default: 4
*/
radarSubStepBand?: number
}
export default function Chart({
@ -52,34 +89,119 @@ export default function Chart({
datasets,
title,
height = 300,
hideLabels
}: ChartProps) {
const data = useMemo(() => ({ labels, datasets }), [labels, datasets])
aspectRatio = 2,
hideLabels,
className = '',
style,
const options = useMemo(
() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: !hideLabels,
position: 'top' as const,
},
title: {
display: !!title,
text: title,
},
},
scales: hideLabels
? {
x: { display: false },
y: { display: false },
radarMin = 0,
radarMax = 100,
radarStepSize = 20,
radarZeroToFirstTick = true,
radarHideTicks = true,
radarFillMode = true,
radarTension = 0.2,
radarSpanGaps = false,
radarSubStepBand = 4,
}: ChartProps) {
const isRadar = type === 'radar'
const isAutoHeight = height === 'auto'
// Original-Daten für Tooltip-Anzeige (echte Prozentwerte)
const originalDatasets = datasets
// Werte fürs Radar aufbereiten:
// - 0% auf ersten Tick
// - (0..ersterTick) leicht oberhalb des ersten Ticks verteilen
const preparedDatasets = useMemo(() => {
if (!isRadar) return originalDatasets
const step = Math.max(radarStepSize, 1)
const band = Math.max(0, radarSubStepBand)
const mapVal = (v: number) => {
if (!radarZeroToFirstTick) return v
if (v === 0) return step
if (v > 0 && v < step) {
const t = v / step // 0..1
return step + t * band
}
: undefined,
}),
[title, hideLabels]
return v
}
return originalDatasets.map(ds => ({
...ds,
fill: ds.fill ?? radarFillMode,
tension: ds.tension ?? radarTension,
spanGaps: ds.spanGaps ?? radarSpanGaps,
data: ds.data.map(mapVal),
}))
}, [originalDatasets, isRadar, radarZeroToFirstTick, radarStepSize, radarSubStepBand, radarFillMode, radarTension, radarSpanGaps])
const data = useMemo(
() => ({ labels, datasets: preparedDatasets }),
[labels, preparedDatasets]
)
const options = useMemo(() => {
const base: any = {
responsive: true,
maintainAspectRatio: isAutoHeight,
aspectRatio: isAutoHeight ? aspectRatio : undefined,
plugins: {
legend: { display: !hideLabels, position: 'top' as const },
title: { display: !!title, text: title },
tooltip: {
callbacks: {
// Tooltip zeigt Originalwert (nicht den gesnappten) an
label: (ctx: any) => {
const dsIdx = ctx.datasetIndex
const i = ctx.dataIndex
const orig = originalDatasets?.[dsIdx]?.data?.[i]
const val = Number.isFinite(orig) ? (orig as number) : ctx.parsed?.r
const name = ctx.dataset?.label ?? ''
return `${name}: ${val?.toFixed?.(1) ?? val}%`
},
},
},
},
}
if (isRadar) {
base.scales = {
r: {
min: radarMin,
max: radarMax,
ticks: {
display: !radarHideTicks,
stepSize: radarStepSize,
showLabelBackdrop: false,
backdropColor: 'transparent',
callback: (v: number) => `${v}%`,
},
angleLines: { color: 'rgba(255,255,255,0.08)' },
grid: { color: 'rgba(255,255,255,0.08)' },
pointLabels: { color: 'inherit' },
},
}
} else if (hideLabels) {
base.scales = { x: { display: false }, y: { display: false } }
}
return base
}, [
title,
hideLabels,
isAutoHeight,
aspectRatio,
isRadar,
radarMin,
radarMax,
radarStepSize,
radarHideTicks,
originalDatasets,
])
const chartMap = {
line: Line,
bar: Bar,
@ -89,12 +211,17 @@ export default function Chart({
bubble: Bubble,
pie: Pie,
scatter: Scatter,
}
} as const
const ChartComponent = chartMap[type]
// Container: bei fixer Höhe -> px setzen; bei auto -> Höhe aus aspectRatio
const wrapperStyle: React.CSSProperties = isAutoHeight
? { width: '100%', ...style }
: { height: typeof height === 'number' ? height : undefined, width: '100%', ...style }
return (
<div style={{ height }}>
<div className={className} style={wrapperStyle}>
<ChartComponent data={data} options={options} />
</div>
)

View File

@ -16,6 +16,8 @@ import LoadingSpinner from './LoadingSpinner'
import type { Match, MatchPlayer } from '../../../types/match'
import type { MapVoteState } from '../../../types/mapvote'
import { MAP_OPTIONS } from '@/lib/mapOptions'
import { Tabs } from './Tabs'
import Chart from './Chart'
/* =================== Utilities & constants =================== */
@ -54,6 +56,8 @@ export default function MapVotePanel({ match }: Props) {
const [overlayShownOnce, setOverlayShownOnce] = useState(false)
const [opensAtOverrideTs, setOpensAtOverrideTs] = useState<number | null>(null)
const [tab, setTab] = useState<'pool' | 'winrate'>('pool')
/* -------- Timers / open window -------- */
const matchBaseTs = useMemo(() => {
const raw = match.matchDate ?? match.demoDate ?? null
@ -449,6 +453,102 @@ export default function MapVotePanel({ match }: Props) {
)
}, [state?.mapPool, state?.mapVisuals])
// -------- Team-Winrate (Radar) Daten --------
// 1) Aktive, echte Maps aus deinen MAP_OPTIONS (keine Lobby/Pseudo-Maps)
const activeMapKeys = useMemo(
() => MAP_OPTIONS
.filter(o => o.active && o.key.startsWith('de_'))
.map(o => o.key),
[]
)
// Labels passend zu den Keys
const activeMapLabels = useMemo(
() => activeMapKeys.map(k => MAP_OPTIONS.find(o => o.key === k)?.label ?? k),
[activeMapKeys]
)
// Response-Struktur des /api/user/:id/winrate Endpoints (wir brauchen nur keys/values)
type WinrateResponse = { keys: string[]; values: number[] }
// Helper: Winrate eines Spielers als Dictionary { [mapKey]: pct }
async function fetchWinrate(steamId: string): Promise<Record<string, number>> {
const r = await fetch(`/api/user/${steamId}/winrate?onlyActive=true`, { cache: 'no-store' })
if (!r.ok) throw new Error('winrate fetch failed')
const j: WinrateResponse = await r.json()
const out: Record<string, number> = {}
j.keys.forEach((key, i) => { out[key] = j.values[i] ?? 0 })
return out
}
// Helper: Durchschnitt (nur finite Werte)
function avg(values: number[]) {
const valid = values.filter(v => Number.isFinite(v))
if (!valid.length) return 0
return valid.reduce((a, b) => a + b, 0) / valid.length
}
// 2) State für Radar-Daten je Team + Team-Ø
const [teamRadarLeft, setTeamRadarLeft] = useState<number[]>(activeMapKeys.map(() => 0))
const [teamRadarRight, setTeamRadarRight] = useState<number[]>(activeMapKeys.map(() => 0))
const [teamAvgLeft, setTeamAvgLeft] = useState<number>(0)
const [teamAvgRight, setTeamAvgRight] = useState<number>(0)
// 3) Laden & Aggregieren: Mittelwert pro Map über alle Spieler des Teams
useEffect(() => {
let cancelled = false
async function loadTeam(
teamPlayers: MatchPlayer[],
setterData: (arr: number[]) => void,
setterAvg: (v: number) => void
) {
try {
if (!teamPlayers.length) {
setterData(activeMapKeys.map(() => 0))
setterAvg(0)
return
}
// Alle Spieler parallel holen
const perPlayer = await Promise.allSettled(
teamPlayers.map(p => fetchWinrate(p.user.steamId))
)
// Mittelwert pro MapKey bilden
const mapAverages: number[] = activeMapKeys.map(k => {
const vals: number[] = []
perPlayer.forEach(res => {
if (res.status === 'fulfilled' && typeof res.value[k] === 'number') {
vals.push(res.value[k])
}
})
return vals.length ? avg(vals) : 0
})
// Team-Gesamtdurchschnitt: nur Maps berücksichtigen, für die es Daten gab (>0)
const present = mapAverages.filter(v => v > 0)
const teamAverage = present.length ? avg(present) : 0
if (!cancelled) {
setterData(mapAverages.map(v => Math.round(v * 10) / 10)) // 1 Nachkommastelle
setterAvg(Math.round(teamAverage * 10) / 10)
}
} catch {
if (!cancelled) {
setterData(activeMapKeys.map(() => 0))
setterAvg(0)
}
}
}
loadTeam(playersLeft, setTeamRadarLeft, setTeamAvgLeft)
loadTeam(playersRight, setTeamRadarRight, setTeamAvgRight)
return () => { cancelled = true }
}, [playersLeft, playersRight, activeMapKeys])
/* =================== Render =================== */
return (
@ -614,6 +714,19 @@ export default function MapVotePanel({ match }: Props) {
</div>
</div>
{/* Tabs für Mittelbereich */}
<div className="mb-3 flex justify-center">
<Tabs
value={tab === 'pool' ? 'Mappool' : 'Winrate'}
onChange={(name) => setTab(name === 'Winrate' ? 'winrate' : 'pool')}
className="justify-center"
tabClassName="backdrop-blur-sm"
>
<Tabs.Tab name="Mappool" href="#pool" />
<Tabs.Tab name="Winrate" href="#winrate" />
</Tabs>
</div>
{/* Hauptbereich */}
{state && (
<div className="mt-0 grid grid-cols-[0.8fr_1.4fr_0.8fr] gap-10 items-start">
@ -656,7 +769,8 @@ export default function MapVotePanel({ match }: Props) {
))}
</div>
{/* Mitte Mappool */}
{/* Mitte Mappool / Winrate per Tabs */}
{tab === 'pool' ? (
<main className="w-full max-w-xl justify-self-center">
<ul className="flex flex-col gap-3">
{sortedMapPool.map((map) => {
@ -787,6 +901,29 @@ export default function MapVotePanel({ match }: Props) {
})}
</ul>
</main>
) : (
// Winrate-Tab
<div className="w-full max-w-xl justify-self-center">
<div className="rounded-lg border border-white/10 bg-neutral-900/40 backdrop-blur-sm p-3" style={{ height: 360 }}>
<div className="w-full max-w-xl justify-self-center">
<Chart
type="radar"
labels={activeMapLabels}
height={"auto"}
radarZeroToFirstTick // 0% auf ersten Tick
radarSubStepBand={4} // (0..20%) wird in 20..24% gestreckt (sichtbar über 0)
radarHideTicks // Ticks ausblenden (optional)
datasets={[
{ label: teamLeft?.name ?? 'Team Links', data: teamRadarLeft,
borderColor: 'rgba(54,162,235,0.9)', backgroundColor: 'rgba(54,162,235,0.20)', borderWidth: 2 },
{ label: teamRight?.name ?? 'Team Rechts', data: teamRadarRight,
borderColor: 'rgba(255,99,132,0.9)', backgroundColor: 'rgba(255,99,132,0.20)', borderWidth: 2 },
]}
/>
</div>
</div>
</div>
)}
{/* Rechte Spalte (Gegner) */}
<div

View File

@ -1,4 +1,3 @@
// /src/app/api/user/[steamId]/winrate/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { MAP_OPTIONS } from '@/lib/mapOptions'
@ -8,38 +7,42 @@ function normMapKey(raw?: string | null) {
return (raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
}
// Label- und Ordnungs-Lookup aus MAP_OPTIONS aufbauen
// Label-/Order-Lookups aus MAP_OPTIONS
const MAP_LABEL_BY_KEY = new Map(MAP_OPTIONS.map(o => [o.key, o.label] as const))
const MAP_ACTIVE_BY_KEY = new Map(MAP_OPTIONS.map(o => [o.key, o.active] as const))
const MAP_ORDER_BY_KEY = new Map(
MAP_OPTIONS.map((o, idx) => [o.key, idx] as const) // Reihenfolge wie in mapOptions
)
const MAP_ORDER_BY_KEY = new Map(MAP_OPTIONS.map((o, idx) => [o.key, idx] as const))
// Optional: bestimmte Pseudo-Maps ignorieren (Lobby, etc.)
// Pseudo-Maps ignorieren
const IGNORED_KEYS = new Set(['lobby_mapvote'])
function labelFor(key: string) {
return MAP_LABEL_BY_KEY.get(key)
?? key.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
return (
MAP_LABEL_BY_KEY.get(key) ??
key.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
)
}
/** Gewinner-Seite ermitteln */
function computeWinnerSide(m: {
/** Gewinner-Seite ermitteln; wenn scoreA/scoreB gleich => Tie */
function computeOutcome(m: {
winnerTeam: string | null
teamAId: string | null
teamBId: string | null
scoreA: number | null
scoreB: number | null
}): 'A' | 'B' | null {
}): 'A' | 'B' | 'TIE' | null {
// 1) Score bevorzugen, da eindeutig (und Ties erkennbar)
if (typeof m.scoreA === 'number' && typeof m.scoreB === 'number') {
if (m.scoreA > m.scoreB) return 'A'
if (m.scoreB > m.scoreA) return 'B'
return 'TIE'
}
// 2) Fallback: winnerTeam kann 'A'/'B' oder teamAId/teamBId sein
const w = (m.winnerTeam ?? '').trim().toLowerCase()
if (w) {
if (w === 'a' || w === (m.teamAId ?? '').toLowerCase()) return 'A'
if (w === 'b' || w === (m.teamBId ?? '').toLowerCase()) return 'B'
}
if (typeof m.scoreA === 'number' && typeof m.scoreB === 'number') {
if (m.scoreA > m.scoreB) return 'A'
if (m.scoreB > m.scoreA) return 'B'
}
return null
}
@ -48,10 +51,10 @@ function computeWinnerSide(m: {
*
* Antwort:
* {
* labels: string[] // z.B. ["Inferno", "Mirage", ...] aus MAP_OPTIONS
* keys: string[] // z.B. ["de_inferno", "de_mirage", ...]
* values: number[] // Winrate 0..100 (ein Nachkomma)
* byMap: Record<key, { wins, losses, total, pct }>
* labels: string[]
* keys: string[]
* values: number[] // Winrate 0..100 (1 Nachkomma), (W + 0.5*T) / (W+L+T)
* byMap: Record<key, { wins, losses, ties, total, pct }>
* }
*/
export async function GET(req: NextRequest, { params }: { params: { steamId: string } }) {
@ -64,6 +67,7 @@ export async function GET(req: NextRequest, { params }: { params: { steamId: str
const types = typesParam ? typesParam.split(',').map(s => s.trim()).filter(Boolean) : []
const onlyActive = (searchParams.get('onlyActive') ?? 'true').toLowerCase() !== 'false'
// Relevante Matches holen; inkl. MatchPlayer-Team-Zuordnung als Fallback
const matches = await prisma.match.findMany({
where: {
players: { some: { steamId } },
@ -79,26 +83,41 @@ export async function GET(req: NextRequest, { params }: { params: { steamId: str
winnerTeam: true,
teamAUsers: { select: { steamId: true } },
teamBUsers: { select: { steamId: true } },
players: {
where: { steamId },
select: { teamId: true }, // 👈 Fallback für Team-Zuordnung
},
},
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
take: 1000,
})
const byMap: Record<string, { wins: number; losses: number; total: number; pct: number }> = {}
type Agg = { wins: number; losses: number; ties: number; total: number; pct: number }
const byMap: Record<string, Agg> = {}
for (const m of matches) {
const keyRaw = normMapKey(m.map) || 'unknown'
if (IGNORED_KEYS.has(keyRaw)) continue
if (onlyActive && MAP_ACTIVE_BY_KEY.has(keyRaw) && !MAP_ACTIVE_BY_KEY.get(keyRaw)) continue
const key = keyRaw
if (!byMap[key]) byMap[key] = { wins: 0, losses: 0, total: 0, pct: 0 }
if (!byMap[key]) byMap[key] = { wins: 0, losses: 0, ties: 0, total: 0, pct: 0 }
const inA = m.teamAUsers.some(u => u.steamId === steamId)
const inB = !inA && m.teamBUsers.some(u => u.steamId === steamId)
if (!inA && !inB) continue
// ◀ Team-Zuordnung robust bestimmen
const inA_fromRel = m.teamAUsers.some(u => u.steamId === steamId)
const inB_fromRel = m.teamBUsers.some(u => u.steamId === steamId)
const winner = computeWinnerSide({
let side: 'A' | 'B' | null = null
if (inA_fromRel) side = 'A'
else if (inB_fromRel) side = 'B'
else {
// Fallback via MatchPlayer.teamId
const teamId = m.players[0]?.teamId ?? null
if (teamId && m.teamAId && teamId === m.teamAId) side = 'A'
else if (teamId && m.teamBId && teamId === m.teamBId) side = 'B'
}
if (!side) continue // keine Teamzuordnung ⇒ ignorieren
const outcome = computeOutcome({
winnerTeam: m.winnerTeam ?? null,
teamAId: m.teamAId ?? null,
teamBId: m.teamBId ?? null,
@ -106,21 +125,31 @@ export async function GET(req: NextRequest, { params }: { params: { steamId: str
scoreB: m.scoreB ?? null,
})
// Nur Matches mit ermittelbarem Ergebnis zählen
if (!outcome) continue
if (outcome === 'TIE') {
byMap[key].ties += 1
byMap[key].total += 1
} else if (outcome === side) {
byMap[key].wins += 1
byMap[key].total += 1
} else {
byMap[key].losses += 1
byMap[key].total += 1
if (winner) {
if ((winner === 'A' && inA) || (winner === 'B' && inB)) byMap[key].wins += 1
else byMap[key].losses += 1
}
}
// Prozente berechnen
// Prozente berechnen: (W + 0.5*T) / (W+L+T)
const presentKeys = Object.keys(byMap)
for (const k of presentKeys) {
const it = byMap[k]
it.pct = it.total > 0 ? Math.round((it.wins / it.total) * 1000) / 10 : 0 // 1 Nachkomma
const denom = it.wins + it.losses + it.ties
const ratio = denom > 0 ? (it.wins + 0.5 * it.ties) / denom : 0
it.pct = Math.round(ratio * 1000) / 10 // 1 Nachkommastelle
}
// Sortierung: 1) Reihenfolge aus MAP_OPTIONS, 2) danach alphabetisch (Label)
// Sortierung: erst MAP_OPTIONS-Reihenfolge, dann Label
const sortedKeys = presentKeys.sort((a, b) => {
const ia = MAP_ORDER_BY_KEY.has(a) ? (MAP_ORDER_BY_KEY.get(a) as number) : Number.POSITIVE_INFINITY
const ib = MAP_ORDER_BY_KEY.has(b) ? (MAP_ORDER_BY_KEY.get(b) as number) : Number.POSITIVE_INFINITY