Release Healer Man 0.1.46
This commit is contained in:
1 parent
f82daf1c13
commit
9ff574ac0b
73 files changed
+33486
-174
No files matched your search
@@ -0,0 +1,263 @@
|
||||
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";
|
||||
}) {
|
||||
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 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);
|
||||
|
||||
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" />
|
||||
</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">HPS</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</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>HPS</th><th>Damage taken / sec</th><th>Total damage taken</th><th>Role median</th><th>Total damage dealt</th><th>Total healing</th><th>Absorbs</th><th>Deaths</th></tr></thead><tbody>{sortedClasses.map((row) => <tr key={`${row.source}/${row.buildId}`}><td><strong>{row.className}</strong><small>{row.specialization}</small></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.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.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> : <>
|
||||
<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="Ability damage contribution" description="Effective damage by ability for the selected source, role, class/spec, status, and text filters." 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user