ff14-mitigator/js/analysis.js

752 lines
35 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(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}`;
}
function normalizeFightName(name) {
return String(name ?? '').trim().toLowerCase();
}
function currentFightName() {
const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId);
return normalizeFightName(fight?.name);
}
function isSameFightName(fight) {
const name = currentFightName();
return name !== '' && normalizeFightName(fight?.name) === name;
}
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 = '';
let mitigationNames = {};
// ── 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;
refPlayers = [];
window.App.setUrlState?.({ compareReportCode: '', compareFightId: '' });
renderRefPlayers();
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,
language: window.App.language,
}),
});
const json = await res.json();
if (!json.error && !json.reauth) {
refEvents = json.aoe_events ?? [];
refFightStart = json.fight_start ?? fight.startTime;
refPlayers = [];
window.App.setUrlState?.({
compareReportCode: '',
compareFightId: refId,
language: window.App.language,
});
}
} catch { }
refFightSelect.disabled = false;
renderRefPlayers();
renderTimeline(lastEvents, lastFightStart);
});
let allSameReportFights = [];
function populateRefFightSelect() {
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameFightName(f));
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 refFflogsLink = document.getElementById('ref-fflogs-report-link');
const refExtFightSelect = document.getElementById('ref-ext-fight-select');
function updateRefFflogsLink(fightId = 0) {
if (!extReportCode) {
refFflogsLink.style.display = 'none';
refFflogsLink.href = '#';
return;
}
refFflogsLink.href = window.App?.fflogsReportUrl
? window.App.fflogsReportUrl(extReportCode, fightId)
: `https://www.fflogs.com/reports/${encodeURIComponent(extReportCode)}${fightId ? `#fight=${fightId}` : ''}`;
refFflogsLink.style.display = '';
}
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';
});
async function loadExternalReport(code, preferredFightId = 0) {
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, language: window.App.language }),
});
const json = await res.json();
if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; }
const fights = json?.data?.reportData?.report?.fights ?? [];
extFights = fights;
extReportCode = code;
updateRefFflogsLink();
const visibleExt = fights.filter(isSameFightName);
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';
refExtPanel.style.display = '';
if (preferredFightId && visibleExt.some(f => f.id === preferredFightId)) {
refExtFightSelect.value = String(preferredFightId);
updateRefFflogsLink(preferredFightId);
await loadExternalCompare(preferredFightId);
}
} catch { }
refReportLoad.disabled = false;
refReportLoad.textContent = 'Laden';
}
refReportLoad.addEventListener('click', async () => {
await loadExternalReport(refReportInput.value.trim());
});
async function loadExternalCompare(refId) {
if (!refId) {
refEvents = [];
refFightStart = 0;
refPlayers = [];
window.App.setUrlState?.({ compareReportCode: '', compareFightId: '' });
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,
language: window.App.language,
}),
});
const json = await res.json();
if (!json.error && !json.reauth) {
refEvents = json.aoe_events ?? [];
refFightStart = json.fight_start ?? fight.startTime;
refPlayers = json.players ?? [];
window.App.setUrlState?.({
compareReportCode: extReportCode,
compareFightId: refId,
language: window.App.language,
});
}
} catch { }
refExtFightSelect.disabled = false;
renderRefPlayers();
renderTimeline(lastEvents, lastFightStart);
}
refExtFightSelect.addEventListener('change', async () => {
const refId = parseInt(refExtFightSelect.value, 10) || 0;
updateRefFflogsLink(refId);
await loadExternalCompare(refId);
});
// ── 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 (ev.isHeavyTankbuster && !visibleTargets.some(t => t.role === 'tank')) return '';
if (!visibleTargets.length) return '';
// Collect boss debuffs (Reprisal/Feint/Addle) once at event level
const seenDebuffKeys = new Set();
const eventDebuffs = [];
for (const t of visibleTargets) {
for (const m of (t.mitigations ?? [])) {
const key = m.key ?? m.name;
if (m.buffType === 'debuff' && !seenDebuffKeys.has(key)) {
seenDebuffKeys.add(key);
eventDebuffs.push(m);
}
}
}
const eventMissingDebuffs = refEv
? (refEv.targets[0]?.mitigations ?? []).filter(m => m.buffType === 'debuff' && !seenDebuffKeys.has(m.key ?? m.name))
: [];
const debuffIconsHtml = [
...eventDebuffs.map(m => ({ ...m, missing: false })),
...eventMissingDebuffs.map(m => ({ ...m, missing: true })),
].map(m => {
const iconSrc = MITIG_ICONS[m.key] ?? 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 currentMitigKeys = new Set((t.mitigations ?? []).map(m => m.key ?? m.name));
const refTarget = refEv?.targets?.find(rt => rt.name === t.name);
const missingMitigs = refTarget
? (refTarget.mitigations ?? []).filter(m => m.buffType === 'buff' && !currentMitigKeys.has(m.key ?? 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.key] ?? 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' && !currentMitigKeys.has(m.key ?? 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 seenRefDebuffKeys = new Set();
const refDebuffIconsHtml = refVisible.flatMap(t => (t.mitigations ?? []))
.filter(m => m.buffType === 'debuff' && !seenRefDebuffKeys.has(m.key ?? m.name) && seenRefDebuffKeys.add(m.key ?? m.name))
.map(m => {
const iconSrc = MITIG_ICONS[m.key] ?? 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 currMitigKeys = new Set((curr?.mitigations ?? []).map(m => m.key ?? m.name));
const refMitigIcons = (t.mitigations ?? []).filter(m => m.buffType === 'buff').map(m => {
const iconSrc = MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name];
if (!iconSrc) return '';
const dr = m.dr > 0 ? ` ${m.dr}%` : '';
const missing = !currMitigKeys.has(m.key ?? 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 => currMitigKeys.has(s.key ?? 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, language: window.App.language }),
});
json = await res.json();
} catch (err) {
setEmpty('Netzwerkfehler: ' + err.message);
return;
}
if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; }
if (json.error) { setEmpty('Fehler: ' + json.error); return; }
lastFightId = fightId;
populateRefFightSelect();
setupPhases(window.App?.phases ?? []);
renderPlayers(json.players ?? []);
mitigationNames = json.mitigation_names ?? {};
renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart);
document.getElementById('analysis-loading').style.display = 'none';
document.getElementById('analysis-content').style.display = 'block';
const exportBtn = document.getElementById('export-to-planner-btn');
if (exportBtn) exportBtn.style.display = '';
}
window.analysisTab = {
onFightSelected: load,
onTabOpen: load,
onFightsLoaded: onFightsLoaded,
async selectSharedCompare(fightId, reportCode = '') {
if (!fightId) return;
if (reportCode && reportCode !== window.App?.reportCode) {
refReportInput.value = reportCode;
await loadExternalReport(reportCode, fightId);
return;
}
if ([...refFightSelect.options].some(opt => parseInt(opt.value, 10) === fightId)) {
refFightSelect.value = String(fightId);
refFightSelect.dispatchEvent(new Event('change'));
}
},
exportForPlanner() {
const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId);
return {
aoeEvents: lastEvents,
fightStart: lastFightStart,
phases: window.App?.phases ?? [],
players: currentPlayers,
fightName: fight?.name ?? `Fight ${window.App?.fightId ?? '?'}`,
reportCode: window.App?.reportCode ?? '',
fightId: window.App?.fightId ?? 0,
fightEnd: window.App?.fightEnd ?? 0,
mitigationNames,
};
},
reset() {
lastFightId = null;
refEvents = [];
refFightStart = 0;
refPlayers = [];
extFights = [];
extReportCode = '';
mitigationNames = {};
document.getElementById('ref-player-section').style.display = 'none';
refFightSelect.value = '';
refFightSelect.style.display = 'none';
refExtFightSelect.value = '';
refExtFightSelect.style.display = 'none';
refFflogsLink.style.display = 'none';
refFflogsLink.href = '#';
refExtPanel.style.display = 'none';
const exportBtn = document.getElementById('export-to-planner-btn');
if (exportBtn) exportBtn.style.display = 'none';
},
};
document.getElementById('export-to-planner-btn')?.addEventListener('click', () => {
window.plannerTab?.showImportModal(window.analysisTab.exportForPlanner());
});
})();