ff14-mitigator/js/analysis.js
xziino 565dedc568 Analyse-Tab: Job-basierter Mitigation-Vergleich statt Namens-Match
Ref-Vergleich prüft jetzt ob die aktuelle Gruppe den passenden Job hat
(via ABILITY_JOBS-Map), statt Spielernamen zu matchen. Fehlende
Mitigations werden nur noch in der REF-Zeile hervorgehoben — der
aktive Pull zeigt ausschließlich tatsächlich genutzte Mitigations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:53:01 +02:00

889 lines
40 KiB
JavaScript
Raw 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',
};
// ability name → jobs that can provide it (for job-based ref comparison)
const ABILITY_JOBS = {
'Passage of Arms': ['PLD'],
'Divine Veil': ['PLD'],
'Guardian': ['PLD'],
'Reprisal': ['PLD', 'WAR', 'DRK', 'GNB'],
'Shake It Off': ['WAR'],
'Bloodwhetting': ['WAR'],
'Dark Missionary': ['DRK'],
'Heart of Light': ['GNB'],
'Temperance': ['WHM'],
'Divine Benison': ['WHM'],
'Divine Caress': ['WHM'],
'Sacred Soil': ['SCH'],
'Expedient': ['SCH'],
'Fey Illumination': ['SCH'],
'Galvanize': ['SCH'],
'Seraphic Veil': ['SCH'],
'Catalyze': ['SCH'],
'Collective Unconscious': ['AST'],
'Neutral Sect': ['AST'],
'Intersection': ['AST'],
'the Spire': ['AST'],
'Kerachole': ['SGE'],
'Holos': ['SGE'],
'Holosakos': ['SGE'],
'Panhaima': ['SGE'],
'Eukrasian Prognosis': ['SGE'],
'Eukrasian Prognosis II': ['SGE'],
'Eukrasian Diagnosis': ['SGE'],
'Differential Diagnosis': ['SGE'],
'Haima': ['SGE'],
'Troubadour': ['BRD'],
'Tactician': ['MCH'],
'Shield Samba': ['DNC'],
'Improvised Finish': ['DNC'],
'Feint': ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'],
'Addle': ['SCH', 'SGE', 'BLM', 'SMN', 'RDM', 'PCT'],
'Radiant Aegis': ['SMN'],
'Magick Barrier': ['RDM'],
'Tempera Coat': ['PCT'],
'Tempera Grassa': ['PCT'],
};
// Deduplicated list of all mitigations across all targets of a ref event
function collectRefMitigs(refEvent) {
if (!refEvent) return [];
const seen = new Set(), result = [];
for (const t of refEvent.targets ?? []) {
for (const m of (t.mitigations ?? [])) {
const k = m.key ?? m.name;
if (!seen.has(k)) { seen.add(k); result.push(m); }
}
}
return result;
}
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;
}
const currentFightJobSet = new Set(currentPlayers.map(p => JOB_ABBR[p.type]).filter(Boolean));
// 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 allRefMitigs = collectRefMitigs(refEv);
const currentEventMitigKeys = new Set();
for (const t of ev.targets) {
for (const m of (t.mitigations ?? [])) currentEventMitigKeys.add(m.key ?? m.name);
}
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
? allRefMitigs.filter(m => {
if (m.buffType !== 'debuff' || seenDebuffKeys.has(m.key ?? m.name)) return false;
const jobs = ABILITY_JOBS[m.key] ?? ABILITY_JOBS[m.name];
return jobs ? jobs.some(j => currentFightJobSet.has(j)) : false;
})
: [];
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>`;
})() : '';
// 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 shieldLines = activeShields.map(s => 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 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 k = m.key ?? m.name;
const jobs = ABILITY_JOBS[k] ?? ABILITY_JOBS[m.name];
const currentGroupHasJob = jobs ? jobs.some(j => currentFightJobSet.has(j)) : false;
const missing = currentGroupHasJob && !currentEventMitigKeys.has(k);
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 => {
const k = s.key ?? s.name;
const jobs = ABILITY_JOBS[k];
const currentGroupHasJob = jobs ? jobs.some(j => currentFightJobSet.has(j)) : false;
const isMissing = currentGroupHasJob && !currentEventMitigKeys.has(k);
return isMissing ? `${s.name} [fehlt im aktuellen Pull]` : s.name;
}).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,
};
},
exportRefForPlanner() {
const sameReportId = parseInt(refFightSelect.value, 10);
const extId = parseInt(refExtFightSelect.value, 10);
let fight = null, reportCode = '', fightId = 0;
if (sameReportId) {
fight = allSameReportFights.find(f => f.id === sameReportId);
reportCode = window.App?.reportCode ?? '';
fightId = sameReportId;
} else if (extId) {
fight = extFights.find(f => f.id === extId);
reportCode = extReportCode;
fightId = extId;
}
const transitions = fight?.phaseTransitions ?? [];
const phases = transitions.length === 0 ? [] : [
{ id: 0, name: 'Ganzer Fight', startTime: fight.startTime, endTime: fight.endTime },
...transitions.map((t, i) => ({
id: t.id,
name: `Phase ${t.id}`,
startTime: t.startTime,
endTime: transitions[i + 1]?.startTime ?? fight.endTime,
})),
];
return {
aoeEvents: refEvents,
fightStart: refFightStart,
phases,
players: refPlayers,
fightName: fight?.name ?? 'Referenz-Fight',
reportCode,
fightId,
fightEnd: fight?.endTime ?? 0,
mitigationNames,
};
},
hasRefExport() { return refEvents.length > 0; },
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', (e) => {
if (!refEvents.length) {
window.plannerTab?.showImportModal(window.analysisTab.exportForPlanner());
return;
}
showExportChoiceMenu(e.currentTarget);
});
function showExportChoiceMenu(anchor) {
document.getElementById('export-choice-menu')?.remove();
const menu = document.createElement('div');
menu.id = 'export-choice-menu';
menu.className = 'export-choice-menu';
[
{ label: 'Aktueller Fight', fn: () => window.analysisTab.exportForPlanner() },
{ label: 'Referenz-Fight', fn: () => window.analysisTab.exportRefForPlanner() },
].forEach(({ label, fn }) => {
const btn = document.createElement('button');
btn.className = 'export-choice-item';
btn.textContent = label;
btn.addEventListener('click', () => {
menu.remove();
window.plannerTab?.showImportModal(fn());
});
menu.appendChild(btn);
});
document.body.appendChild(menu);
const rect = anchor.getBoundingClientRect();
menu.style.top = (rect.bottom + 4) + 'px';
menu.style.right = (window.innerWidth - rect.right) + 'px';
const close = (ev) => {
if (!menu.contains(ev.target) && ev.target !== anchor) {
menu.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 0);
}
})();