Add proximity clustering, absorbed/overkill display, phase filter

- Replace fixed-window AoE bucketing with proximity clustering (1000ms
  from first event per ability) to prevent boundary splits
- Show absorbed shield damage in blue next to player damage (e.g. 40k +20k)
- Show overkill damage in red below job abbreviation on death
- Phase dropdown in AoE Timeline for multi-phase fights (phaseTransitions)
- Default phase selection: Ganzer Fight

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xziino 2026-05-20 15:30:59 +02:00
parent 399e5fad93
commit 2d121b8ee5
6 changed files with 133 additions and 31 deletions

View File

@ -182,8 +182,12 @@ for ($page = 0; $page < 10; $page++) {
if ($nextPage === null || $nextPage >= $endTime) break; if ($nextPage === null || $nextPage >= $endTime) break;
} }
// ── 3. AoE detection ─────────────────────────────────────────────────────── // ── 3. AoE detection — proximity clustering ────────────────────────────────
$buckets = []; // Group events by abilityId, then cluster by time proximity (≤ 1000ms from
// the first event in the cluster) to avoid fixed-window boundary splits.
const CLUSTER_WINDOW_MS = 1000;
$byAbility = []; // abilityId → [{ts, tgtId, amount, hp, maxHp, buffs, name}]
foreach ($allEvents as $ev) { foreach ($allEvents as $ev) {
if (!empty($ev['tick'])) continue; if (!empty($ev['tick'])) continue;
if (($ev['type'] ?? '') !== 'damage') continue; if (($ev['type'] ?? '') !== 'damage') continue;
@ -192,33 +196,60 @@ foreach ($allEvents as $ev) {
$tgtId = (int)($ev['targetID'] ?? 0); $tgtId = (int)($ev['targetID'] ?? 0);
if (!$abId || !$tgtId || $abId <= 7) continue; if (!$abId || !$tgtId || $abId <= 7) continue;
$ts = (float)($ev['timestamp'] ?? 0); $byAbility[$abId][] = [
$bucket = (int)floor($ts / 990) * 990; 'ts' => (float)($ev['timestamp'] ?? 0),
$key = $bucket . '_' . $abId; 'tgtId' => $tgtId,
'amount' => (int)($ev['amount'] ?? 0),
'absorbed' => (int)($ev['absorbed'] ?? 0),
'overkill' => (int)($ev['overkill'] ?? 0),
'hp' => (int)($ev['targetResources']['hitPoints'] ?? 0),
'maxHp' => (int)($ev['targetResources']['maxHitPoints'] ?? 0),
'buffs' => $ev['buffs'] ?? '',
'name' => $abilityNames[$abId] ?? $ev['ability']['name'] ?? ('Ability #' . $abId),
];
}
if (!isset($buckets[$key])) { $clusters = [];
$buckets[$key] = [ foreach ($byAbility as $abId => $events) {
'timestamp' => (int)$ts, // Events from FFLogs are already time-sorted, but sort per ability to be safe
'abilityId' => $abId, usort($events, fn($a, $b) => $a['ts'] <=> $b['ts']);
'abilityName' => $abilityNames[$abId] ?? $ev['ability']['name'] ?? ('Ability #' . $abId),
'targets' => [],
];
}
if (!isset($buckets[$key]['targets'][$tgtId])) { $clusterStart = null;
$buckets[$key]['targets'][$tgtId] = [ $current = null;
'id' => $tgtId,
'amount' => 0, foreach ($events as $ev) {
'hp' => (int)($ev['targetResources']['hitPoints'] ?? 0), if ($current === null || ($ev['ts'] - $clusterStart) > CLUSTER_WINDOW_MS) {
'maxHp' => (int)($ev['targetResources']['maxHitPoints'] ?? 0), if ($current !== null) $clusters[] = $current;
'buffs' => $ev['buffs'] ?? '', $clusterStart = $ev['ts'];
]; $current = [
'timestamp' => (int)$ev['ts'],
'abilityId' => $abId,
'abilityName' => $ev['name'],
'targets' => [],
];
}
$tgtId = $ev['tgtId'];
if (!isset($current['targets'][$tgtId])) {
$current['targets'][$tgtId] = [
'id' => $tgtId,
'amount' => 0,
'absorbed' => 0,
'overkill' => 0,
'hp' => $ev['hp'],
'maxHp' => $ev['maxHp'],
'buffs' => $ev['buffs'],
];
}
$current['targets'][$tgtId]['amount'] += $ev['amount'];
$current['targets'][$tgtId]['absorbed'] += $ev['absorbed'];
$current['targets'][$tgtId]['overkill'] += $ev['overkill'];
} }
$buckets[$key]['targets'][$tgtId]['amount'] += (int)($ev['amount'] ?? 0); if ($current !== null) $clusters[] = $current;
} }
$aoeEvents = []; $aoeEvents = [];
foreach ($buckets as $group) { foreach ($clusters as $group) {
if (count($group['targets']) < 3) continue; if (count($group['targets']) < 3) continue;
$targets = []; $targets = [];
@ -230,6 +261,8 @@ foreach ($buckets as $group) {
'type' => $p['type'] ?? '', 'type' => $p['type'] ?? '',
'role' => $p['role'] ?? 'dps', 'role' => $p['role'] ?? 'dps',
'amount' => $tgt['amount'], 'amount' => $tgt['amount'],
'absorbed' => $tgt['absorbed'],
'overkill' => $tgt['overkill'],
'hp' => $tgt['hp'], 'hp' => $tgt['hp'],
'maxHp' => $tgt['maxHp'], 'maxHp' => $tgt['maxHp'],
'mitigations' => resolveMitigations($tgt['buffs'], $mitigIdMap), 'mitigations' => resolveMitigations($tgt['buffs'], $mitigIdMap),

View File

@ -48,6 +48,11 @@ query GetReportData($reportCode: String!) {
bossPercentage bossPercentage
fightPercentage fightPercentage
averageItemLevel averageItemLevel
lastPhase
phaseTransitions {
id
startTime
}
} }
} }
} }

