updated Mapvote
This commit is contained in:
parent
5100844e77
commit
8ae14cc2b9
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user