updated Mapvote
This commit is contained in:
parent
5100844e77
commit
8ae14cc2b9
@ -12,6 +12,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
Title,
|
Title,
|
||||||
|
Filler,
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
import { Line, Bar, Radar, Doughnut, PolarArea, Bubble, Pie, Scatter } from 'react-chartjs-2'
|
import { Line, Bar, Radar, Doughnut, PolarArea, Bubble, Pie, Scatter } from 'react-chartjs-2'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
@ -26,24 +27,60 @@ ChartJS.register(
|
|||||||
ArcElement,
|
ArcElement,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
Title
|
Title,
|
||||||
|
Filler
|
||||||
)
|
)
|
||||||
|
|
||||||
type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar'
|
type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polararea' | 'bubble' | 'scatter'
|
||||||
|
|
||||||
type ChartProps = {
|
type BaseDataset = {
|
||||||
type: ChartType
|
|
||||||
labels: string[]
|
|
||||||
datasets: {
|
|
||||||
label: string
|
label: string
|
||||||
data: number[]
|
data: number[]
|
||||||
backgroundColor?: string | string[]
|
backgroundColor?: string | string[]
|
||||||
borderColor?: string
|
borderColor?: string
|
||||||
borderWidth?: number
|
borderWidth?: number
|
||||||
}[]
|
fill?: boolean | number | 'origin' | 'start' | 'end'
|
||||||
|
tension?: number
|
||||||
|
spanGaps?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartProps = {
|
||||||
|
type: ChartType
|
||||||
|
labels: string[]
|
||||||
|
datasets: BaseDataset[]
|
||||||
title?: string
|
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
|
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({
|
export default function Chart({
|
||||||
@ -52,34 +89,119 @@ export default function Chart({
|
|||||||
datasets,
|
datasets,
|
||||||
title,
|
title,
|
||||||
height = 300,
|
height = 300,
|
||||||
hideLabels
|
aspectRatio = 2,
|
||||||
}: ChartProps) {
|
hideLabels,
|
||||||
const data = useMemo(() => ({ labels, datasets }), [labels, datasets])
|
className = '',
|
||||||
|
style,
|
||||||
|
|
||||||
const options = useMemo(
|
radarMin = 0,
|
||||||
() => ({
|
radarMax = 100,
|
||||||
responsive: true,
|
radarStepSize = 20,
|
||||||
maintainAspectRatio: false,
|
radarZeroToFirstTick = true,
|
||||||
plugins: {
|
radarHideTicks = true,
|
||||||
legend: {
|
|
||||||
display: !hideLabels,
|
radarFillMode = true,
|
||||||
position: 'top' as const,
|
radarTension = 0.2,
|
||||||
},
|
radarSpanGaps = false,
|
||||||
title: {
|
radarSubStepBand = 4,
|
||||||
display: !!title,
|
}: ChartProps) {
|
||||||
text: title,
|
const isRadar = type === 'radar'
|
||||||
},
|
const isAutoHeight = height === 'auto'
|
||||||
},
|
|
||||||
scales: hideLabels
|
// Original-Daten für Tooltip-Anzeige (echte Prozentwerte)
|
||||||
? {
|
const originalDatasets = datasets
|
||||||
x: { display: false },
|
|
||||||
y: { display: false },
|
// 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,
|
return v
|
||||||
}),
|
}
|
||||||
[title, hideLabels]
|
|
||||||
|
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 = {
|
const chartMap = {
|
||||||
line: Line,
|
line: Line,
|
||||||
bar: Bar,
|
bar: Bar,
|
||||||
@ -89,12 +211,17 @@ export default function Chart({
|
|||||||
bubble: Bubble,
|
bubble: Bubble,
|
||||||
pie: Pie,
|
pie: Pie,
|
||||||
scatter: Scatter,
|
scatter: Scatter,
|
||||||
}
|
} as const
|
||||||
|
|
||||||
const ChartComponent = chartMap[type]
|
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 (
|
return (
|
||||||
<div style={{ height }}>
|
<div className={className} style={wrapperStyle}>
|
||||||
<ChartComponent data={data} options={options} />
|
<ChartComponent data={data} options={options} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import LoadingSpinner from './LoadingSpinner'
|
|||||||
import type { Match, MatchPlayer } from '../../../types/match'
|
import type { Match, MatchPlayer } from '../../../types/match'
|
||||||
import type { MapVoteState } from '../../../types/mapvote'
|
import type { MapVoteState } from '../../../types/mapvote'
|
||||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||||
|
import { Tabs } from './Tabs'
|
||||||
|
import Chart from './Chart'
|
||||||
|
|
||||||
/* =================== Utilities & constants =================== */
|
/* =================== Utilities & constants =================== */
|
||||||
|
|
||||||
@ -54,6 +56,8 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
const [overlayShownOnce, setOverlayShownOnce] = useState(false)
|
const [overlayShownOnce, setOverlayShownOnce] = useState(false)
|
||||||
const [opensAtOverrideTs, setOpensAtOverrideTs] = useState<number | null>(null)
|
const [opensAtOverrideTs, setOpensAtOverrideTs] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<'pool' | 'winrate'>('pool')
|
||||||
|
|
||||||
/* -------- Timers / open window -------- */
|
/* -------- Timers / open window -------- */
|
||||||
const matchBaseTs = useMemo(() => {
|
const matchBaseTs = useMemo(() => {
|
||||||
const raw = match.matchDate ?? match.demoDate ?? null
|
const raw = match.matchDate ?? match.demoDate ?? null
|
||||||
@ -449,6 +453,102 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
)
|
)
|
||||||
}, [state?.mapPool, state?.mapVisuals])
|
}, [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 =================== */
|
/* =================== Render =================== */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -614,6 +714,19 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Hauptbereich */}
|
||||||
{state && (
|
{state && (
|
||||||
<div className="mt-0 grid grid-cols-[0.8fr_1.4fr_0.8fr] gap-10 items-start">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Mitte – Mappool */}
|
{/* Mitte – Mappool / Winrate per Tabs */}
|
||||||
|
{tab === 'pool' ? (
|
||||||
<main className="w-full max-w-xl justify-self-center">
|
<main className="w-full max-w-xl justify-self-center">
|
||||||
<ul className="flex flex-col gap-3">
|
<ul className="flex flex-col gap-3">
|
||||||
{sortedMapPool.map((map) => {
|
{sortedMapPool.map((map) => {
|
||||||
@ -787,6 +901,29 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</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) */}
|
{/* Rechte Spalte (Gegner) */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
// /src/app/api/user/[steamId]/winrate/route.ts
|
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||||
@ -8,38 +7,42 @@ function normMapKey(raw?: string | null) {
|
|||||||
return (raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
|
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_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_ACTIVE_BY_KEY = new Map(MAP_OPTIONS.map(o => [o.key, o.active] as const))
|
||||||
const MAP_ORDER_BY_KEY = new Map(
|
const MAP_ORDER_BY_KEY = new Map(MAP_OPTIONS.map((o, idx) => [o.key, idx] as const))
|
||||||
MAP_OPTIONS.map((o, idx) => [o.key, idx] as const) // Reihenfolge wie in mapOptions
|
|
||||||
)
|
|
||||||
|
|
||||||
// Optional: bestimmte Pseudo-„Maps“ ignorieren (Lobby, etc.)
|
// Pseudo-Maps ignorieren
|
||||||
const IGNORED_KEYS = new Set(['lobby_mapvote'])
|
const IGNORED_KEYS = new Set(['lobby_mapvote'])
|
||||||
|
|
||||||
function labelFor(key: string) {
|
function labelFor(key: string) {
|
||||||
return MAP_LABEL_BY_KEY.get(key)
|
return (
|
||||||
?? key.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
MAP_LABEL_BY_KEY.get(key) ??
|
||||||
|
key.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gewinner-Seite ermitteln */
|
/** Gewinner-Seite ermitteln; wenn scoreA/scoreB gleich => Tie */
|
||||||
function computeWinnerSide(m: {
|
function computeOutcome(m: {
|
||||||
winnerTeam: string | null
|
winnerTeam: string | null
|
||||||
teamAId: string | null
|
teamAId: string | null
|
||||||
teamBId: string | null
|
teamBId: string | null
|
||||||
scoreA: number | null
|
scoreA: number | null
|
||||||
scoreB: 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()
|
const w = (m.winnerTeam ?? '').trim().toLowerCase()
|
||||||
if (w) {
|
if (w) {
|
||||||
if (w === 'a' || w === (m.teamAId ?? '').toLowerCase()) return 'A'
|
if (w === 'a' || w === (m.teamAId ?? '').toLowerCase()) return 'A'
|
||||||
if (w === 'b' || w === (m.teamBId ?? '').toLowerCase()) return 'B'
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,10 +51,10 @@ function computeWinnerSide(m: {
|
|||||||
*
|
*
|
||||||
* Antwort:
|
* Antwort:
|
||||||
* {
|
* {
|
||||||
* labels: string[] // z.B. ["Inferno", "Mirage", ...] – aus MAP_OPTIONS
|
* labels: string[]
|
||||||
* keys: string[] // z.B. ["de_inferno", "de_mirage", ...]
|
* keys: string[]
|
||||||
* values: number[] // Winrate 0..100 (ein Nachkomma)
|
* values: number[] // Winrate 0..100 (1 Nachkomma), (W + 0.5*T) / (W+L+T)
|
||||||
* byMap: Record<key, { wins, losses, total, pct }>
|
* byMap: Record<key, { wins, losses, ties, total, pct }>
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export async function GET(req: NextRequest, { params }: { params: { steamId: string } }) {
|
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 types = typesParam ? typesParam.split(',').map(s => s.trim()).filter(Boolean) : []
|
||||||
const onlyActive = (searchParams.get('onlyActive') ?? 'true').toLowerCase() !== 'false'
|
const onlyActive = (searchParams.get('onlyActive') ?? 'true').toLowerCase() !== 'false'
|
||||||
|
|
||||||
|
// Relevante Matches holen; inkl. MatchPlayer-Team-Zuordnung als Fallback
|
||||||
const matches = await prisma.match.findMany({
|
const matches = await prisma.match.findMany({
|
||||||
where: {
|
where: {
|
||||||
players: { some: { steamId } },
|
players: { some: { steamId } },
|
||||||
@ -79,26 +83,41 @@ export async function GET(req: NextRequest, { params }: { params: { steamId: str
|
|||||||
winnerTeam: true,
|
winnerTeam: true,
|
||||||
teamAUsers: { select: { steamId: true } },
|
teamAUsers: { select: { steamId: true } },
|
||||||
teamBUsers: { select: { steamId: true } },
|
teamBUsers: { select: { steamId: true } },
|
||||||
|
players: {
|
||||||
|
where: { steamId },
|
||||||
|
select: { teamId: true }, // 👈 Fallback für Team-Zuordnung
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
|
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
|
||||||
take: 1000,
|
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) {
|
for (const m of matches) {
|
||||||
const keyRaw = normMapKey(m.map) || 'unknown'
|
const keyRaw = normMapKey(m.map) || 'unknown'
|
||||||
if (IGNORED_KEYS.has(keyRaw)) continue
|
if (IGNORED_KEYS.has(keyRaw)) continue
|
||||||
if (onlyActive && MAP_ACTIVE_BY_KEY.has(keyRaw) && !MAP_ACTIVE_BY_KEY.get(keyRaw)) continue
|
if (onlyActive && MAP_ACTIVE_BY_KEY.has(keyRaw) && !MAP_ACTIVE_BY_KEY.get(keyRaw)) continue
|
||||||
|
|
||||||
const key = keyRaw
|
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)
|
// ◀ Team-Zuordnung robust bestimmen
|
||||||
const inB = !inA && m.teamBUsers.some(u => u.steamId === steamId)
|
const inA_fromRel = m.teamAUsers.some(u => u.steamId === steamId)
|
||||||
if (!inA && !inB) continue
|
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,
|
winnerTeam: m.winnerTeam ?? null,
|
||||||
teamAId: m.teamAId ?? null,
|
teamAId: m.teamAId ?? null,
|
||||||
teamBId: m.teamBId ?? null,
|
teamBId: m.teamBId ?? null,
|
||||||
@ -106,21 +125,31 @@ export async function GET(req: NextRequest, { params }: { params: { steamId: str
|
|||||||
scoreB: m.scoreB ?? null,
|
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
|
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)
|
const presentKeys = Object.keys(byMap)
|
||||||
for (const k of presentKeys) {
|
for (const k of presentKeys) {
|
||||||
const it = byMap[k]
|
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 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 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
|
const ib = MAP_ORDER_BY_KEY.has(b) ? (MAP_ORDER_BY_KEY.get(b) as number) : Number.POSITIVE_INFINITY
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user