View File

@ -123,6 +123,22 @@
background: rgba(224, 92, 92, 0.08); background: rgba(224, 92, 92, 0.08);
} }
.aoe-target-left {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.aoe-target-overkill {
font-size: 9px;
font-family: var(--font-d);
color: var(--red);
font-weight: 600;
margin-top: -3px;
}
.aoe-target-job { .aoe-target-job {
font-family: var(--font-d); font-family: var(--font-d);
font-size: 10px; font-size: 10px;
@ -149,8 +165,9 @@
gap: 5px; gap: 5px;
} }
.aoe-target-name { color: var(--t1); } .aoe-target-name { color: var(--t1); }
.aoe-target-dmg { color: var(--t2); margin-left: 2px; } .aoe-target-dmg { color: var(--t2); margin-left: 2px; }
.aoe-target-absorbed { color: var(--blue); }
/* ── HP Bar ──────────────────────────────────────────────────────────────── */ /* ── HP Bar ──────────────────────────────────────────────────────────────── */
.aoe-hp-bar { .aoe-hp-bar {

View File

@ -49,10 +49,11 @@
return String(n); return String(n);
} }
let hiddenPlayers = new Set(); let hiddenPlayers = new Set();
let lastEvents = []; let lastEvents = [];
let lastFightStart = 0; let lastFightStart = 0;
let playerFilter = ''; let playerFilter = '';
let phaseFilter = { startTime: 0, endTime: Infinity };
function renderPlayers(players) { function renderPlayers(players) {
const grid = document.getElementById('player-grid'); const grid = document.getElementById('player-grid');
@ -91,6 +92,31 @@
renderTimeline(lastEvents, lastFightStart); renderTimeline(lastEvents, lastFightStart);
}); });
const phaseSelect = document.getElementById('phase-select');
phaseSelect.addEventListener('change', () => {
const phases = window.App?.phases ?? [];
const phase = phases.find(p => p.id === parseInt(phaseSelect.value, 10));
if (phase) {
phaseFilter = { startTime: phase.startTime, endTime: phase.endTime };
renderTimeline(lastEvents, lastFightStart);
}
});
function setupPhases(phases) {
if (!phases.length) {
phaseSelect.style.display = 'none';
phaseFilter = { startTime: 0, endTime: Infinity };
return;
}
phaseSelect.innerHTML = phases.map(p =>
`<option value="${p.id}">${p.name}</option>`
).join('');
// Pre-select "Ganzer Fight"
phaseSelect.value = 0;
phaseFilter = { startTime: phases[0].startTime, endTime: phases[0].endTime };
phaseSelect.style.display = '';
}
function renderTimeline(events, fightStart) { function renderTimeline(events, fightStart) {
lastEvents = events; lastEvents = events;
lastFightStart = fightStart; lastFightStart = fightStart;
@ -103,6 +129,8 @@
} }
const rows = events.map(ev => { const rows = events.map(ev => {
if (ev.timestamp < phaseFilter.startTime || ev.timestamp >= phaseFilter.endTime) return '';
const visibleTargets = ev.targets.filter(t => const visibleTargets = ev.targets.filter(t =>
!hiddenPlayers.has(t.id) && !hiddenPlayers.has(t.id) &&
(!playerFilter || t.name.toLowerCase().includes(playerFilter)) (!playerFilter || t.name.toLowerCase().includes(playerFilter))
@ -132,11 +160,14 @@
return ` return `
<div class="aoe-target-wrap"> <div class="aoe-target-wrap">
<div class="aoe-target${dead ? ' aoe-target--dead' : ''}"> <div class="aoe-target${dead ? ' aoe-target--dead' : ''}">
<span class="aoe-target-job role-${t.role}">${abbr(t.type)}</span> <div class="aoe-target-left">
<span class="aoe-target-job role-${t.role}">${abbr(t.type)}</span>
${dead && t.overkill > 0 ? `<span class="aoe-target-overkill">-${fmtDmg(t.overkill)}</span>` : ''}
</div>
<div class="aoe-target-body"> <div class="aoe-target-body">
<div class="aoe-target-row"> <div class="aoe-target-row">
<span class="aoe-target-name">${t.name}</span> <span class="aoe-target-name">${t.name}</span>
<span class="aoe-target-dmg">${fmtDmg(t.amount)}</span> <span class="aoe-target-dmg">${fmtDmg(t.amount)}${t.absorbed > 0 ? ` <span class="aoe-target-absorbed">+${fmtDmg(t.absorbed)}</span>` : ''}</span>
</div> </div>
${hpBar} ${hpBar}
</div> </div>
@ -197,6 +228,7 @@
if (json.error) { setEmpty('Fehler: ' + json.error); return; } if (json.error) { setEmpty('Fehler: ' + json.error); return; }
lastFightId = fightId; lastFightId = fightId;
setupPhases(window.App?.phases ?? []);
renderPlayers(json.players ?? []); renderPlayers(json.players ?? []);
renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart); renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart);

View File

@ -1,5 +1,5 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
window.App = { reportCode: null, fightId: null, fightStart: 0, fightEnd: 0 }; window.App = { reportCode: null, fightId: null, fightStart: 0, fightEnd: 0, phases: [] };
const form = document.getElementById('report-form'); const form = document.getElementById('report-form');
const output = document.getElementById('output'); const output = document.getElementById('output');
@ -41,6 +41,7 @@ document.addEventListener('DOMContentLoaded', () => {
window.App.fightId = id; window.App.fightId = id;
window.App.fightStart = fight.startTime; window.App.fightStart = fight.startTime;
window.App.fightEnd = fight.endTime; window.App.fightEnd = fight.endTime;
window.App.phases = buildPhases(fight);
displayFight(fight); displayFight(fight);
explorerCard.style.display = 'block'; explorerCard.style.display = 'block';
@ -48,6 +49,18 @@ document.addEventListener('DOMContentLoaded', () => {
loadAbilities(id, fight.startTime, fight.endTime); loadAbilities(id, fight.startTime, fight.endTime);
}); });
function buildPhases(fight) {
const transitions = fight.phaseTransitions ?? [];
if (transitions.length === 0) return [];
const phases = transitions.map((t, i) => ({
id: t.id,
name: `Phase ${t.id}`,
startTime: t.startTime,
endTime: transitions[i + 1]?.startTime ?? fight.endTime,
}));
return [{ id: 0, name: 'Ganzer Fight', startTime: fight.startTime, endTime: fight.endTime }, ...phases];
}
async function loadAbilities(fightId, startTime, endTime) { async function loadAbilities(fightId, startTime, endTime) {
exAbilitySelect.innerHTML = '<option value="">Lädt…</option>'; exAbilitySelect.innerHTML = '<option value="">Lädt…</option>';
try { try {
@ -133,6 +146,7 @@ document.addEventListener('DOMContentLoaded', () => {
window.App.fightId = null; window.App.fightId = null;
window.App.fightStart = 0; window.App.fightStart = 0;
window.App.fightEnd = 0; window.App.fightEnd = 0;
window.App.phases = [];
window.analysisTab?.reset?.(); window.analysisTab?.reset?.();
let response, json; let response, json;

View File

@ -18,6 +18,7 @@
<div class="card"> <div class="card">
<div class="card-title-row"> <div class="card-title-row">
<div class="card-title">AoE Timeline</div> <div class="card-title">AoE Timeline</div>
<select id="phase-select" class="filter-input" style="display:none"></select>
<input type="text" id="player-filter" class="filter-input" placeholder="Spieler filtern…"> <input type="text" id="player-filter" class="filter-input" placeholder="Spieler filtern…">
</div> </div>
<div id="aoe-timeline"></div> <div id="aoe-timeline"></div>