When an external reference report is loaded and its roster differs from the current report, a second player section appears below the main grid showing all REF report players. REF tanks are hidden by default. Clicking any REF player card toggles their visibility in the REF timeline rows. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
653 lines
30 KiB
JavaScript
653 lines
30 KiB
JavaScript
(function () {
|
||
const MITIG_ICONS = {
|
||
// DR buffs
|
||
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
|
||
'Dark Missionary': 'assets/icons/mitigation/dark-missionary.png',
|
||
'Heart of Light': 'assets/icons/mitigation/heart-of-light.png',
|
||
'Temperance': 'assets/icons/mitigation/temperance.png',
|
||
'Sacred Soil': 'assets/icons/mitigation/sacred-soil.png',
|
||
'Expedient': 'assets/icons/mitigation/expedient.png',
|
||
'Fey Illumination': 'assets/icons/mitigation/fey-illumination.png',
|
||
'Collective Unconscious': 'assets/icons/mitigation/collective-unconscious.png',
|
||
'Holos': 'assets/icons/mitigation/holos.png',
|
||
'Kerachole': 'assets/icons/mitigation/kerachole.png',
|
||
'Troubadour': 'assets/icons/mitigation/troubadour.png',
|
||
'Tactician': 'assets/icons/mitigation/tactician.png',
|
||
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
|
||
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.png',
|
||
// Debuffs
|
||
'Reprisal': 'assets/icons/mitigation/reprisal.png',
|
||
'Feint': 'assets/icons/mitigation/feint.png',
|
||
'Addle': 'assets/icons/mitigation/addle.png',
|
||
// Shields
|
||
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
|
||
'Guardian': 'assets/icons/mitigation/guardian.png',
|
||
'Shake It Off': 'assets/icons/mitigation/shake-it-off.png',
|
||
'Bloodwhetting': 'assets/icons/mitigation/bloodwhetting.png',
|
||
'Divine Benison': 'assets/icons/mitigation/divine-benison.png',
|
||
'Divine Caress': 'assets/icons/mitigation/divine-caress.png',
|
||
'Intersection': 'assets/icons/mitigation/intersection.png',
|
||
'Neutral Sect': 'assets/icons/mitigation/neutral-sect.png',
|
||
'the Spire': 'assets/icons/mitigation/the-spire.png',
|
||
'Panhaima': 'assets/icons/mitigation/panhaima.png',
|
||
'Holosakos': 'assets/icons/mitigation/holos.png',
|
||
'Eukrasian Prognosis': 'assets/icons/mitigation/eukrasian-prognosis.png',
|
||
'Eukrasian Prognosis II': 'assets/icons/mitigation/eukrasian-prognosis-ii.png',
|
||
'Eukrasian Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
||
'Differential Diagnosis': 'assets/icons/mitigation/eukrasian-diagnosis.png',
|
||
'Haima': 'assets/icons/mitigation/haima.png',
|
||
'Galvanize': 'assets/icons/mitigation/galvanize.png',
|
||
'Seraphic Veil': 'assets/icons/mitigation/seraphic-veil.png',
|
||
'Radiant Aegis': 'assets/icons/mitigation/radiant-aegis.png',
|
||
'Tempera Coat': 'assets/icons/mitigation/tempera-coat.png',
|
||
'Tempera Grassa': 'assets/icons/mitigation/tempera-grassa.png',
|
||
'Improvised Finish': 'assets/icons/mitigation/improvised-finish.png',
|
||
};
|
||
|
||
const JOB_ABBR = {
|
||
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
|
||
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
|
||
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
|
||
'Reaper': 'RPR', 'Viper': 'VPR',
|
||
'Bard': 'BRD', 'Machinist': 'MCH', 'Dancer': 'DNC',
|
||
'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
|
||
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
|
||
};
|
||
|
||
function abbr(type) {
|
||
return JOB_ABBR[type] ?? type.slice(0, 3).toUpperCase();
|
||
}
|
||
|
||
function fmtTime(ms, start) {
|
||
const rel = ms - start;
|
||
const min = Math.floor(rel / 60000);
|
||
const sec = String(Math.floor((rel % 60000) / 1000)).padStart(2, '0');
|
||
return `${min}:${sec}`;
|
||
}
|
||
|
||
function fmtDmg(n) {
|
||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
|
||
if (n >= 1_000) return Math.round(n / 1_000) + 'k';
|
||
return String(n);
|
||
}
|
||
|
||
function fmtDur(ms) {
|
||
const min = Math.floor(ms / 60000);
|
||
const sec = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0');
|
||
return `${min}:${sec}`;
|
||
}
|
||
|
||
let hiddenPlayers = new Set();
|
||
let hiddenPlayerNames = new Set();
|
||
let lastEvents = [];
|
||
let lastFightStart = 0;
|
||
let playerFilter = '';
|
||
let phaseFilter = { startTime: 0, endTime: Infinity };
|
||
let refEvents = [];
|
||
let refFightStart = 0;
|
||
let refPlayers = [];
|
||
let currentPlayers = [];
|
||
let extFights = [];
|
||
let extReportCode = '';
|
||
|
||
// ── Player grid ──────────────────────────────────────────────────────────
|
||
|
||
function renderPlayers(players) {
|
||
currentPlayers = players;
|
||
const grid = document.getElementById('player-grid');
|
||
const order = { healer: 0, dps: 1, tank: 2 };
|
||
players.sort((a, b) => {
|
||
const roleCmp = (order[a.role] ?? 2) - (order[b.role] ?? 2);
|
||
return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name);
|
||
});
|
||
|
||
hiddenPlayers = new Set(players.filter(p => p.role === 'tank').map(p => p.id));
|
||
hiddenPlayerNames = new Set(players.filter(p => p.role === 'tank').map(p => p.name));
|
||
|
||
grid.innerHTML = players.map(p => `
|
||
<div class="player-card ${hiddenPlayers.has(p.id) ? 'player-hidden' : ''}" data-player-id="${p.id}" data-player-name="${p.name}">
|
||
<div class="player-job-icon role-${p.role}">${abbr(p.type)}</div>
|
||
<div>
|
||
<div class="player-name">${p.name}</div>
|
||
<div class="player-type">${p.type}</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderRefPlayers() {
|
||
const section = document.getElementById('ref-player-section');
|
||
const grid = document.getElementById('ref-player-grid');
|
||
|
||
if (!refPlayers.length) { section.style.display = 'none'; return; }
|
||
|
||
const currentNames = new Set(currentPlayers.map(p => p.name));
|
||
if (!refPlayers.some(p => !currentNames.has(p.name))) {
|
||
section.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const order = { healer: 0, dps: 1, tank: 2 };
|
||
const sorted = [...refPlayers].sort((a, b) => {
|
||
const roleCmp = (order[a.role] ?? 2) - (order[b.role] ?? 2);
|
||
return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name);
|
||
});
|
||
|
||
sorted.filter(p => p.role === 'tank').forEach(p => hiddenPlayerNames.add(p.name));
|
||
|
||
grid.innerHTML = sorted.map(p => `
|
||
<div class="player-card player-card--ref ${hiddenPlayerNames.has(p.name) ? 'player-hidden' : ''}" data-player-name="${p.name}">
|
||
<div class="player-job-icon role-${p.role}">${abbr(p.type)}</div>
|
||
<div>
|
||
<div class="player-name">${p.name}</div>
|
||
<div class="player-type">${p.type}</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
section.style.display = '';
|
||
}
|
||
|
||
document.getElementById('ref-player-grid').addEventListener('click', e => {
|
||
const card = e.target.closest('.player-card');
|
||
if (!card) return;
|
||
const name = card.dataset.playerName;
|
||
if (hiddenPlayerNames.has(name)) {
|
||
hiddenPlayerNames.delete(name);
|
||
card.classList.remove('player-hidden');
|
||
} else {
|
||
hiddenPlayerNames.add(name);
|
||
card.classList.add('player-hidden');
|
||
}
|
||
renderTimeline(lastEvents, lastFightStart);
|
||
});
|
||
|
||
document.getElementById('player-grid').addEventListener('click', e => {
|
||
const card = e.target.closest('.player-card');
|
||
if (!card) return;
|
||
const id = parseInt(card.dataset.playerId, 10);
|
||
const name = card.dataset.playerName;
|
||
if (hiddenPlayers.has(id)) {
|
||
hiddenPlayers.delete(id);
|
||
hiddenPlayerNames.delete(name);
|
||
card.classList.remove('player-hidden');
|
||
} else {
|
||
hiddenPlayers.add(id);
|
||
hiddenPlayerNames.add(name);
|
||
card.classList.add('player-hidden');
|
||
}
|
||
renderTimeline(lastEvents, lastFightStart);
|
||
});
|
||
|
||
document.getElementById('player-filter').addEventListener('input', e => {
|
||
playerFilter = e.target.value.trim().toLowerCase();
|
||
renderTimeline(lastEvents, lastFightStart);
|
||
});
|
||
|
||
// ── Phase select ─────────────────────────────────────────────────────────
|
||
|
||
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('');
|
||
phaseSelect.value = 0;
|
||
phaseFilter = { startTime: phases[0].startTime, endTime: phases[0].endTime };
|
||
phaseSelect.style.display = '';
|
||
}
|
||
|
||
// ── Reference fight select ────────────────────────────────────────────────
|
||
|
||
const refFightSelect = document.getElementById('ref-fight-select');
|
||
|
||
refFightSelect.addEventListener('change', async () => {
|
||
const refId = parseInt(refFightSelect.value, 10);
|
||
if (!refId) {
|
||
refEvents = [];
|
||
refFightStart = 0;
|
||
renderTimeline(lastEvents, lastFightStart);
|
||
return;
|
||
}
|
||
|
||
const fight = (window.App?.fights ?? []).find(f => f.id === refId);
|
||
if (!fight) return;
|
||
|
||
// Clear ext-report selection
|
||
refExtFightSelect.value = '';
|
||
|
||
refFightSelect.disabled = true;
|
||
try {
|
||
const res = await fetch('api/analysis.php', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: new URLSearchParams({
|
||
report_code: window.App.reportCode,
|
||
fight_id: refId,
|
||
start_time: fight.startTime,
|
||
end_time: fight.endTime,
|
||
}),
|
||
});
|
||
const json = await res.json();
|
||
if (!json.error && !json.reauth) {
|
||
refEvents = json.aoe_events ?? [];
|
||
refFightStart = json.fight_start ?? fight.startTime;
|
||
}
|
||
} catch { }
|
||
refFightSelect.disabled = false;
|
||
renderTimeline(lastEvents, lastFightStart);
|
||
});
|
||
|
||
let allSameReportFights = [];
|
||
|
||
function populateRefFightSelect() {
|
||
const currentName = (window.App.fights ?? []).find(f => f.id === window.App.fightId)?.name;
|
||
const visible = allSameReportFights.filter(f =>
|
||
f.id !== window.App.fightId && (!currentName || f.name === currentName)
|
||
);
|
||
refFightSelect.innerHTML = '<option value="">Kein Vergleich</option>';
|
||
visible.forEach(f => {
|
||
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
||
const opt = document.createElement('option');
|
||
opt.value = f.id;
|
||
opt.textContent = `${f.name} — ${fmtDur(f.endTime - f.startTime)} — ${hp}`;
|
||
refFightSelect.appendChild(opt);
|
||
});
|
||
refFightSelect.style.display = visible.length ? '' : 'none';
|
||
}
|
||
|
||
function onFightsLoaded(fights) {
|
||
allSameReportFights = fights;
|
||
populateRefFightSelect();
|
||
}
|
||
|
||
// ── External report comparison ────────────────────────────────────────────
|
||
|
||
const refExtToggle = document.getElementById('ref-ext-toggle');
|
||
const refExtPanel = document.getElementById('ref-ext-panel');
|
||
const refReportInput = document.getElementById('ref-report-input');
|
||
const refReportLoad = document.getElementById('ref-report-load');
|
||
const refExtFightSelect = document.getElementById('ref-ext-fight-select');
|
||
|
||
refReportInput.addEventListener('input', () => {
|
||
const match = refReportInput.value.match(/fflogs\.com\/reports\/([A-Za-z0-9]+)/);
|
||
if (match) refReportInput.value = match[1];
|
||
});
|
||
|
||
refExtToggle.addEventListener('click', () => {
|
||
const hidden = refExtPanel.style.display === 'none';
|
||
refExtPanel.style.display = hidden ? '' : 'none';
|
||
});
|
||
|
||
refReportLoad.addEventListener('click', async () => {
|
||
const code = refReportInput.value.trim();
|
||
if (!code) return;
|
||
|
||
refReportLoad.disabled = true;
|
||
refReportLoad.textContent = 'Lädt…';
|
||
|
||
try {
|
||
const res = await fetch('api/fight.php', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: new URLSearchParams({ report_code: code }),
|
||
});
|
||
const json = await res.json();
|
||
if (json.reauth) { window.location.href = 'auth/start.php'; return; }
|
||
|
||
const fights = json?.data?.reportData?.report?.fights ?? [];
|
||
extFights = fights;
|
||
extReportCode = code;
|
||
|
||
const currentName = (window.App.fights ?? []).find(f => f.id === window.App.fightId)?.name;
|
||
const visibleExt = currentName ? fights.filter(f => f.name === currentName) : fights;
|
||
refExtFightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
||
visibleExt.forEach(f => {
|
||
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
||
const opt = document.createElement('option');
|
||
opt.value = f.id;
|
||
opt.textContent = `${f.name} — ${fmtDur(f.endTime - f.startTime)} — ${hp}`;
|
||
refExtFightSelect.appendChild(opt);
|
||
});
|
||
refExtFightSelect.style.display = visibleExt.length ? '' : 'none';
|
||
} catch { }
|
||
|
||
refReportLoad.disabled = false;
|
||
refReportLoad.textContent = 'Laden';
|
||
});
|
||
|
||
refExtFightSelect.addEventListener('change', async () => {
|
||
const refId = parseInt(refExtFightSelect.value, 10);
|
||
if (!refId) {
|
||
refEvents = [];
|
||
refFightStart = 0;
|
||
refPlayers = [];
|
||
renderRefPlayers();
|
||
renderTimeline(lastEvents, lastFightStart);
|
||
return;
|
||
}
|
||
|
||
const fight = extFights.find(f => f.id === refId);
|
||
if (!fight) return;
|
||
|
||
// Clear same-report selection
|
||
refFightSelect.value = '';
|
||
|
||
refExtFightSelect.disabled = true;
|
||
try {
|
||
const res = await fetch('api/analysis.php', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: new URLSearchParams({
|
||
report_code: extReportCode,
|
||
fight_id: refId,
|
||
start_time: fight.startTime,
|
||
end_time: fight.endTime,
|
||
}),
|
||
});
|
||
const json = await res.json();
|
||
if (!json.error && !json.reauth) {
|
||
refEvents = json.aoe_events ?? [];
|
||
refFightStart = json.fight_start ?? fight.startTime;
|
||
refPlayers = json.players ?? [];
|
||
}
|
||
} catch { }
|
||
refExtFightSelect.disabled = false;
|
||
renderRefPlayers();
|
||
renderTimeline(lastEvents, lastFightStart);
|
||
});
|
||
|
||
// ── Timeline rendering ────────────────────────────────────────────────────
|
||
|
||
function renderTimeline(events, fightStart) {
|
||
lastEvents = events;
|
||
lastFightStart = fightStart;
|
||
|
||
const el = document.getElementById('aoe-timeline');
|
||
|
||
if (!events.length) {
|
||
el.innerHTML = '<div class="empty" style="padding:30px 0"><h3>Keine AoE-Events gefunden</h3></div>';
|
||
return;
|
||
}
|
||
|
||
// Build reference index: abilityName → [events in order]
|
||
const refIndex = {};
|
||
for (const ev of refEvents) {
|
||
(refIndex[ev.abilityName] = refIndex[ev.abilityName] ?? []).push(ev);
|
||
}
|
||
const abilityOccurrence = {};
|
||
|
||
const rows = events.map(ev => {
|
||
if (ev.timestamp < phaseFilter.startTime || ev.timestamp >= phaseFilter.endTime) return '';
|
||
|
||
// Track occurrence for ref matching
|
||
const occ = abilityOccurrence[ev.abilityName] ?? 0;
|
||
abilityOccurrence[ev.abilityName] = occ + 1;
|
||
const refEv = refEvents.length ? (refIndex[ev.abilityName]?.[occ] ?? null) : null;
|
||
|
||
const visibleTargets = ev.targets.filter(t =>
|
||
!hiddenPlayers.has(t.id) &&
|
||
(!playerFilter || t.name.toLowerCase().includes(playerFilter))
|
||
);
|
||
if (!visibleTargets.length) return '';
|
||
|
||
// Collect boss debuffs (Reprisal/Feint/Addle) once at event level
|
||
const seenDebuffNames = new Set();
|
||
const eventDebuffs = [];
|
||
for (const t of visibleTargets) {
|
||
for (const m of (t.mitigations ?? [])) {
|
||
if (m.buffType === 'debuff' && !seenDebuffNames.has(m.name)) {
|
||
seenDebuffNames.add(m.name);
|
||
eventDebuffs.push(m);
|
||
}
|
||
}
|
||
}
|
||
const eventMissingDebuffs = refEv
|
||
? (refEv.targets[0]?.mitigations ?? []).filter(m => m.buffType === 'debuff' && !seenDebuffNames.has(m.name))
|
||
: [];
|
||
const debuffIconsHtml = [
|
||
...eventDebuffs.map(m => ({ ...m, missing: false })),
|
||
...eventMissingDebuffs.map(m => ({ ...m, missing: true })),
|
||
].map(m => {
|
||
const iconSrc = MITIG_ICONS[m.name];
|
||
if (!iconSrc) return '';
|
||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
||
return m.missing
|
||
? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr} fehlt (war im Referenz-Pull aktiv)">`
|
||
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
|
||
}).join('');
|
||
|
||
// Current targets
|
||
const targets = visibleTargets.map(t => {
|
||
const hpBar = (t.maxHp > 0) ? (() => {
|
||
const afterPct = t.hp / t.maxHp * 100;
|
||
const damagePct = t.amount / t.maxHp * 100;
|
||
const hpColor = afterPct > 50 ? 'var(--green)' : afterPct > 25 ? '#e8a020' : 'var(--red)';
|
||
const missingBefore = Math.max(0, t.maxHp - t.hp - t.amount);
|
||
const fmt = n => n.toLocaleString();
|
||
const hpPct = (t.hp / t.maxHp * 100).toFixed(1);
|
||
const missingPct = (missingBefore / t.maxHp * 100).toFixed(1);
|
||
const tooltip = `MaxHP: ${fmt(t.maxHp)}\nCurrentHP: ${fmt(t.hp)}\nHP-%: ${hpPct}%\nMissingBefore: ${fmt(missingBefore)}\nMissing-%: ${missingPct}%`;
|
||
return `<div class="aoe-hp-bar" title="${tooltip}">
|
||
<div class="aoe-hp-remaining" style="width:${afterPct.toFixed(1)}%;background:${hpColor}"></div>
|
||
<div class="aoe-hp-damage" style="width:${damagePct.toFixed(1)}%"></div>
|
||
</div>`;
|
||
})() : '';
|
||
|
||
const currentMitigNames = new Set((t.mitigations ?? []).map(m => m.name));
|
||
const refTarget = refEv?.targets?.find(rt => rt.name === t.name);
|
||
const missingMitigs = refTarget
|
||
? (refTarget.mitigations ?? []).filter(m => m.buffType === 'buff' && !currentMitigNames.has(m.name))
|
||
: [];
|
||
|
||
// DR buff icons (shown below player box)
|
||
const mitigIcons = (t.mitigations ?? []).filter(m => m.buffType === 'buff').map(m => {
|
||
const iconSrc = MITIG_ICONS[m.name];
|
||
if (!iconSrc) return '';
|
||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
||
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
|
||
}).join('');
|
||
|
||
// Shield tooltip on absorbed value
|
||
const activeShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield');
|
||
const missingShields = refTarget
|
||
? (refTarget.mitigations ?? []).filter(m => m.buffType === 'shield' && !currentMitigNames.has(m.name))
|
||
: [];
|
||
const shieldLines = [
|
||
...activeShields.map(s => s.name),
|
||
...missingShields.map(s => `[fehlt: ${s.name}]`),
|
||
];
|
||
const shieldTitle = shieldLines.length ? shieldLines.join('\n') : null;
|
||
|
||
const dead = t.hp === 0 && t.maxHp > 0;
|
||
|
||
return `
|
||
<div class="aoe-target-wrap">
|
||
<div class="aoe-target${dead ? ' aoe-target--dead' : ''}">
|
||
<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"><span title="Unmitigated: ${(t.unmitigatedAmount||0).toLocaleString()}\nMitigated: ${(t.mitigated||0).toLocaleString()}\nShielded: ${(t.absorbed||0).toLocaleString()}\nHP damage: ${(t.amount||0).toLocaleString()}">${fmtDmg(t.amount)}</span>${t.absorbed > 0 ? ` <span class="aoe-target-absorbed" title="${shieldTitle ?? 'Keine erkannten Schilde'}">+${fmtDmg(t.absorbed)}</span>` : ''}</span>
|
||
</div>
|
||
${hpBar}
|
||
</div>
|
||
</div>
|
||
${mitigIcons ? `<div class="aoe-target-buffs">${mitigIcons}</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Reference row
|
||
let refHtml = '';
|
||
if (refEv) {
|
||
const refVisible = refEv.targets.filter(t =>
|
||
!hiddenPlayerNames.has(t.name) &&
|
||
(!playerFilter || t.name.toLowerCase().includes(playerFilter))
|
||
);
|
||
if (refVisible.length) {
|
||
const currentByName = {};
|
||
ev.targets.forEach(t => { currentByName[t.name] = t; });
|
||
|
||
const seenRefDebuffNames = new Set();
|
||
const refDebuffIconsHtml = refVisible.flatMap(t => (t.mitigations ?? []))
|
||
.filter(m => m.buffType === 'debuff' && !seenRefDebuffNames.has(m.name) && seenRefDebuffNames.add(m.name))
|
||
.map(m => {
|
||
const iconSrc = MITIG_ICONS[m.name];
|
||
if (!iconSrc) return '';
|
||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
||
return `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}">`;
|
||
}).join('');
|
||
|
||
const refCards = refVisible.map(t => {
|
||
const curr = currentByName[t.name];
|
||
const diff = curr ? curr.amount - t.amount : 0;
|
||
const dead = t.hp === 0 && t.maxHp > 0;
|
||
|
||
const deltaHtml = diff !== 0
|
||
? `<span class="${diff > 0 ? 'aoe-delta-worse' : 'aoe-delta-better'}">${diff > 0 ? '+' : '-'}${fmtDmg(Math.abs(diff))}</span>`
|
||
: '';
|
||
|
||
const currMitigNames = new Set((curr?.mitigations ?? []).map(m => m.name));
|
||
|
||
const refMitigIcons = (t.mitigations ?? []).filter(m => m.buffType === 'buff').map(m => {
|
||
const iconSrc = MITIG_ICONS[m.name];
|
||
if (!iconSrc) return '';
|
||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
||
const missing = !currMitigNames.has(m.name);
|
||
const cls = missing ? ' aoe-buff-ref-unique' : '';
|
||
const titleSufx = missing ? ' (fehlt im aktuellen Pull)' : '';
|
||
return `<img class="aoe-target-buff-icon${cls}" src="${iconSrc}" alt="${m.name}" title="${m.name}${dr}${titleSufx}">`;
|
||
}).join('');
|
||
|
||
const refShields = (t.mitigations ?? []).filter(m => m.buffType === 'shield');
|
||
const refShieldTitle = refShields.length
|
||
? refShields.map(s => currMitigNames.has(s.name) ? s.name : `${s.name} [fehlt im aktuellen Pull]`).join('\n')
|
||
: null;
|
||
|
||
return `
|
||
<div class="aoe-target-wrap">
|
||
<div class="aoe-ref-target${dead ? ' aoe-target--dead' : ''}">
|
||
<div class="aoe-target-left">
|
||
<span class="aoe-target-job role-${t.role}">${abbr(t.type)}</span>
|
||
${deltaHtml}
|
||
</div>
|
||
<span class="aoe-target-name">${t.name}</span>
|
||
<span class="aoe-target-dmg">${fmtDmg(t.amount)}${t.absorbed > 0 ? ` <span class="aoe-target-absorbed" title="${refShieldTitle ?? 'Keine erkannten Schilde'}">+${fmtDmg(t.absorbed)}</span>` : ''}</span>
|
||
</div>
|
||
${refMitigIcons ? `<div class="aoe-target-buffs">${refMitigIcons}</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
|
||
const totalDiff = ev.totalDamage - refEv.totalDamage;
|
||
const totalDelta = totalDiff !== 0
|
||
? `<span class="${totalDiff > 0 ? 'aoe-delta-worse' : 'aoe-delta-better'}">${totalDiff > 0 ? '+' : ''}${fmtDmg(totalDiff)}</span>`
|
||
: '';
|
||
|
||
refHtml = `
|
||
<div class="aoe-ref-row">
|
||
<span class="aoe-ref-label">REF ${fmtDmg(refEv.totalDamage)} ${totalDelta} ${refDebuffIconsHtml}</span>
|
||
<div class="aoe-targets">${refCards}</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
return `
|
||
<div class="aoe-event">
|
||
<div class="aoe-time">${fmtTime(ev.timestamp, fightStart)}</div>
|
||
<div>
|
||
<div class="aoe-ability">
|
||
${ev.abilityName}
|
||
<span class="aoe-total">— ${fmtDmg(ev.totalDamage)} total</span>
|
||
${debuffIconsHtml}
|
||
</div>
|
||
<div class="aoe-targets">${targets}</div>
|
||
${refHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
el.innerHTML = rows || '<div class="empty" style="padding:30px 0"><h3>Keine sichtbaren Targets</h3></div>';
|
||
}
|
||
|
||
function setEmpty(msg) {
|
||
document.getElementById('analysis-loading').style.display = 'none';
|
||
document.getElementById('analysis-content').style.display = 'none';
|
||
document.getElementById('analysis-empty').style.display = 'block';
|
||
document.getElementById('analysis-empty-msg').textContent = msg;
|
||
}
|
||
|
||
let lastFightId = null;
|
||
|
||
async function load() {
|
||
const { reportCode, fightId, fightStart, fightEnd } = window.App ?? {};
|
||
if (!reportCode || !fightId) return;
|
||
if (lastFightId === fightId) return;
|
||
|
||
document.getElementById('analysis-loading').style.display = 'flex';
|
||
document.getElementById('analysis-empty').style.display = 'none';
|
||
document.getElementById('analysis-content').style.display = 'none';
|
||
|
||
let json;
|
||
try {
|
||
const res = await fetch('api/analysis.php', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: new URLSearchParams({ report_code: reportCode, fight_id: fightId, start_time: fightStart, end_time: fightEnd }),
|
||
});
|
||
json = await res.json();
|
||
} catch (err) {
|
||
setEmpty('Netzwerkfehler: ' + err.message);
|
||
return;
|
||
}
|
||
|
||
if (json.reauth) { window.location.href = 'auth/start.php'; return; }
|
||
if (json.error) { setEmpty('Fehler: ' + json.error); return; }
|
||
|
||
lastFightId = fightId;
|
||
populateRefFightSelect();
|
||
setupPhases(window.App?.phases ?? []);
|
||
renderPlayers(json.players ?? []);
|
||
renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart);
|
||
|
||
document.getElementById('analysis-loading').style.display = 'none';
|
||
document.getElementById('analysis-content').style.display = 'block';
|
||
}
|
||
|
||
window.analysisTab = {
|
||
onFightSelected: load,
|
||
onTabOpen: load,
|
||
onFightsLoaded: onFightsLoaded,
|
||
reset() {
|
||
lastFightId = null;
|
||
refEvents = [];
|
||
refFightStart = 0;
|
||
refPlayers = [];
|
||
extFights = [];
|
||
extReportCode = '';
|
||
document.getElementById('ref-player-section').style.display = 'none';
|
||
refFightSelect.value = '';
|
||
refFightSelect.style.display = 'none';
|
||
refExtFightSelect.value = '';
|
||
refExtFightSelect.style.display = 'none';
|
||
refExtPanel.style.display = 'none';
|
||
},
|
||
};
|
||
})();
|