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 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 ChartProps = {
|
||||||
type: ChartType
|
type: ChartType
|
||||||
labels: string[]
|
labels: string[]
|
||||||
datasets: {
|
datasets: BaseDataset[]
|
||||||
label: string
|
|
||||||
data: number[]
|
|
||||||
backgroundColor?: string | string[]
|
|
||||||
borderColor?: string
|
|
||||||
borderWidth?: number
|
|
||||||
}[]
|
|
||||||
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,33 +89,118 @@ 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,
|
||||||
|
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,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: isAutoHeight,
|
||||||
|
aspectRatio: isAutoHeight ? aspectRatio : undefined,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: { display: !hideLabels, position: 'top' as const },
|
||||||
display: !hideLabels,
|
title: { display: !!title, text: title },
|
||||||
position: 'top' as const,
|
tooltip: {
|
||||||
},
|
callbacks: {
|
||||||
title: {
|
// Tooltip zeigt Originalwert (nicht den gesnappten) an
|
||||||
display: !!title,
|
label: (ctx: any) => {
|
||||||
text: title,
|
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 },
|
if (isRadar) {
|
||||||
y: { display: false },
|
base.scales = {
|
||||||
}
|
r: {
|
||||||
: undefined,
|
min: radarMin,
|
||||||
}),
|
max: radarMax,
|
||||||
[title, hideLabels]
|
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,
|
||||||
@ -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,137 +769,161 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mitte – Mappool */}
|
{/* Mitte – Mappool / Winrate per Tabs */}
|
||||||
<main className="w-full max-w-xl justify-self-center">
|
{tab === 'pool' ? (
|
||||||
<ul className="flex flex-col gap-3">
|
<main className="w-full max-w-xl justify-self-center">
|
||||||
{sortedMapPool.map((map) => {
|
<ul className="flex flex-col gap-3">
|
||||||
const decision = decisionByMap.get(map)
|
{sortedMapPool.map((map) => {
|
||||||
const status = decision?.action ?? null
|
const decision = decisionByMap.get(map)
|
||||||
const teamId = decision?.teamId ?? null
|
const status = decision?.action ?? null
|
||||||
|
const teamId = decision?.teamId ?? null
|
||||||
|
|
||||||
const taken = !!status
|
const taken = !!status
|
||||||
const isAvailable = !taken && isMyTurn && isOpen && !state?.locked
|
const isAvailable = !taken && isMyTurn && isOpen && !state?.locked
|
||||||
|
|
||||||
const intent = isAvailable ? currentStep?.action : null
|
const intent = isAvailable ? currentStep?.action : null
|
||||||
const intentStyles =
|
const intentStyles =
|
||||||
intent === 'ban'
|
intent === 'ban'
|
||||||
? { hover: 'hover:bg-red-50 dark:hover:bg-red-950', progress: 'bg-red-200/60 dark:bg-red-800/40' }
|
? { hover: 'hover:bg-red-50 dark:hover:bg-red-950', progress: 'bg-red-200/60 dark:bg-red-800/40' }
|
||||||
: intent === 'pick'
|
: 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-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' }
|
: { 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 baseClasses = 'relative flex items-center justify-between gap-2 rounded-md border border-neutral-500 p-2.5 transition select-none'
|
||||||
const visualTaken =
|
const visualTaken =
|
||||||
status === 'ban'
|
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'
|
? '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'
|
: status === 'pick' || status === 'decider'
|
||||||
? 'bg-blue-50/60 dark:bg-blue-900/20 border-blue-200 dark:border-blue-900/40'
|
? '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'
|
: '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 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 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 visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled
|
||||||
|
|
||||||
// DECIDER-Chooser (letztes Ban davor)
|
// DECIDER-Chooser (letztes Ban davor)
|
||||||
const steps = state?.steps ?? []
|
const steps = state?.steps ?? []
|
||||||
const decIdx = steps.findIndex(s => s.action === 'decider')
|
const decIdx = steps.findIndex(s => s.action === 'decider')
|
||||||
let deciderChooserTeamId: string | null = null
|
let deciderChooserTeamId: string | null = null
|
||||||
if (decIdx >= 0) {
|
if (decIdx >= 0) {
|
||||||
for (let i = decIdx - 1; i >= 0; i--) {
|
for (let i = decIdx - 1; i >= 0; i--) {
|
||||||
const s = steps[i]
|
const s = steps[i]
|
||||||
if (s.action === 'ban' && s.teamId) { deciderChooserTeamId = s.teamId; break }
|
if (s.action === 'ban' && s.teamId) { deciderChooserTeamId = s.teamId; break }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveTeamId =
|
const effectiveTeamId =
|
||||||
status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null
|
status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null
|
||||||
|
|
||||||
const pickedByLeft = (status === 'pick' || status === 'decider') && effectiveTeamId === leftTeamId
|
const pickedByLeft = (status === 'pick' || status === 'decider') && effectiveTeamId === leftTeamId
|
||||||
const pickedByRight = (status === 'pick' || status === 'decider') && effectiveTeamId === rightTeamId
|
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 progress = progressByMap[map] ?? 0
|
||||||
const showProgress = isAvailable && progress > 0 && progress < 1
|
const showProgress = isAvailable && progress > 0 && progress < 1
|
||||||
|
|
||||||
const disabledTitle = isFrozenByAdmin
|
const disabledTitle = isFrozenByAdmin
|
||||||
? 'Ein Admin bearbeitet gerade – Voting gesperrt'
|
? 'Ein Admin bearbeitet gerade – Voting gesperrt'
|
||||||
: 'Nur der Team-Leader (oder Admin) darf wählen'
|
: 'Nur der Team-Leader (oder Admin) darf wählen'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2">
|
<li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2">
|
||||||
{pickedByLeft ? (
|
{pickedByLeft ? (
|
||||||
<img
|
<img
|
||||||
src={getTeamLogo(teamLeft?.logo)}
|
src={getTeamLogo(teamLeft?.logo)}
|
||||||
alt={teamLeft?.name ?? 'Team'}
|
alt={teamLeft?.name ?? 'Team'}
|
||||||
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
|
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
|
||||||
/>
|
/>
|
||||||
) : <div className="w-10 h-10" />}
|
) : <div className="w-10 h-10" />}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
color="transparent"
|
color="transparent"
|
||||||
className={[baseClasses, visualClasses, 'w-full text-left relative overflow-hidden group', 'transition-colors duration-300 ease-in-out'].join(' ')}
|
className={[baseClasses, visualClasses, 'w-full text-left relative overflow-hidden group', 'transition-colors duration-300 ease-in-out'].join(' ')}
|
||||||
disabled={!isAvailable}
|
disabled={!isAvailable}
|
||||||
size="full"
|
size="full"
|
||||||
title={
|
title={
|
||||||
taken ? (status === 'ban' ? 'Map gebannt' : status === 'pick' ? 'Map gepickt' : 'Decider')
|
taken ? (status === 'ban' ? 'Map gebannt' : status === 'pick' ? 'Map gepickt' : 'Decider')
|
||||||
: isAvailable ? 'Zum Bestätigen gedrückt halten' : disabledTitle
|
: isAvailable ? 'Zum Bestätigen gedrückt halten' : disabledTitle
|
||||||
}
|
}
|
||||||
onMouseDown={() => onHoldStart(map, isAvailable)}
|
onMouseDown={() => onHoldStart(map, isAvailable)}
|
||||||
onMouseUp={() => cancelOrSubmitIfComplete(map)}
|
onMouseUp={() => cancelOrSubmitIfComplete(map)}
|
||||||
onMouseLeave={() => cancelOrSubmitIfComplete(map)}
|
onMouseLeave={() => cancelOrSubmitIfComplete(map)}
|
||||||
onTouchStart={(e: React.TouchEvent) => { e.preventDefault(); onHoldStart(map, isAvailable) }}
|
onTouchStart={(e: React.TouchEvent) => { e.preventDefault(); onHoldStart(map, isAvailable) }}
|
||||||
onTouchEnd={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
|
onTouchEnd={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
|
||||||
onTouchCancel={(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}')` }} />
|
<div className="absolute inset-0 bg-center bg-cover filter opacity-30 transition-opacity duration-300" style={{ backgroundImage: `url('${bg}')` }} />
|
||||||
{showProgress && (
|
{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)}%` }} />
|
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{pickedByRight ? (
|
{taken && (status === 'ban' || status === 'pick' || status === 'decider') && (
|
||||||
<img
|
<>
|
||||||
src={getTeamLogo(teamRight?.logo)}
|
{(((status === 'ban' && teamId === leftTeamId) ||
|
||||||
alt={teamRight?.name ?? 'Team'}
|
(status === 'pick' && effectiveTeamId === leftTeamId) ||
|
||||||
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
|
(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 }}>
|
||||||
) : <div className="w-10 h-10" />}
|
{status === 'ban' ? 'Ban' : 'Pick'}
|
||||||
</li>
|
</span>
|
||||||
)
|
)}
|
||||||
})}
|
{(((status === 'ban' && teamId === rightTeamId) ||
|
||||||
</ul>
|
(status === 'pick' && effectiveTeamId === rightTeamId) ||
|
||||||
</main>
|
(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) */}
|
{/* 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
byMap[key].total += 1
|
// Nur Matches mit ermittelbarem Ergebnis zählen
|
||||||
if (winner) {
|
if (!outcome) continue
|
||||||
if ((winner === 'A' && inA) || (winner === 'B' && inB)) byMap[key].wins += 1
|
|
||||||
else byMap[key].losses += 1
|
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)
|
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