(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 extFights = []; let extReportCode = ''; // ── Player grid ────────────────────────────────────────────────────────── function renderPlayers(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 => `
${abbr(p.type)}
${p.name}
${p.type}
`).join(''); } 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 => `` ).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 = ''; 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 = ''; 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; 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; } } catch { } refExtFightSelect.disabled = false; renderTimeline(lastEvents, lastFightStart); }); // ── Timeline rendering ──────────────────────────────────────────────────── function renderTimeline(events, fightStart) { lastEvents = events; lastFightStart = fightStart; const el = document.getElementById('aoe-timeline'); if (!events.length) { el.innerHTML = '

Keine AoE-Events gefunden

'; 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 ? `${m.name}` : `${m.name}`; }).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 `
`; })() : ''; 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 `${m.name}`; }).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 `
${abbr(t.type)} ${dead && t.overkill > 0 ? `-${fmtDmg(t.overkill)}` : ''}
${t.name} ${fmtDmg(t.amount)}${t.absorbed > 0 ? ` +${fmtDmg(t.absorbed)}` : ''}
${hpBar}
${mitigIcons ? `
${mitigIcons}
` : ''}
`; }).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 `${m.name}`; }).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 ? `${diff > 0 ? '+' : '-'}${fmtDmg(Math.abs(diff))}` : ''; 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 `${m.name}`; }).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 `
${abbr(t.type)} ${deltaHtml}
${t.name} ${fmtDmg(t.amount)}${t.absorbed > 0 ? ` +${fmtDmg(t.absorbed)}` : ''}
${refMitigIcons ? `
${refMitigIcons}
` : ''}
`; }).join(''); const totalDiff = ev.totalDamage - refEv.totalDamage; const totalDelta = totalDiff !== 0 ? `${totalDiff > 0 ? '+' : ''}${fmtDmg(totalDiff)}` : ''; refHtml = `
REF ${fmtDmg(refEv.totalDamage)} ${totalDelta} ${refDebuffIconsHtml}
${refCards}
`; } } return `
${fmtTime(ev.timestamp, fightStart)}
${ev.abilityName} — ${fmtDmg(ev.totalDamage)} total ${debuffIconsHtml}
${targets}
${refHtml}
`; }).join(''); el.innerHTML = rows || '

Keine sichtbaren Targets

'; } 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; extFights = []; extReportCode = ''; refFightSelect.value = ''; refFightSelect.style.display = 'none'; refExtFightSelect.value = ''; refExtFightSelect.style.display = 'none'; refExtPanel.style.display = 'none'; }, }; })();