Files
healer-man/src/ui/BalanceLabResults.tsx
T
2026-09-18 09:34:59 -04:00

286 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from "react";
import type {
BalanceLabBossResult,
BalanceLabClassAbilityResult,
BalanceLabClassSpecResult,
BalanceLabPartyResult,
BalanceLabSuiteReport,
} from "../app/balanceLabClient";
type ResultsTab = "overview" | "bosses" | "classes" | "abilities" | "parties";
type PartySourceFilter = "all" | "wow" | "coa" | "rom";
type BossSourceFilter = "all" | "wow" | "rom";
interface ChartDatum {
readonly key: string;
readonly label: string;
readonly detail: string;
readonly value: number;
}
const number = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
const wholeNumber = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
function formatNumber(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : number.format(value);
}
function formatWhole(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : wholeNumber.format(value);
}
function formatPercent(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : `${number.format(value * 100)}%`;
}
function formatSeconds(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : `${number.format(value)}s`;
}
function formatRatio(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : `${number.format(value)}×`;
}
function flagClass(flag: string): string {
if (flag === "high" || flag === "very high") return "is-high";
if (flag === "low" || flag === "very low") return "is-low";
return "is-baseline";
}
function BarChart({
title,
description,
rows,
format = formatNumber,
tone = "damage",
}: {
readonly title: string;
readonly description: string;
readonly rows: readonly ChartDatum[];
readonly format?: (value: number) => string;
readonly tone?: "damage" | "party" | "time" | "class" | "taken" | "healing" | "received";
}) {
const sorted = [...rows].filter((row) => Number.isFinite(row.value)).sort((left, right) => right.value - left.value);
const visible = sorted.slice(0, 15);
const maximum = Math.max(1, ...visible.map((row) => row.value));
return (
<article className={`balance-results-chart is-${tone}`} aria-label={title}>
<header><div><h4>{title}</h4><p>{description}</p></div><strong>{rows.length}</strong></header>
<div className="balance-results-chart__plot">
{visible.map((row) => (
<div className="balance-results-bar" key={row.key} title={`${row.label}: ${format(row.value)}`}>
<span className="balance-results-bar__label"><b>{row.label}</b><small>{row.detail}</small></span>
<span className="balance-results-bar__track"><i style={{ width: `${Math.max(1.5, (row.value / maximum) * 100)}%` }} /></span>
<strong>{format(row.value)}</strong>
</div>
))}
{visible.length === 0 && <p className="balance-results-empty">No matching data for this graph.</p>}
</div>
{sorted.length > visible.length && <footer>Showing the highest 15 of {sorted.length}. The complete filtered set is available in the tables.</footer>}
</article>
);
}
function sortNumeric<T>(rows: readonly T[], read: (row: T) => number | null, ascending: boolean): T[] {
return [...rows].sort((left, right) => {
const leftValue = read(left);
const rightValue = read(right);
const leftComparable = leftValue === null || !Number.isFinite(leftValue) ? Number.NEGATIVE_INFINITY : leftValue;
const rightComparable = rightValue === null || !Number.isFinite(rightValue) ? Number.NEGATIVE_INFINITY : rightValue;
return (leftComparable - rightComparable) * (ascending ? 1 : -1);
});
}
export function BalanceLabResults({ report }: { readonly report: BalanceLabSuiteReport }) {
const [tab, setTab] = useState<ResultsTab>("overview");
const [search, setSearch] = useState("");
const [partySource, setPartySource] = useState<PartySourceFilter>("all");
const [bossSource, setBossSource] = useState<BossSourceFilter>("all");
const [role, setRole] = useState("all");
const [bossSort, setBossSort] = useState<keyof BalanceLabBossResult>("incomingDtps");
const [classSort, setClassSort] = useState<keyof BalanceLabClassSpecResult>("bossDps");
const [abilityBuild, setAbilityBuild] = useState("all");
const [abilityStatus, setAbilityStatus] = useState("all");
const [abilitySort, setAbilitySort] = useState<keyof BalanceLabClassAbilityResult>("effectiveDamage");
const [partySort, setPartySort] = useState<keyof BalanceLabPartyResult>("partyDps");
const [ascending, setAscending] = useState(false);
const query = search.trim().toLowerCase();
const bosses = useMemo(() => report.bosses.filter((boss) => (
(bossSource === "all" || boss.category === bossSource)
&& (!query || `${boss.bossName} ${boss.dungeonTitle} ${boss.bossKey} ${boss.pressureFlag}`.toLowerCase().includes(query))
)), [bossSource, query, report.bosses]);
const classes = useMemo(() => report.classSpecs.filter((row) => (
(partySource === "all" || row.source === partySource)
&& (role === "all" || row.role === role)
&& (!query || `${row.className} ${row.specialization} ${row.role} ${row.source} ${row.damageFlag}`.toLowerCase().includes(query))
)), [partySource, query, report.classSpecs, role]);
const parties = useMemo(() => report.partyConfigurations.filter((row) => (
(partySource === "all" || row.source === partySource)
&& (!query || `${row.partyConfigurationId} ${row.source} ${row.members}`.toLowerCase().includes(query))
)), [partySource, query, report.partyConfigurations]);
const allAbilities = report.classAbilities ?? [];
const abilityBuildOptions = useMemo(() => {
const unique = new Map<string, BalanceLabClassAbilityResult>();
for (const row of allAbilities) unique.set(`${row.source}/${row.buildId}`, row);
return [...unique.entries()].sort((left, right) => (
left[1].source.localeCompare(right[1].source)
|| left[1].className.localeCompare(right[1].className)
|| left[1].specialization.localeCompare(right[1].specialization)
));
}, [allAbilities]);
const abilityBuildKeys = useMemo(() => new Set(abilityBuildOptions.map(([key]) => key)), [abilityBuildOptions]);
const selectedAbilityBuild = abilityBuildOptions.find(([key]) => key === abilityBuild)?.[1] ?? null;
const abilities = useMemo(() => allAbilities.filter((row) => {
const buildKey = `${row.source}/${row.buildId}`;
const matchesStatus = abilityStatus === "all"
|| (abilityStatus === "used" && row.activeRuns > 0)
|| (abilityStatus === "unused" && row.unusedAvailable)
|| (abilityStatus === "unused-damage" && row.unusedAvailable && /damage|execute|debuff/i.test(row.categories))
|| (abilityStatus === "damage" && row.effectiveDamage > 0)
|| (abilityStatus === "support" && (row.effectiveHealing > 0 || row.absorbs > 0));
return (partySource === "all" || row.source === partySource)
&& (role === "all" || row.role === role)
&& (abilityBuild === "all" || buildKey === abilityBuild)
&& matchesStatus
&& (!query || `${row.className} ${row.specialization} ${row.role} ${row.source} ${row.abilityName} ${row.abilityId} ${row.categories} ${row.status}`.toLowerCase().includes(query));
}), [abilityBuild, abilityStatus, allAbilities, partySource, query, role]);
const sortedBosses = useMemo(() => sortNumeric(bosses, (row) => {
const value = row[bossSort];
return typeof value === "number" ? value : null;
}, ascending), [ascending, bossSort, bosses]);
const sortedClasses = useMemo(() => sortNumeric(classes, (row) => {
const value = row[classSort];
return typeof value === "number" ? value : null;
}, ascending), [ascending, classSort, classes]);
const sortedAbilities = useMemo(() => sortNumeric(abilities, (row) => {
const value = row[abilitySort];
return typeof value === "number" ? value : null;
}, ascending), [abilities, abilitySort, ascending]);
const sortedParties = useMemo(() => sortNumeric(parties, (row) => {
const value = row[partySort];
return typeof value === "number" ? value : null;
}, ascending), [ascending, parties, partySort]);
const bossChart = (metric: keyof BalanceLabBossResult): ChartDatum[] => bosses.map((boss) => ({
key: boss.bossKey,
label: boss.bossName,
detail: boss.dungeonTitle,
value: typeof boss[metric] === "number" ? boss[metric] as number : Number.NaN,
}));
const classChart = (metric: keyof BalanceLabClassSpecResult): ChartDatum[] => classes.map((row) => ({
key: `${row.source}/${row.buildId}/${metric}`,
label: `${row.className} · ${row.specialization}`,
detail: `${row.source.toUpperCase()} · ${row.role}`,
value: typeof row[metric] === "number" ? row[metric] as number : Number.NaN,
}));
const abilityChart = (metric: keyof BalanceLabClassAbilityResult): ChartDatum[] => abilities.map((row) => ({
key: `${row.source}/${row.buildId}/${row.abilityId}/${metric}`,
label: row.abilityName,
detail: `${row.className} · ${row.specialization}`,
value: typeof row[metric] === "number" ? row[metric] as number : Number.NaN,
}));
const availableAbilityCount = abilities.filter((row) => row.availableRuns > 0).length;
const unusedAbilityCount = abilities.filter((row) => row.unusedAvailable).length;
const usedAbilityCount = abilities.filter((row) => row.activeRuns > 0).length;
const filteredAbilityDamage = abilities.reduce((total, row) => total + row.effectiveDamage, 0);
const openAbilityDrilldown = (row: BalanceLabClassSpecResult) => {
const buildKey = `${row.source}/${row.buildId}`;
setPartySource(row.source === "wow" || row.source === "coa" || row.source === "rom" ? row.source : "all");
setRole(row.role);
setSearch("");
setAbilityBuild(buildKey);
setAbilityStatus("damage");
setAbilitySort("effectiveDamage");
setAscending(false);
setTab("abilities");
window.requestAnimationFrame(() => document.querySelector(".balance-results")?.scrollIntoView({ behavior: "smooth", block: "start" }));
};
return (
<section className="balance-results" aria-label="Balance Lab report analysis">
<header className="balance-results__header">
<div>
<p className="front-kicker">Run analysis</p>
<h2>Level {report.level} balance report</h2>
<p>{new Date(report.generatedAt).toLocaleString()} · {report.summary.runs.toLocaleString()} simulations · every filtered row remains available below.</p>
</div>
<div className="balance-results__filters">
<label><span>Find anything</span><input type="search" value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder="Boss, dungeon, class, spec, party…" /></label>
<label><span>Party source</span><select value={partySource} onChange={(event) => setPartySource(event.currentTarget.value as PartySourceFilter)}><option value="all">All party sources</option><option value="wow">WoW</option><option value="coa">CoA</option><option value="rom">RoM</option></select></label>
<label><span>Boss source</span><select value={bossSource} onChange={(event) => setBossSource(event.currentTarget.value as BossSourceFilter)}><option value="all">All boss sources</option><option value="wow">WoW</option><option value="rom">RoM</option></select></label>
<label><span>Class role</span><select value={role} onChange={(event) => setRole(event.currentTarget.value)}><option value="all">All roles</option><option value="tank">Tank</option><option value="healer">Healer</option><option value="damage">Damage</option></select></label>
</div>
</header>
<nav className="balance-results__tabs" aria-label="Balance report sections">
{(["overview", "bosses", "classes", "abilities", "parties"] as const).map((value) => <button key={value} type="button" className={tab === value ? "is-active" : ""} onClick={() => setTab(value)}>{value === "classes" ? "Class / spec" : value}</button>)}
</nav>
{tab === "overview" && <div className="balance-results-overview">
<div className="balance-results-summary">
<article><span>Party configurations</span><strong>{report.summary.partyConfigurations.toLocaleString()}</strong></article>
<article><span>Bosses tested</span><strong>{report.summary.bosses.toLocaleString()}</strong></article>
<article><span>Class/spec builds</span><strong>{report.summary.classSpecs.toLocaleString()}</strong></article>
<article><span>Median boss pressure</span><strong>{formatNumber(report.summary.bossPressureMedianDtps)} <small>DTPS</small></strong></article>
</div>
<div className="balance-results-charts">
<BarChart title="Boss damage to party" description="Incoming party damage per second; fastest way to spot healer-pressure outliers." rows={bossChart("incomingDtps")} tone="damage" />
<BarChart title="Party damage to boss" description="Damage per second applied directly to each boss, excluding unrelated targets." rows={bossChart("bossDpsTaken")} tone="party" />
<BarChart title="Kill times" description="Average seconds to defeat; bosses never defeated are listed in the boss table as —." rows={bossChart("averageTimeToDefeat")} format={formatSeconds} tone="time" />
<BarChart title="Class/spec boss damage" description="Every tested build ranked by DPS applied directly to bosses." rows={classChart("bossDps")} tone="class" />
<BarChart title="Damage taken by class/spec" description="Incoming damage per second by build; useful for survivability and healer-load checks." rows={classChart("dtps")} tone="taken" />
<BarChart title="Healing done by class/spec" description="Effective healing per second, excluding overhealing. Compare healer output with boss pressure and healer DPS." rows={classChart("hps")} tone="healing" />
<BarChart title="Healing received by class/spec" description="Effective healing received per second. New runs attribute every heal to its party target, including self-heals." rows={classChart("hpsReceived")} tone="received" />
</div>
</div>}
{tab === "bosses" && <div className="balance-results-table-panel">
<header><div><h3>Every boss</h3><p>{sortedBosses.length} matching bosses · pressure, outgoing damage, party output, kill time, survival, and outcomes.</p></div><div className="balance-results-sort"><select aria-label="Sort bosses" value={bossSort} onChange={(event) => setBossSort(event.currentTarget.value as keyof BalanceLabBossResult)}><option value="incomingDtps">Boss → party DTPS</option><option value="averageIncomingDamage">Average party damage</option><option value="bossDpsTaken">Party → boss DPS</option><option value="partyDps">Total party DPS</option><option value="averageTimeToDefeat">Kill time</option><option value="wipeRate">Wipe rate</option><option value="defeatRate">Defeat rate</option><option value="averageDeaths">Average deaths</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Boss</th><th>Source</th><th>Runs</th><th>Boss → party DTPS</th><th>Avg party damage</th><th>Pressure</th><th>Party → boss DPS</th><th>Total party DPS</th><th>Kill time</th><th>Defeat</th><th>Wipe</th><th>Avg deaths</th><th>Boss health</th></tr></thead><tbody>{sortedBosses.map((boss) => <tr key={boss.bossKey}><td><strong>{boss.bossName}</strong><small>{boss.dungeonTitle}</small></td><td>{boss.category.toUpperCase()}</td><td>{boss.runs.toLocaleString()}</td><td>{formatNumber(boss.incomingDtps)}</td><td>{formatWhole(boss.averageIncomingDamage)}</td><td><span className={`balance-results-flag ${flagClass(boss.pressureFlag)}`}>{formatRatio(boss.pressureVsMedian)} · {boss.pressureFlag}</span></td><td>{formatNumber(boss.bossDpsTaken)}</td><td>{formatNumber(boss.partyDps)}</td><td>{formatSeconds(boss.averageTimeToDefeat)}</td><td>{formatPercent(boss.defeatRate)}</td><td>{formatPercent(boss.wipeRate)}</td><td>{formatNumber(boss.averageDeaths)}</td><td>{formatWhole(boss.bossMaxHealth)}</td></tr>)}</tbody></table></div>
</div>}
{tab === "classes" && <div className="balance-results-table-panel">
<header><div><h3>Every class and specialization</h3><p>{sortedClasses.length} matching builds · output, healing, mitigation, survivability, and role-relative tuning.</p></div><div className="balance-results-sort"><select aria-label="Sort class specs" value={classSort} onChange={(event) => setClassSort(event.currentTarget.value as keyof BalanceLabClassSpecResult)}><option value="bossDps">Boss DPS</option><option value="dps">Total DPS</option><option value="hps">Healing done / sec</option><option value="hpsReceived">Healing received / sec</option><option value="dtps">Damage taken / sec</option><option value="totalDamageTaken">Total damage taken</option><option value="totalDamage">Total damage dealt</option><option value="totalHealing">Total healing done</option><option value="totalHealingReceived">Total healing received</option><option value="totalAbsorbs">Total absorbs</option><option value="deathEvents">Deaths</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Class / specialization</th><th>Source</th><th>Role</th><th>Parties</th><th>Runs</th><th>Boss DPS</th><th>Total DPS</th><th>Healing done / sec</th><th>Healing received / sec</th><th>Damage taken / sec</th><th>Total damage taken</th><th>Role median</th><th>Total damage dealt</th><th>Total healing done</th><th>Total healing received</th><th>Absorbs</th><th>Deaths</th></tr></thead><tbody>{sortedClasses.map((row) => {
const buildKey = `${row.source}/${row.buildId}`;
const hasAbilityData = abilityBuildKeys.has(buildKey);
return <tr key={buildKey}><td><strong>{row.className}</strong><small>{row.specialization}</small><button className="balance-results-drilldown" type="button" disabled={!hasAbilityData} onClick={() => openAbilityDrilldown(row)} aria-label={`View ${row.className} ${row.specialization} ability damage`}>{hasAbilityData ? "View ability damage" : "No ability data"}</button></td><td>{row.source.toUpperCase()}</td><td>{row.role}</td><td>{row.partyConfigurations.toLocaleString()}</td><td>{row.runs.toLocaleString()}</td><td>{formatNumber(row.bossDps)}</td><td>{formatNumber(row.dps)}</td><td>{formatNumber(row.hps)}</td><td>{formatNumber(row.hpsReceived)}</td><td>{formatNumber(row.dtps)}</td><td>{formatWhole(row.totalDamageTaken)}</td><td><span className={`balance-results-flag ${flagClass(row.damageFlag)}`}>{formatRatio(row.damageVsSourceRoleMedian)} · {row.damageFlag}</span></td><td>{formatWhole(row.totalDamage)}</td><td>{formatWhole(row.totalHealing)}</td><td>{formatWhole(row.totalHealingReceived)}</td><td>{formatWhole(row.totalAbsorbs)}</td><td>{row.deathEvents.toLocaleString()}</td></tr>;
})}</tbody></table></div>
</div>}
{tab === "abilities" && <div className="balance-results-abilities">
{allAbilities.length === 0 ? <p className="balance-results-legacy-note">This saved run predates ability-level reporting. Run the same configuration again to populate ability use, unused abilities, damage share, healing, and absorbs.</p> : <>
{selectedAbilityBuild && <div className="balance-results-drilldown-banner"><div><span>Spec damage drill-down</span><strong>{selectedAbilityBuild.className} · {selectedAbilityBuild.specialization}</strong><small>{selectedAbilityBuild.source.toUpperCase()} · {selectedAbilityBuild.role} · ability damage ranked by effective damage</small></div><button type="button" onClick={() => { setAbilityBuild("all"); setAbilityStatus("all"); setPartySource("all"); setRole("all"); }}>Show all specs</button></div>}
<div className="balance-results-summary balance-results-summary--abilities">
<article><span>Matching abilities</span><strong>{abilities.length.toLocaleString()}</strong></article>
<article><span>Used in at least one run</span><strong>{usedAbilityCount.toLocaleString()}</strong></article>
<article><span>Available but unused</span><strong>{unusedAbilityCount.toLocaleString()}</strong></article>
<article><span>Filtered effective damage</span><strong>{formatWhole(filteredAbilityDamage)}</strong></article>
</div>
<BarChart title={selectedAbilityBuild ? `${selectedAbilityBuild.className} · ${selectedAbilityBuild.specialization} ability damage` : "Ability damage contribution"} description="Effective damage by ability for the selected source, role, class/spec, status, and text filters. Casts, hits, DPS, damage share, and damage per hit are listed below." rows={abilityChart("effectiveDamage")} tone="class" />
<div className="balance-results-table-panel">
<header><div><h3>Every class/spec ability</h3><p>{sortedAbilities.length} matching abilities · {availableAbilityCount} are available to the tested builds. Zero-use rows are deliberately retained.</p></div><div className="balance-results-ability-controls">
<select aria-label="Filter ability class spec" value={abilityBuild} onChange={(event) => setAbilityBuild(event.currentTarget.value)}><option value="all">All class/specs</option>{abilityBuildOptions.map(([key, row]) => <option key={key} value={key}>{row.source.toUpperCase()} · {row.className} — {row.specialization}</option>)}</select>
<select aria-label="Filter ability usage" value={abilityStatus} onChange={(event) => setAbilityStatus(event.currentTarget.value)}><option value="all">All ability states</option><option value="used">Used in any run</option><option value="unused">Available but unused</option><option value="unused-damage">Unused damage abilities</option><option value="damage">Dealt damage</option><option value="support">Healed or absorbed</option></select>
<select aria-label="Sort abilities" value={abilitySort} onChange={(event) => setAbilitySort(event.currentTarget.value as keyof BalanceLabClassAbilityResult)}><option value="effectiveDamage">Effective damage</option><option value="damageShare">Damage share</option><option value="dps">DPS</option><option value="averageDamagePerHit">Damage per hit</option><option value="activeRuns">Active runs</option><option value="usageRate">Usage rate</option><option value="casts">Casts</option><option value="castsPerMinute">Casts per minute</option><option value="hits">Hits</option><option value="effectiveHealing">Effective healing</option><option value="overheal">Overheal</option><option value="absorbs">Absorbs</option></select>
<button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button>
</div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Ability</th><th>Class / specialization</th><th>Source</th><th>Role</th><th>AI category</th><th>Usage state</th><th>Active runs</th><th>Usage rate</th><th>Casts</th><th>Casts / min</th><th>Hits</th><th>Effective damage</th><th>Damage share</th><th>DPS</th><th>Damage / hit</th><th>Raw damage</th><th>Effective healing</th><th>Overheal</th><th>Absorbs</th><th>Cooldown</th></tr></thead><tbody>{sortedAbilities.map((row) => <tr key={`${row.source}/${row.buildId}/${row.abilityId}`}>
<td><span className="balance-results-ability-name">{row.icon && <img src={row.icon} alt="" />}<span><strong>{row.abilityName}</strong><small>{row.abilityId}</small></span></span></td>
<td><strong>{row.className}</strong><small>{row.specialization}</small></td><td>{row.source.toUpperCase()}</td><td>{row.role}</td><td>{row.categories || "—"}</td><td><span className={`balance-results-flag ${row.unusedAvailable ? "is-high" : row.status === "support" ? "is-low" : "is-baseline"}`}>{row.unusedAvailable ? "available · unused" : row.status}</span></td><td>{row.activeRuns}/{row.runs}</td><td>{formatPercent(row.usageRate)}</td><td>{row.casts.toLocaleString()}</td><td>{formatNumber(row.castsPerMinute)}</td><td>{row.hits.toLocaleString()}</td><td>{formatWhole(row.effectiveDamage)}</td><td>{formatPercent(row.damageShare)}</td><td>{formatNumber(row.dps)}</td><td>{formatNumber(row.averageDamagePerHit)}</td><td>{formatWhole(row.rawDamage)}</td><td>{formatWhole(row.effectiveHealing)}</td><td>{formatWhole(row.overheal)}</td><td>{formatWhole(row.absorbs)}</td><td>{row.cooldownSeconds === null ? "—" : formatSeconds(row.cooldownSeconds)}</td>
</tr>)}</tbody></table></div>
</div>
</>}
</div>}
{tab === "parties" && <div className="balance-results-table-panel">
<header><div><h3>Every party configuration</h3><p>{sortedParties.length} matching parties · compare composition output, healer pressure, outcomes, and deaths.</p></div><div className="balance-results-sort"><select aria-label="Sort parties" value={partySort} onChange={(event) => setPartySort(event.currentTarget.value as keyof BalanceLabPartyResult)}><option value="partyDps">Party DPS</option><option value="incomingDtps">Incoming DTPS</option><option value="defeatRate">Defeat rate</option><option value="wipeRate">Wipe rate</option><option value="averageDeaths">Average deaths</option><option value="runs">Runs</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Party</th><th>Source</th><th>Members</th><th>Runs</th><th>Party DPS</th><th>Incoming DTPS</th><th>Defeat</th><th>Wipe</th><th>Avg deaths</th></tr></thead><tbody>{sortedParties.map((row) => <tr key={row.partyConfigurationId}><td><strong>{row.partyConfigurationId}</strong></td><td>{row.source.toUpperCase()}</td><td className="balance-results-members">{row.members}</td><td>{row.runs.toLocaleString()}</td><td>{formatNumber(row.partyDps)}</td><td>{formatNumber(row.incomingDtps)}</td><td>{formatPercent(row.defeatRate)}</td><td>{formatPercent(row.wipeRate)}</td><td>{formatNumber(row.averageDeaths)}</td></tr>)}</tbody></table></div>
</div>}
</section>
);
}