- Neuer Abschnitt 2c in analysis.php: Cast-Events fuer Reprisal/Feint/Addle per 1 GQL-Request (3 Aliase) abfragen, da dataType:Buffs nur Friendly- Targets liefert (Boss-Debuffs fehlen dort) - buffSourceTimeline mit Cast-Timestamps + 10s Dauer befuellt - findBuffSourcePlayer(): sucht aktiven Caster zum Schadens-Zeitpunkt - resolveMitigations(): gibt sourcePlayerType aus buffSourceTimeline zurueck - guessJob() in planner.js: sourcePlayerType als erste Prioritaet vor job-basiertem Fallback -> DRK Reprisal, MNK Feint etc. korrekt zugeordnet - analysis.js: Debuff-Icons im Header zeigen Job im Tooltip (z.B. DRK - Reprisal) - Cache v5 -> v6 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
955 lines
41 KiB
JavaScript
955 lines
41 KiB
JavaScript
(function () {
|
||
const { MITIG_ICONS, JOB_ABBR, ABILITY_JOBS, JOB_ROLE } = window.FF14_DATA;
|
||
|
||
// 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 fightEncounterId(fight) {
|
||
return parseInt(fight?.encounterID ?? fight?.encounterId ?? 0, 10) || 0;
|
||
}
|
||
|
||
function currentFight() {
|
||
return (window.App?.fights ?? []).find(f => f.id === window.App?.fightId) ?? null;
|
||
}
|
||
|
||
function isSameEncounter(fight) {
|
||
const selectedFight = currentFight();
|
||
const selectedEncounterId = fightEncounterId(selectedFight);
|
||
const encounterId = fightEncounterId(fight);
|
||
|
||
if (selectedEncounterId && encounterId) {
|
||
return encounterId === selectedEncounterId;
|
||
}
|
||
|
||
const name = normalizeFightName(selectedFight?.name);
|
||
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 = '';
|
||
let actionIconPromise = null;
|
||
const actionIconsByName = {};
|
||
|
||
function mitigationIcon(m) {
|
||
return MITIG_ICONS[m.key] ?? MITIG_ICONS[m.name] ?? actionIconsByName[m.key] ?? actionIconsByName[m.name] ?? '';
|
||
}
|
||
|
||
async function ensureActionIconCache() {
|
||
if (actionIconPromise) return actionIconPromise;
|
||
actionIconPromise = (async () => {
|
||
try {
|
||
const res = await fetch('assets/jsons/Action.json', { cache: 'no-cache' });
|
||
if (!res.ok) return;
|
||
const actions = await res.json();
|
||
for (const action of Object.values(actions ?? {})) {
|
||
if (action?.names?.en && action?.icon) actionIconsByName[action.names.en] = action.icon;
|
||
}
|
||
} catch { }
|
||
})();
|
||
return actionIconPromise;
|
||
}
|
||
|
||
// ── 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 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 && isSameEncounter(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(isSameEncounter);
|
||
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 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 ?? [];
|
||
await ensureActionIconCache();
|
||
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');
|
||
|
||
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: 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 = '<option value="">— Plan auswählen —</option>';
|
||
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 = (plan.jobComposition ?? [])
|
||
.map((job, i) => {
|
||
const name = plan.playerRoster?.[i]?.name ?? '';
|
||
if (!name || !job) return null;
|
||
return { name, type: job, role: JOB_ROLE[job] ?? 'dps' };
|
||
})
|
||
.filter(Boolean);
|
||
|
||
renderRefPlayers();
|
||
renderTimeline(lastEvents, lastFightStart);
|
||
});
|
||
|
||
// ── Timeline rendering ────────────────────────────────────────────────────
|
||
|
||
function renderTimeline(events, fightStart) {
|
||
lastEvents = events;
|
||
lastFightStart = fightStart;
|
||
|
||
const el = document.getElementById('aoe-timeline');
|
||
|
||
if (!events.length) {
|
||
el.innerHTML = '<div class="empty" style="padding:30px 0"><h3>Keine AoE-Events gefunden</h3></div>';
|
||
return;
|
||
}
|
||
|
||
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 = mitigationIcon(m);
|
||
if (!iconSrc) return '';
|
||
const dr = m.dr > 0 ? ` −${m.dr}%` : '';
|
||
const jobAbbr = m.sourcePlayerType ? (JOB_ABBR[m.sourcePlayerType] ?? '') : '';
|
||
const label = jobAbbr ? `${jobAbbr} · ${m.name}` : m.name;
|
||
return m.missing
|
||
? `<img class="aoe-target-buff-icon aoe-buff-missing" src="${iconSrc}" alt="${m.name}" title="${label}${dr} fehlt (war im Referenz-Pull aktiv)">`
|
||
: `<img class="aoe-target-buff-icon" src="${iconSrc}" alt="${m.name}" title="${label}${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 = mitigationIcon(m);
|
||
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 = mitigationIcon(m);
|
||
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 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
|
||
? `<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 = mitigationIcon(m);
|
||
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 = !isPlanRef && currentGroupHasJob && !currentEventMitigKeys.has(k);
|
||
return isMissing ? `${s.name} [fehlt im aktuellen Pull]` : s.name;
|
||
}).join('\n')
|
||
: null;
|
||
|
||
const absorbedHtml = isPlanRef
|
||
? (refShields.length ? ` <span class="aoe-target-absorbed" title="${refShieldTitle ?? ''}">Schild</span>` : '')
|
||
: (t.absorbed > 0 ? ` <span class="aoe-target-absorbed" title="${refShieldTitle ?? 'Keine erkannten Schilde'}">+${fmtDmg(t.absorbed)}</span>` : '');
|
||
|
||
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">${isPlanRef ? '' : fmtDmg(t.amount)}${absorbedHtml}</span>
|
||
</div>
|
||
${refMitigIcons ? `<div class="aoe-target-buffs">${refMitigIcons}</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
|
||
const totalDiff = ev.totalDamage - refEv.totalDamage;
|
||
const totalDelta = (!isPlanRef && totalDiff !== 0)
|
||
? `<span class="${totalDiff > 0 ? 'aoe-delta-worse' : 'aoe-delta-better'}">${totalDiff > 0 ? '+' : ''}${fmtDmg(totalDiff)}</span>`
|
||
: '';
|
||
const refLabel = isPlanRef ? 'PLAN' : `REF ${fmtDmg(refEv.totalDamage)} ${totalDelta}`;
|
||
|
||
refHtml = `
|
||
<div class="aoe-ref-row">
|
||
<span class="aoe-ref-label">${refLabel} ${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 ?? {};
|
||
await ensureActionIconCache();
|
||
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);
|
||
}
|
||
})();
|