286 lines
26 KiB
TypeScript
286 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|