diff --git a/api/analysis.php b/api/analysis.php index 3fcefbc..e04f0e3 100644 --- a/api/analysis.php +++ b/api/analysis.php @@ -212,11 +212,12 @@ foreach (MITIGATION_ABILITIES as $name => $meta) { } } -// statusId set for shield abilities — used to filter the buff timeline query -$shieldStatusIds = []; +// statusId set for tracked mitigations — used to resolve localized buff names +// from Buffs events and to build the shield fallback timeline. +$trackedStatusIds = []; foreach (MITIGATION_ABILITIES as $meta) { - if ($meta['buffType'] === 'shield' && isset($meta['statusId'])) { - $shieldStatusIds[$meta['statusId']] = true; + if (isset($meta['statusId'])) { + $trackedStatusIds[$meta['statusId']] = true; } } @@ -276,8 +277,9 @@ for ($page = 0; $page < 10; $page++) { // Builds applybuff/removebuff intervals per target so we can detect shields // that were consumed by a hit (absent from the damage event's buffs snapshot). $shieldTimeline = []; // targetId → statusId → [[apply, remove|null], ...] +$statusNames = []; // statusId → localized display name from Buffs events -if (!empty($shieldStatusIds)) { +if (!empty($trackedStatusIds)) { $nextPage = $startTime; for ($page = 0; $page < 10; $page++) { $bfResult = fflogs_gql(<< $ts, 'remove' => null]; @@ -329,6 +339,20 @@ if (!empty($shieldStatusIds)) { } } +foreach ($statusNames as $statusId => $displayName) { + if (isset($mitigIdMap[$statusId])) { + $mitigIdMap[$statusId]['name'] = $displayName; + } +} + +$mitigationNames = []; +foreach ($mitigIdMap as $meta) { + $key = $meta['key'] ?? null; + if ($key) { + $mitigationNames[$key] = $meta['name'] ?? $key; + } +} + // ── 3. AoE detection — proximity clustering ──────────────────────────────── // Group events by abilityId, then cluster by time proximity (≤ 1000ms from // the first event in the cluster) to avoid fixed-window boundary splits. @@ -474,7 +498,8 @@ foreach ($clusters as $group) { usort($aoeEvents, fn($a, $b) => $a['timestamp'] <=> $b['timestamp']); echo json_encode([ - 'players' => array_values($players), - 'aoe_events' => $aoeEvents, - 'fight_start' => (int)$startTime, + 'players' => array_values($players), + 'aoe_events' => $aoeEvents, + 'fight_start' => (int)$startTime, + 'mitigation_names' => $mitigationNames, ]); diff --git a/js/analysis.js b/js/analysis.js index a252c0e..5f160eb 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -103,6 +103,7 @@ let currentPlayers = []; let extFights = []; let extReportCode = ''; + let mitigationNames = {}; // ── Player grid ────────────────────────────────────────────────────────── @@ -683,6 +684,7 @@ 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'; @@ -717,6 +719,9 @@ players: currentPlayers, fightName: fight?.name ?? `Fight ${window.App?.fightId ?? '?'}`, reportCode: window.App?.reportCode ?? '', + fightId: window.App?.fightId ?? 0, + fightEnd: window.App?.fightEnd ?? 0, + mitigationNames, }; }, reset() { @@ -726,6 +731,7 @@ refPlayers = []; extFights = []; extReportCode = ''; + mitigationNames = {}; document.getElementById('ref-player-section').style.display = 'none'; refFightSelect.value = ''; refFightSelect.style.display = 'none'; diff --git a/js/app.js b/js/app.js index 0c7b0f7..db982bf 100644 --- a/js/app.js +++ b/js/app.js @@ -103,6 +103,7 @@ document.addEventListener('DOMContentLoaded', () => { window.App.language = normalizeLanguage(languageSelect.value); localStorage.setItem('ff14-mitigator-language', window.App.language); setUrlState({ language: window.App.language }); + window.dispatchEvent(new CustomEvent('ff14-language-change', { detail: { language: window.App.language } })); if (window.App.reportCode) { loadReport(window.App.reportCode, window.App.fightId); } @@ -136,6 +137,12 @@ document.addEventListener('DOMContentLoaded', () => { document.querySelector('.tabs .tab[data-tab="analysis"]')?.click(); } + function shouldAutoOpenAnalysis() { + const params = new URLSearchParams(window.location.search); + const requestedTab = params.get('tab') || localStorage.getItem('ff14-mitigator-active-tab'); + return requestedTab !== 'planner'; + } + function selectFight(id, updateUrl = true) { const fight = allFights.find(f => f.id === id); if (!fight) return false; @@ -340,7 +347,7 @@ document.addEventListener('DOMContentLoaded', () => { if (initialUrlState.reportCode) { form.elements['report_code'].value = initialUrlState.reportCode; loadReport(initialUrlState.reportCode, initialUrlState.fightId).then(() => { - if (initialUrlState.fightId) { + if (initialUrlState.fightId && shouldAutoOpenAnalysis()) { openAnalysisTab(); } if (initialUrlState.compareFightId) { diff --git a/js/planner.js b/js/planner.js index e8b1561..6acb9ad 100644 --- a/js/planner.js +++ b/js/planner.js @@ -1,6 +1,7 @@ // ── Storage ─────────────────────────────────────────────────────────────────── const PLANNER_KEY = 'ff14-planner-plans'; +const PLANNER_ACTIVE_KEY = 'ff14-planner-active-plan'; const FOLDERS_KEY = 'ff14-planner-folders'; function loadPlans() { @@ -39,6 +40,7 @@ function createPlan(name) { createdAt: Date.now(), updatedAt: Date.now(), source: null, + mitigationNames: {}, folderId: null, jobComposition: Array(8).fill(''), mechanics: [] @@ -118,6 +120,24 @@ function fmtNumber(n) { return Number(n).toLocaleString('de-DE'); } +function assignmentAbilityName(assignment, plan = null) { + const key = assignment?.ability ?? ''; + return assignment?.abilityName ?? plan?.mitigationNames?.[key] ?? key; +} + +function plannerLanguage() { + return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en'; +} + +function sameMechanic(existing, incoming, source) { + const fightStart = source?.fightStart ?? 0; + const incomingRel = incoming.timestamp - fightStart; + if (existing.abilityId && incoming.abilityId && existing.abilityId === incoming.abilityId) { + return Math.abs(existing.timestamp - incomingRel) < 1500; + } + return Math.abs(existing.timestamp - incomingRel) < 1500; +} + // ── Rendering: Plan List ────────────────────────────────────────────────────── function planItemHtml(p) { @@ -354,7 +374,8 @@ function renderMechanicListHtml(plan) { : 'badge-assign-buff'; const isMissing = !!a.job && !activeJobSet.has(a.job); const icon = MITIG_ICONS[a.ability] ?? ''; - const label = a.job ? `${escHtml(a.job)} · ${escHtml(a.ability)}` : escHtml(a.ability); + const ability = assignmentAbilityName(a, plan); + const label = a.job ? `${escHtml(a.job)} · ${escHtml(ability)}` : escHtml(ability); const title = isMissing ? `${escHtml(a.job)} nicht in Jobaufstellung` : ''; const suggestions = isMissing ? findEquivSuggestions(a.ability, activeJobSet) : []; const badgeHtml = ` @@ -576,8 +597,10 @@ function startRename(id, currentName) { function openPlan(id) { activePlanId = id; + localStorage.setItem(PLANNER_ACTIVE_KEY, id); renderPlanList(); renderPlanDetail(getPlan(id)); + refreshPlanLanguage(id); } // ── New plan form ───────────────────────────────────────────────────────────── @@ -928,9 +951,17 @@ function guessJob(abilityName, players) { return ''; } +function mitigationDisplayName(mitigation) { + return mitigation?.name ?? mitigation?.key ?? ''; +} + +function mitigationKey(mitigation) { + return mitigation?.key ?? mitigation?.name ?? ''; +} + // ── AoE Events → Plan Mechanics ─────────────────────────────────────────────── -function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations) { +function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations, mitigationNames = {}) { return aoeEvents.map(ev => { const relTs = ev.timestamp - fightStart; const phase = (phases ?? []).filter(p => p.id !== 0).find(p => @@ -949,10 +980,15 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga const seen = new Set(); for (const t of ev.targets) { for (const m of (t.mitigations ?? [])) { - const key = m.key ?? m.name; + const key = mitigationKey(m); if (!seen.has(key)) { seen.add(key); - assignments.push({ ability: key, job: guessJob(key, players), buffType: m.buffType ?? '' }); + assignments.push({ + ability: key, + abilityName: mitigationDisplayName(m) || mitigationNames[key], + job: guessJob(key, players), + buffType: m.buffType ?? '', + }); } } } @@ -961,6 +997,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga return { id: crypto.randomUUID(), name: ev.abilityName, + abilityId: ev.abilityId, timestamp: relTs, phase: phase?.name ?? '', unmitigatedDamage: avgUnmit, @@ -986,14 +1023,16 @@ function extractJobComp(players) { } function doImport(data, withMitigations, whereMode, mergeId, newName) { - const { aoeEvents, fightStart, phases, players, fightName, reportCode } = data; - const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations); + const { aoeEvents, fightStart, fightEnd, phases, players, fightName, reportCode, fightId, mitigationNames = {} } = data; + const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations, mitigationNames); + const source = { reportCode, fightId, fightName, fightStart, fightEnd, language: plannerLanguage() }; if (whereMode === 'new') { const plan = createPlan(uniquePlanName(newName || fightName || 'Importierter Plan')); return updatePlan(plan.id, { mechanics, - source: { reportCode, fightName }, + source, + mitigationNames, jobComposition: extractJobComp(players), }); } @@ -1011,7 +1050,87 @@ function doImport(data, withMitigations, whereMode, mergeId, newName) { } merged.sort((a, b) => a.timestamp - b.timestamp); - return updatePlan(mergeId, { mechanics: merged }); + return updatePlan(mergeId, { + mechanics: merged, + source: { ...(plan.source ?? {}), ...source }, + mitigationNames: { ...(plan.mitigationNames ?? {}), ...mitigationNames }, + }); +} + +const refreshingPlans = new Set(); + +async function refreshPlanLanguage(planId) { + const plan = getPlan(planId); + const source = plan?.source ?? {}; + const language = plannerLanguage(); + if (!plan || refreshingPlans.has(planId)) return; + if (!source.reportCode || !source.fightId || !source.fightStart || !source.fightEnd) return; + if (source.language === language && plan.mitigationNames && Object.keys(plan.mitigationNames).length) return; + + refreshingPlans.add(planId); + try { + const params = new URLSearchParams({ + report_code: source.reportCode, + fight_id: source.fightId, + start_time: source.fightStart, + end_time: source.fightEnd, + language, + }); + + const res = await fetch('api/analysis.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params, + }); + const json = await res.json(); + if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } + if (json.error) return; + + const refreshed = (json.aoe_events ?? []); + const mechanics = plan.mechanics.map(mechanic => { + const match = refreshed.find(ev => sameMechanic(mechanic, ev, source)); + if (!match) return mechanic; + const assignments = (mechanic.assignments ?? []).map(a => ({ + ...a, + abilityName: json.mitigation_names?.[a.ability] ?? a.abilityName, + })); + return { + ...mechanic, + name: match.abilityName ?? mechanic.name, + abilityId: match.abilityId ?? mechanic.abilityId, + assignments, + }; + }); + + let fightName = source.fightName; + try { + const fightRes = await fetch('api/fight.php', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ report_code: source.reportCode, language }), + }); + const fightJson = await fightRes.json(); + const fight = (fightJson?.data?.reportData?.report?.fights ?? []).find(f => f.id === source.fightId); + if (fight?.name) fightName = fight.name; + } catch { } + + const nextSource = { ...source, fightName, language }; + const nextName = plan.name === source.fightName && fightName ? fightName : plan.name; + const updated = updatePlan(planId, { + name: nextName, + mechanics, + source: nextSource, + mitigationNames: json.mitigation_names ?? plan.mitigationNames ?? {}, + }); + + if (updated && activePlanId === planId) { + renderPlanList(); + renderPlanDetail(updated); + } + } catch { } + finally { + refreshingPlans.delete(planId); + } } // ── Ability Assignment Modal ────────────────────────────────────────────────── @@ -1067,12 +1186,14 @@ function renderAbilityModalContent() { const otherClass = byOtherJob ? ' ability-chip--other-job' : ''; const title = byOtherJob ? `Bereits von ${escHtml(assigned.job)} zugewiesen` : ''; const icon = MITIG_ICONS[ab.name] ?? ''; + const assignedName = assigned ? assignmentAbilityName(assigned, plan) : ''; + const label = assignedName || plan.mitigationNames?.[ab.name] || ab.name; return ``; + >${icon ? `` : ''}${escHtml(label)}`; }).join(''); return ` @@ -1099,7 +1220,7 @@ function toggleAbilityAssignment(abilityName, job, buffType) { mechanic.assignments[idx].job = job; } } else { - mechanic.assignments.push({ ability: abilityName, job, buffType }); + mechanic.assignments.push({ ability: abilityName, abilityName: plan.mitigationNames?.[abilityName], job, buffType }); } updatePlan(abilityModalPlanId, { mechanics: plan.mechanics }); @@ -1241,6 +1362,17 @@ document.addEventListener('DOMContentLoaded', () => { initNewFolderForm(); initImportModal(); initAbilityModal(); + activePlanId = localStorage.getItem(PLANNER_ACTIVE_KEY); renderPlanList(); - renderPlanDetail(null); + if (activePlanId && getPlan(activePlanId)) { + renderPlanDetail(getPlan(activePlanId)); + } else { + activePlanId = null; + renderPlanDetail(null); + } +}); + +window.addEventListener('ff14-language-change', () => { + if (!activePlanId) return; + refreshPlanLanguage(activePlanId); }); diff --git a/js/tabs.js b/js/tabs.js index 6388c61..70b13f9 100644 --- a/js/tabs.js +++ b/js/tabs.js @@ -1,8 +1,10 @@ document.addEventListener('DOMContentLoaded', () => { const tabs = document.querySelectorAll('.tabs .tab'); const contents = document.querySelectorAll('.tab-content'); + const validTabs = new Set([...tabs].map(btn => btn.dataset.tab)); function showTab(name) { + if (!validTabs.has(name)) name = 'report'; contents.forEach(el => el.style.display = 'none'); tabs.forEach(btn => btn.classList.remove('active')); @@ -14,8 +16,13 @@ document.addEventListener('DOMContentLoaded', () => { if (name === 'analysis') window.analysisTab?.onTabOpen?.(); if (name === 'planner') window.plannerTab?.onTabOpen?.(); + + localStorage.setItem('ff14-mitigator-active-tab', name); } tabs.forEach(btn => btn.addEventListener('click', () => showTab(btn.dataset.tab))); window.showTab = showTab; + + const params = new URLSearchParams(window.location.search); + showTab(params.get('tab') || localStorage.getItem('ff14-mitigator-active-tab') || 'report'); });