- Replace fixed-window AoE bucketing with proximity clustering (1000ms from first event per ability) to prevent boundary splits - Show absorbed shield damage in blue next to player damage (e.g. 40k +20k) - Show overkill damage in red below job abbreviation on death - Phase dropdown in AoE Timeline for multi-phase fights (phaseTransitions) - Default phase selection: Ganzer Fight Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
245 lines
11 KiB
JavaScript
245 lines
11 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',
|
||
'Magick Barrier': 'assets/icons/mitigation/magick-barrier.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);
|
||
}
|
||
|
||
let hiddenPlayers = new Set();
|
||
let lastEvents = [];
|
||
let lastFightStart = 0;
|
||
let playerFilter = '';
|
||
let phaseFilter = { startTime: 0, endTime: Infinity };
|
||
|
||
function renderPlayers(players) {
|
||
const grid = document.getElementById('player-grid');
|
||
const order = { healer: 0, dps: 1, tank: 2 };
|
||
players.sort((a, b) => (order[a.role] ?? 2) - (order[b.role] ?? 2));
|
||
|
||
hiddenPlayers = new Set(players.filter(p => p.role === 'tank').map(p => p.id));
|
||
|
||
grid.innerHTML = players.map(p => `
|
||
<div class="player-card ${hiddenPlayers.has(p.id) ? 'player-hidden' : ''}" data-player-id="${p.id}">
|
||
<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('');
|
||
}
|
||
|
||
document.getElementById('player-grid').addEventListener('click', e => {
|
||
const card = e.target.closest('.player-card');
|
||
if (!card) return;
|
||
const id = parseInt(card.dataset.playerId, 10);
|
||
if (hiddenPlayers.has(id)) {
|
||
hiddenPlayers.delete(id);
|
||
card.classList.remove('player-hidden');
|
||
} else {
|
||
hiddenPlayers.add(id);
|
||
card.classList.add('player-hidden');
|
||
}
|
||
renderTimeline(lastEvents, lastFightStart);
|
||
});
|
||
|
||
document.getElementById('player-filter').addEventListener('input', e => {
|
||
playerFilter = e.target.value.trim().toLowerCase();
|
||
renderTimeline(lastEvents, lastFightStart);
|
||
});
|
||
|
||
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('');
|
||
// Pre-select "Ganzer Fight"
|
||
phaseSelect.value = 0;
|
||
phaseFilter = { startTime: phases[0].startTime, endTime: phases[0].endTime };
|
||
phaseSelect.style.display = '';
|
||
}
|
||
|
||
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 rows = events.map(ev => {
|
||
if (ev.timestamp < phaseFilter.startTime || ev.timestamp >= phaseFilter.endTime) return '';
|
||
|
||
const visibleTargets = ev.targets.filter(t =>
|
||
!hiddenPlayers.has(t.id) &&
|
||
(!playerFilter || t.name.toLowerCase().includes(playerFilter))
|
||
);
|
||
if (!visibleTargets.length) return '';
|
||
|
||
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)';
|
||
return `<div class="aoe-hp-bar">
|
||
<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>`;
|
||
})() : '';
|
||
|
||
const mitigIcons = (t.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}">`;
|
||
}).join('');
|
||
|
||
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">${fmtDmg(t.amount)}${t.absorbed > 0 ? ` <span class="aoe-target-absorbed">+${fmtDmg(t.absorbed)}</span>` : ''}</span>
|
||
</div>
|
||
${hpBar}
|
||
</div>
|
||
</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('');
|
||
|
||
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 }),
|
||
});
|
||
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;
|
||
setupPhases(window.App?.phases ?? []);
|
||
renderPlayers(json.players ?? []);
|
||
renderTimeline(json.aoe_events ?? [], json.fight_start ?? fightStart);
|
||
|
||
document.getElementById('analysis-loading').style.display = 'none';
|
||
document.getElementById('analysis-content').style.display = 'block';
|
||
}
|
||
|
||
window.analysisTab = {
|
||
onFightSelected: load,
|
||
onTabOpen: load,
|
||
reset() { lastFightId = null; },
|
||
};
|
||
})();
|