diff --git a/api/analysis.php b/api/analysis.php index 4d95bfe..ea01940 100644 --- a/api/analysis.php +++ b/api/analysis.php @@ -182,8 +182,12 @@ for ($page = 0; $page < 10; $page++) { if ($nextPage === null || $nextPage >= $endTime) break; } -// ── 3. AoE detection ─────────────────────────────────────────────────────── -$buckets = []; +// ── 3. AoE detection — proximity clustering ──────────────────────────────── +// 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) { if (!empty($ev['tick'])) continue; if (($ev['type'] ?? '') !== 'damage') continue; @@ -192,33 +196,60 @@ foreach ($allEvents as $ev) { $tgtId = (int)($ev['targetID'] ?? 0); if (!$abId || !$tgtId || $abId <= 7) continue; - $ts = (float)($ev['timestamp'] ?? 0); - $bucket = (int)floor($ts / 990) * 990; - $key = $bucket . '_' . $abId; + $byAbility[$abId][] = [ + 'ts' => (float)($ev['timestamp'] ?? 0), + '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])) { - $buckets[$key] = [ - 'timestamp' => (int)$ts, - 'abilityId' => $abId, - 'abilityName' => $abilityNames[$abId] ?? $ev['ability']['name'] ?? ('Ability #' . $abId), - 'targets' => [], - ]; - } +$clusters = []; +foreach ($byAbility as $abId => $events) { + // Events from FFLogs are already time-sorted, but sort per ability to be safe + usort($events, fn($a, $b) => $a['ts'] <=> $b['ts']); - if (!isset($buckets[$key]['targets'][$tgtId])) { - $buckets[$key]['targets'][$tgtId] = [ - 'id' => $tgtId, - 'amount' => 0, - 'hp' => (int)($ev['targetResources']['hitPoints'] ?? 0), - 'maxHp' => (int)($ev['targetResources']['maxHitPoints'] ?? 0), - 'buffs' => $ev['buffs'] ?? '', - ]; + $clusterStart = null; + $current = null; + + foreach ($events as $ev) { + if ($current === null || ($ev['ts'] - $clusterStart) > CLUSTER_WINDOW_MS) { + if ($current !== null) $clusters[] = $current; + $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 = []; -foreach ($buckets as $group) { +foreach ($clusters as $group) { if (count($group['targets']) < 3) continue; $targets = []; @@ -230,6 +261,8 @@ foreach ($buckets as $group) { 'type' => $p['type'] ?? '', 'role' => $p['role'] ?? 'dps', 'amount' => $tgt['amount'], + 'absorbed' => $tgt['absorbed'], + 'overkill' => $tgt['overkill'], 'hp' => $tgt['hp'], 'maxHp' => $tgt['maxHp'], 'mitigations' => resolveMitigations($tgt['buffs'], $mitigIdMap), diff --git a/api/fight.php b/api/fight.php index 5fb5590..38fe168 100644 --- a/api/fight.php +++ b/api/fight.php @@ -48,6 +48,11 @@ query GetReportData($reportCode: String!) { bossPercentage fightPercentage averageItemLevel + lastPhase + phaseTransitions { + id + startTime + } } } } diff --git a/css/analysis.css b/css/analysis.css index fbeb10c..ca83375 100644 --- a/css/analysis.css +++ b/css/analysis.css @@ -123,6 +123,22 @@ 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 { font-family: var(--font-d); font-size: 10px; @@ -149,8 +165,9 @@ gap: 5px; } -.aoe-target-name { color: var(--t1); } -.aoe-target-dmg { color: var(--t2); margin-left: 2px; } +.aoe-target-name { color: var(--t1); } +.aoe-target-dmg { color: var(--t2); margin-left: 2px; } +.aoe-target-absorbed { color: var(--blue); } /* ── HP Bar ──────────────────────────────────────────────────────────────── */ .aoe-hp-bar { diff --git a/js/analysis.js b/js/analysis.js index 2fe7fab..a809892 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -49,10 +49,11 @@ return String(n); } - let hiddenPlayers = new Set(); - let lastEvents = []; + let hiddenPlayers = new Set(); + let lastEvents = []; let lastFightStart = 0; - let playerFilter = ''; + let playerFilter = ''; + let phaseFilter = { startTime: 0, endTime: Infinity }; function renderPlayers(players) { const grid = document.getElementById('player-grid'); @@ -91,6 +92,31 @@ 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 => + `` + ).join(''); + // Pre-select "Ganzer Fight" + phaseSelect.value = 0; + phaseFilter = { startTime: phases[0].startTime, endTime: phases[0].endTime }; + phaseSelect.style.display = ''; + } + function renderTimeline(events, fightStart) { lastEvents = events; lastFightStart = fightStart; @@ -103,6 +129,8 @@ } const rows = events.map(ev => { + if (ev.timestamp < phaseFilter.startTime || ev.timestamp >= phaseFilter.endTime) return ''; + const visibleTargets = ev.targets.filter(t => !hiddenPlayers.has(t.id) && (!playerFilter || t.name.toLowerCase().includes(playerFilter)) @@ -132,11 +160,14 @@ return `
- ${abbr(t.type)} +
+ ${abbr(t.type)} + ${dead && t.overkill > 0 ? `-${fmtDmg(t.overkill)}` : ''} +
${t.name} - ${fmtDmg(t.amount)} + ${fmtDmg(t.amount)}${t.absorbed > 0 ? ` +${fmtDmg(t.absorbed)}` : ''}
${hpBar}
@@ -197,6 +228,7 @@ if (json.error) { setEmpty('Fehler: ' + json.error); return; } lastFightId = fightId; + setupPhases(window.App?.phases ?? []); renderPlayers(json.players ?? []); renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart); diff --git a/js/app.js b/js/app.js index e3446e9..8400c06 100644 --- a/js/app.js +++ b/js/app.js @@ -1,5 +1,5 @@ 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 output = document.getElementById('output'); @@ -41,6 +41,7 @@ document.addEventListener('DOMContentLoaded', () => { window.App.fightId = id; window.App.fightStart = fight.startTime; window.App.fightEnd = fight.endTime; + window.App.phases = buildPhases(fight); displayFight(fight); explorerCard.style.display = 'block'; @@ -48,6 +49,18 @@ document.addEventListener('DOMContentLoaded', () => { 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) { exAbilitySelect.innerHTML = ''; try { @@ -133,6 +146,7 @@ document.addEventListener('DOMContentLoaded', () => { window.App.fightId = null; window.App.fightStart = 0; window.App.fightEnd = 0; + window.App.phases = []; window.analysisTab?.reset?.(); let response, json; diff --git a/templates/tab-analysis.php b/templates/tab-analysis.php index 0c70464..3d0636a 100644 --- a/templates/tab-analysis.php +++ b/templates/tab-analysis.php @@ -18,6 +18,7 @@
AoE Timeline
+