(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 = {};
let planRefId = '';
// ── 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 => `
`).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 => `
`).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 =>
``
).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 and plan selections
refExtFightSelect.value = '';
planRefId = '';
refPlanSelect.value = '';
refPlanPanel.style.display = 'none';
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 = json.players ?? [];
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 = '';
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 = '';
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 and plan selections
refFightSelect.value = '';
planRefId = '';
refPlanSelect.value = '';
refPlanPanel.style.display = 'none';
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);
});
// ── Plan as reference ─────────────────────────────────────────────────────
const refPlanToggle = document.getElementById('ref-plan-toggle');
const refPlanPanel = document.getElementById('ref-plan-panel');
const refPlanSelect = document.getElementById('ref-plan-select');
const PLAN_JOB_ROLE = {
'PLD': 'tank', 'WAR': 'tank', 'DRK': 'tank', 'GNB': 'tank',
'WHM': 'healer', 'SCH': 'healer', 'AST': 'healer', 'SGE': 'healer',
};
function loadPlansForRef() {
try { return JSON.parse(localStorage.getItem('ff14-planner-plans') || '[]'); }
catch { return []; }
}
function planToRefEvents(plan) {
const roster = plan.playerRoster ?? [];
const jobComp = plan.jobComposition ?? [];
const fightStart = plan.source?.fightStart ?? 0;
const mitigNames = plan.mitigationNames ?? {};
const players = jobComp.map((job, i) => ({
job,
name: roster[i]?.name ?? '',
role: PLAN_JOB_ROLE[job] ?? 'dps',
})).filter(p => p.name && p.job);
return plan.mechanics.map(m => {
const mitigations = (m.assignments ?? []).map(a => ({
key: a.ability,
name: a.abilityName || mitigNames[a.ability] || a.ability,
buffType: a.buffType,
dr: 0,
}));
const targets = players.map(p => ({
id: 0,
name: p.name,
type: p.job,
role: p.role,
amount: 0,
absorbed: 0,
overkill: 0,
hp: 0,
maxHp: 0,
unmitigatedAmount: 0,
mitigations,
}));
return {
abilityName: m.name,
abilityId: m.abilityId ?? 0,
timestamp: fightStart + m.timestamp,
totalDamage: 0,
targets,
isPlanRef: true,
};
});
}
function populateRefPlanSelect() {
const plans = loadPlansForRef();
refPlanSelect.innerHTML = '';
plans.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = `${p.name} (${p.mechanics.length} Mechaniken)`;
refPlanSelect.appendChild(opt);
});
refPlanSelect.value = planRefId || '';
}
refPlanToggle.addEventListener('click', () => {
const hidden = refPlanPanel.style.display === 'none';
refPlanPanel.style.display = hidden ? '' : 'none';
if (hidden) populateRefPlanSelect();
});
refPlanSelect.addEventListener('change', () => {
const id = refPlanSelect.value;
// Clear other ref sources
refFightSelect.value = '';
refExtFightSelect.value = '';
updateRefFflogsLink(0);
if (!id) {
planRefId = '';
refEvents = [];
refFightStart = 0;
refPlayers = [];
renderRefPlayers();
renderTimeline(lastEvents, lastFightStart);
return;
}
const plan = loadPlansForRef().find(p => p.id === id);
if (!plan) return;
planRefId = id;
refEvents = planToRefEvents(plan);
refFightStart = plan.source?.fightStart ?? 0;
refPlayers = [];
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 = 'Keine AoE-Events gefunden
';
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
? `
`
: `
`;
}).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 ``;
})() : '';
// 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 `
`;
}).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 `
${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 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 `
`;
}).join('');
const isPlanRef = !!refEv.isPlanRef;
const refCards = refVisible.map(t => {
const curr = currentByName[t.name];
const diff = (!isPlanRef && curr) ? curr.amount - t.amount : 0;
const dead = !isPlanRef && t.hp === 0 && t.maxHp > 0;
const deltaHtml = diff !== 0
? `${diff > 0 ? '+' : '-'}${fmtDmg(Math.abs(diff))}`
: '';
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 `
`;
}).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 = !isPlanRef && currentGroupHasJob && !currentEventMitigKeys.has(k);
return isMissing ? `${s.name} [fehlt im aktuellen Pull]` : s.name;
}).join('\n')
: null;
const absorbedHtml = isPlanRef
? (refShields.length ? ` Schild` : '')
: (t.absorbed > 0 ? ` +${fmtDmg(t.absorbed)}` : '');
return `
${abbr(t.type)}
${deltaHtml}
${t.name}
${isPlanRef ? '' : fmtDmg(t.amount)}${absorbedHtml}
${refMitigIcons ? `
${refMitigIcons}
` : ''}
`;
}).join('');
const totalDiff = ev.totalDamage - refEv.totalDamage;
const totalDelta = (!isPlanRef && totalDiff !== 0)
? `${totalDiff > 0 ? '+' : ''}${fmtDmg(totalDiff)}`
: '';
const refLabel = isPlanRef ? 'PLAN' : `REF ${fmtDmg(refEv.totalDamage)} ${totalDelta}`;
refHtml = `
${refLabel} ${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, 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 && !planRefId; },
reset() {
lastFightId = null;
refEvents = [];
refFightStart = 0;
refPlayers = [];
extFights = [];
extReportCode = '';
mitigationNames = {};
planRefId = '';
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';
refPlanPanel.style.display = 'none';
refPlanSelect.value = '';
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);
}
})();