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:
parent
399e5fad93
commit
2d121b8ee5
@ -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),
|
||||
|
||||
@ -48,6 +48,11 @@ query GetReportData($reportCode: String!) {
|
||||
bossPercentage
|
||||
fightPercentage
|
||||
averageItemLevel
|
||||
lastPhase
|
||||
phaseTransitions {
|
||||
id
|
||||
startTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 =>
|
||||
`<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) {
|
||||
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 `
|
||||
<div class="aoe-target-wrap">
|
||||
<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-row">
|
||||
<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>
|
||||
${hpBar}
|
||||
</div>
|
||||
@ -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);
|
||||
|
||||
|
||||
16
js/app.js
16
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 = '<option value="">Lädt…</option>';
|
||||
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;
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
<div class="card">
|
||||
<div class="card-title-row">
|
||||
<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…">
|
||||
</div>
|
||||
<div id="aoe-timeline"></div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user