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 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: {
label: string
data: number[]
backgroundColor?: string | string[]
borderColor?: string
borderWidth?: number
}[]
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,33 +89,118 @@ 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(
() => ({
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
}
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: false,
maintainAspectRatio: isAutoHeight,
aspectRatio: isAutoHeight ? aspectRatio : undefined,
plugins: {
legend: {
display: !hideLabels,
position: 'top' as const,
},
title: {
display: !!title,
text: title,
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}%`
},
},
},
},
scales: hideLabels
? {
x: { display: false },
y: { display: false },
}
: undefined,
}),
[title, hideLabels]
)
}
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,
@ -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 =================== */
@ -53,6 +55,8 @@ export default function MapVotePanel({ match }: Props) {
const [adminEditMode, setAdminEditMode] = useState(false)
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(() => {
@ -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,137 +769,161 @@ export default function MapVotePanel({ match }: Props) {
))}
</div>
{/* Mitte Mappool */}
<main className="w-full max-w-xl justify-self-center">
<ul className="flex flex-col gap-3">
{sortedMapPool.map((map) => {
const decision = decisionByMap.get(map)
const status = decision?.action ?? null
const teamId = decision?.teamId ?? null
{/* 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) => {
const decision = decisionByMap.get(map)
const status = decision?.action ?? null
const teamId = decision?.teamId ?? null
const taken = !!status
const isAvailable = !taken && isMyTurn && isOpen && !state?.locked
const taken = !!status
const isAvailable = !taken && isMyTurn && isOpen && !state?.locked
const intent = isAvailable ? currentStep?.action : null
const intentStyles =
intent === 'ban'
? { hover: 'hover:bg-red-50 dark:hover:bg-red-950', progress: 'bg-red-200/60 dark:bg-red-800/40' }
: intent === 'pick'
? { hover: 'hover:bg-green-50 dark:hover:bg-green-950', progress: 'bg-green-200/60 dark:bg-green-800/40' }
: { hover: 'hover:bg-blue-50 dark:hover:bg-blue-950', progress: 'bg-blue-200/60 dark:bg-blue-800/40' }
const intent = isAvailable ? currentStep?.action : null
const intentStyles =
intent === 'ban'
? { hover: 'hover:bg-red-50 dark:hover:bg-red-950', progress: 'bg-red-200/60 dark:bg-red-800/40' }
: intent === 'pick'
? { hover: 'hover:bg-green-50 dark:hover:bg-green-950', progress: 'bg-green-200/60 dark:bg-green-800/40' }
: { hover: 'hover:bg-blue-50 dark:hover:bg-blue-950', progress: 'bg-blue-200/60 dark:bg-blue-800/40' }
const baseClasses = 'relative flex items-center justify-between gap-2 rounded-md border border-neutral-500 p-2.5 transition select-none'
const visualTaken =
status === 'ban'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-900/40 text-red-800 dark:text-red-200'
: status === 'pick' || status === 'decider'
? 'bg-blue-50/60 dark:bg-blue-900/20 border-blue-200 dark:border-blue-900/40'
: 'bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700'
const visualAvailable = `bg-white dark:bg-neutral-900 ${intentStyles.hover} cursor-pointer`
const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}`
const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled
const baseClasses = 'relative flex items-center justify-between gap-2 rounded-md border border-neutral-500 p-2.5 transition select-none'
const visualTaken =
status === 'ban'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-900/40 text-red-800 dark:text-red-200'
: status === 'pick' || status === 'decider'
? 'bg-blue-50/60 dark:bg-blue-900/20 border-blue-200 dark:border-blue-900/40'
: 'bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700'
const visualAvailable = `bg-white dark:bg-neutral-900 ${intentStyles.hover} cursor-pointer`
const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}`
const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled
// DECIDER-Chooser (letztes Ban davor)
const steps = state?.steps ?? []
const decIdx = steps.findIndex(s => s.action === 'decider')
let deciderChooserTeamId: string | null = null
if (decIdx >= 0) {
for (let i = decIdx - 1; i >= 0; i--) {
const s = steps[i]
if (s.action === 'ban' && s.teamId) { deciderChooserTeamId = s.teamId; break }
// DECIDER-Chooser (letztes Ban davor)
const steps = state?.steps ?? []
const decIdx = steps.findIndex(s => s.action === 'decider')
let deciderChooserTeamId: string | null = null
if (decIdx >= 0) {
for (let i = decIdx - 1; i >= 0; i--) {
const s = steps[i]
if (s.action === 'ban' && s.teamId) { deciderChooserTeamId = s.teamId; break }
}
}
}
const effectiveTeamId =
status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null
const effectiveTeamId =
status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null
const pickedByLeft = (status === 'pick' || status === 'decider') && effectiveTeamId === leftTeamId
const pickedByRight = (status === 'pick' || status === 'decider') && effectiveTeamId === rightTeamId
const pickedByLeft = (status === 'pick' || status === 'decider') && effectiveTeamId === leftTeamId
const pickedByRight = (status === 'pick' || status === 'decider') && effectiveTeamId === rightTeamId
const bg = state?.mapVisuals?.[map]?.bg ?? `/assets/img/maps/${map}/${map}_1_png.webp`
const bg = state?.mapVisuals?.[map]?.bg ?? `/assets/img/maps/${map}/${map}_1_png.webp`
const progress = progressByMap[map] ?? 0
const showProgress = isAvailable && progress > 0 && progress < 1
const progress = progressByMap[map] ?? 0
const showProgress = isAvailable && progress > 0 && progress < 1
const disabledTitle = isFrozenByAdmin
? 'Ein Admin bearbeitet gerade Voting gesperrt'
: 'Nur der Team-Leader (oder Admin) darf wählen'
const disabledTitle = isFrozenByAdmin
? 'Ein Admin bearbeitet gerade Voting gesperrt'
: 'Nur der Team-Leader (oder Admin) darf wählen'
return (
<li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2">
{pickedByLeft ? (
<img
src={getTeamLogo(teamLeft?.logo)}
alt={teamLeft?.name ?? 'Team'}
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
/>
) : <div className="w-10 h-10" />}
return (
<li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2">
{pickedByLeft ? (
<img
src={getTeamLogo(teamLeft?.logo)}
alt={teamLeft?.name ?? 'Team'}
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
/>
) : <div className="w-10 h-10" />}
<Button
variant="link"
color="transparent"
className={[baseClasses, visualClasses, 'w-full text-left relative overflow-hidden group', 'transition-colors duration-300 ease-in-out'].join(' ')}
disabled={!isAvailable}
size="full"
title={
taken ? (status === 'ban' ? 'Map gebannt' : status === 'pick' ? 'Map gepickt' : 'Decider')
: isAvailable ? 'Zum Bestätigen gedrückt halten' : disabledTitle
}
onMouseDown={() => onHoldStart(map, isAvailable)}
onMouseUp={() => cancelOrSubmitIfComplete(map)}
onMouseLeave={() => cancelOrSubmitIfComplete(map)}
onTouchStart={(e: React.TouchEvent) => { e.preventDefault(); onHoldStart(map, isAvailable) }}
onTouchEnd={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
onTouchCancel={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
>
<div className="absolute inset-0 bg-center bg-cover filter opacity-30 transition-opacity duration-300" style={{ backgroundImage: `url('${bg}')` }} />
{showProgress && (
<span aria-hidden className={`absolute inset-y-0 left-0 rounded-md ${intentStyles.progress} pointer-events-none z-10`} style={{ width: `${Math.round(progress * 100)}%` }} />
)}
{taken && (status === 'ban' || status === 'pick' || status === 'decider') && (
<>
{(((status === 'ban' && teamId === leftTeamId) ||
(status === 'pick' && effectiveTeamId === leftTeamId) ||
(status === 'decider' && effectiveTeamId === leftTeamId))) && (
<span className={`pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded transition duration-300 ease-out ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}>
{status === 'ban' ? 'Ban' : 'Pick'}
</span>
)}
{(((status === 'ban' && teamId === rightTeamId) ||
(status === 'pick' && effectiveTeamId === rightTeamId) ||
(status === 'decider' && effectiveTeamId === rightTeamId))) && (
<span className={`pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}>
{status === 'ban' ? 'Ban' : 'Pick'}
</span>
)}
</>
)}
<div className="flex-1 min-w-0 relative z-20 flex flex-col items-center justify-center text-center">
<span className="text-[13px] font-medium truncate text-white font-semibold uppercase">{fmt(map)}</span>
{status === 'ban' && (
<span aria-hidden className="absolute inset-0 pointer-events-none flex items-center justify-center z-30">
<svg viewBox="0 0 24 24" className="w-12 h-12 sm:w-14 sm:h-14 opacity-30 text-red-600" fill="currentColor">
<path d="M18.3 5.71a1 1 0 0 0-1.41 0L12 10.59 7.11 5.7A1 1 0 1 0 5.7 7.11L10.59 12l-4.9 4.89a1 1 0 1 0 1.41 1.41L12 13.41l4.89 4.9a1 1 0 0 0 1.41-1.41L13.41 12l4.9-4.89a1 1 0 0 0-.01-1.4Z" />
</svg>
</span>
<Button
variant="link"
color="transparent"
className={[baseClasses, visualClasses, 'w-full text-left relative overflow-hidden group', 'transition-colors duration-300 ease-in-out'].join(' ')}
disabled={!isAvailable}
size="full"
title={
taken ? (status === 'ban' ? 'Map gebannt' : status === 'pick' ? 'Map gepickt' : 'Decider')
: isAvailable ? 'Zum Bestätigen gedrückt halten' : disabledTitle
}
onMouseDown={() => onHoldStart(map, isAvailable)}
onMouseUp={() => cancelOrSubmitIfComplete(map)}
onMouseLeave={() => cancelOrSubmitIfComplete(map)}
onTouchStart={(e: React.TouchEvent) => { e.preventDefault(); onHoldStart(map, isAvailable) }}
onTouchEnd={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
onTouchCancel={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
>
<div className="absolute inset-0 bg-center bg-cover filter opacity-30 transition-opacity duration-300" style={{ backgroundImage: `url('${bg}')` }} />
{showProgress && (
<span aria-hidden className={`absolute inset-y-0 left-0 rounded-md ${intentStyles.progress} pointer-events-none z-10`} style={{ width: `${Math.round(progress * 100)}%` }} />
)}
</div>
</Button>
{pickedByRight ? (
<img
src={getTeamLogo(teamRight?.logo)}
alt={teamRight?.name ?? 'Team'}
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
/>
) : <div className="w-10 h-10" />}
</li>
)
})}
</ul>
</main>
{taken && (status === 'ban' || status === 'pick' || status === 'decider') && (
<>
{(((status === 'ban' && teamId === leftTeamId) ||
(status === 'pick' && effectiveTeamId === leftTeamId) ||
(status === 'decider' && effectiveTeamId === leftTeamId))) && (
<span className={`pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded transition duration-300 ease-out ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}>
{status === 'ban' ? 'Ban' : 'Pick'}
</span>
)}
{(((status === 'ban' && teamId === rightTeamId) ||
(status === 'pick' && effectiveTeamId === rightTeamId) ||
(status === 'decider' && effectiveTeamId === rightTeamId))) && (
<span className={`pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}>
{status === 'ban' ? 'Ban' : 'Pick'}
</span>
)}
</>
)}
<div className="flex-1 min-w-0 relative z-20 flex flex-col items-center justify-center text-center">
<span className="text-[13px] font-medium truncate text-white font-semibold uppercase">{fmt(map)}</span>
{status === 'ban' && (
<span aria-hidden className="absolute inset-0 pointer-events-none flex items-center justify-center z-30">
<svg viewBox="0 0 24 24" className="w-12 h-12 sm:w-14 sm:h-14 opacity-30 text-red-600" fill="currentColor">
<path d="M18.3 5.71a1 1 0 0 0-1.41 0L12 10.59 7.11 5.7A1 1 0 1 0 5.7 7.11L10.59 12l-4.9 4.89a1 1 0 1 0 1.41 1.41L12 13.41l4.89 4.9a1 1 0 0 0 1.41-1.41L13.41 12l4.9-4.89a1 1 0 0 0-.01-1.4Z" />
</svg>
</span>
)}
</div>
</Button>
{pickedByRight ? (
<img
src={getTeamLogo(teamRight?.logo)}
alt={teamRight?.name ?? 'Team'}
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
/>
) : <div className="w-10 h-10" />}
</li>
)
})}
</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,
})
byMap[key].total += 1
if (winner) {
if ((winner === 'A' && inA) || (winner === 'B' && inB)) byMap[key].wins += 1
else byMap[key].losses += 1
// 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
}
}
// 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