forked from xziino/ff14-mitigator
Two-tab app: report viewer + analysis tab with AoE timeline, per-player mitigation icons (local XIVAPI PNGs), and fight-wide buff/debuff window tracking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
157 lines
6.7 KiB
JavaScript
157 lines
6.7 KiB
JavaScript
(function () {
|
||
const MITIG_ICONS = {
|
||
'Passage of Arms': 'assets/icons/mitigation/passage-of-arms.png',
|
||
'Divine Veil': 'assets/icons/mitigation/divine-veil.png',
|
||
'Shake It Off': 'assets/icons/mitigation/shake-it-off.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',
|
||
'Panhaima': 'assets/icons/mitigation/panhaima.png',
|
||
'Troubadour': 'assets/icons/mitigation/troubadour.png',
|
||
'Tactician': 'assets/icons/mitigation/tactician.png',
|
||
'Shield Samba': 'assets/icons/mitigation/shield-samba.png',
|
||
'Reprisal': 'assets/icons/mitigation/reprisal.png',
|
||
'Feint': 'assets/icons/mitigation/feint.png',
|
||
'Addle': 'assets/icons/mitigation/addle.png',
|
||
};
|
||
|
||
const JOB_ABBR = {
|
||
'Paladin': 'PLD', 'Warrior': 'WAR', 'DarkKnight': 'DRK', 'Gunbreaker': 'GNB',
|
||
'WhiteMage': 'WHM', 'Scholar': 'SCH', 'Astrologian': 'AST', 'Sage': 'SGE',
|
||
'Monk': 'MNK', 'Dragoon': 'DRG', 'Ninja': 'NIN', 'Samurai': 'SAM',
|
||
'Reaper': 'RPR', 'Viper': 'VPR',
|
||
'Bard': 'BRD', 'Machinist': 'MCH', 'Dancer': 'DNC',
|
||
'BlackMage': 'BLM', 'Summoner': 'SMN', 'RedMage': 'RDM',
|
||
'Pictomancer': 'PCT', 'BlueMage': 'BLU',
|
||
};
|
||
|
||
function abbr(type) {
|
||
return JOB_ABBR[type] ?? type.slice(0, 3).toUpperCase();
|
||
}
|
||
|
||
function fmtTime(ms, start) {
|
||
const rel = ms - start;
|
||
const min = Math.floor(rel / 60000);
|
||
const sec = String(Math.floor((rel % 60000) / 1000)).padStart(2, '0');
|
||
return `${min}:${sec}`;
|
||
}
|
||
|
||
function fmtDmg(n) {
|
||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
|
||
if (n >= 1_000) return Math.round(n / 1_000) + 'k';
|
||
return String(n);
|
||
}
|
||
|
||
function renderPlayers(players) {
|
||
const grid = document.getElementById('player-grid');
|
||
const order = { tank: 0, healer: 1, dps: 2 };
|
||
players.sort((a, b) => (order[a.role] ?? 2) - (order[b.role] ?? 2));
|
||
|
||
grid.innerHTML = players.map(p => `
|
||
<div class="player-card">
|
||
<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 renderTimeline(events, 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;
|
||
}
|
||
|
||
el.innerHTML = events.map(ev => {
|
||
const mitigIcons = (ev.mitigations ?? []).map(m => {
|
||
const iconSrc = 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} (${m.casterName})">`;
|
||
}).join('');
|
||
|
||
const targets = ev.targets.map(t => `
|
||
<div class="aoe-target-wrap">
|
||
<div class="aoe-target">
|
||
<span class="aoe-target-job role-${t.role}">${abbr(t.type)}</span>
|
||
<span class="aoe-target-name">${t.name}</span>
|
||
<span class="aoe-target-dmg">${fmtDmg(t.amount)}</span>
|
||
</div>
|
||
${mitigIcons ? `<div class="aoe-target-buffs">${mitigIcons}</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
|
||
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>
|
||
</div>
|
||
<div class="aoe-targets">${targets}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function setEmpty(msg) {
|
||
document.getElementById('analysis-loading').style.display = 'none';
|
||
document.getElementById('analysis-content').style.display = 'none';
|
||
document.getElementById('analysis-empty').style.display = 'block';
|
||
document.getElementById('analysis-empty-msg').textContent = msg;
|
||
}
|
||
|
||
let lastFightId = null;
|
||
|
||
async function load() {
|
||
const { reportCode, fightId, fightStart, fightEnd } = window.App ?? {};
|
||
if (!reportCode || !fightId) return;
|
||
if (lastFightId === fightId) return;
|
||
|
||
document.getElementById('analysis-loading').style.display = 'flex';
|
||
document.getElementById('analysis-empty').style.display = 'none';
|
||
document.getElementById('analysis-content').style.display = 'none';
|
||
|
||
let json;
|
||
try {
|
||
const res = await fetch('api/analysis.php', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: new URLSearchParams({ report_code: reportCode, fight_id: fightId, start_time: fightStart, end_time: fightEnd }),
|
||
});
|
||
json = await res.json();
|
||
} catch (err) {
|
||
setEmpty('Netzwerkfehler: ' + err.message);
|
||
return;
|
||
}
|
||
|
||
if (json.reauth) { window.location.href = 'auth/start.php'; return; }
|
||
if (json.error) { setEmpty('Fehler: ' + json.error); return; }
|
||
|
||
lastFightId = fightId;
|
||
renderPlayers(json.players ?? []);
|
||
renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart);
|
||
|
||
document.getElementById('analysis-loading').style.display = 'none';
|
||
document.getElementById('analysis-content').style.display = 'block';
|
||
}
|
||
|
||
window.analysisTab = {
|
||
onFightSelected: load,
|
||
onTabOpen: load,
|
||
reset() { lastFightId = null; },
|
||
};
|
||
})();
|