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 `