diff --git a/css/planner.css b/css/planner.css index 33f27bb..db94af1 100644 --- a/css/planner.css +++ b/css/planner.css @@ -211,6 +211,9 @@ font-size: 12px; color: var(--t2); } +.badge-assign-buff { background: rgba(74,158,255,.08); border-color: rgba(74,158,255,.4); color: var(--blue); } +.badge-assign-debuff { background: rgba(224,92,92,.08); border-color: rgba(224,92,92,.4); color: var(--red); } +.badge-assign-shield { background: rgba(200,168,75,.08); border-color: rgba(200,168,75,.4); color: var(--gold); } .mechanic-notes { font-size: 12px; @@ -218,3 +221,91 @@ font-style: italic; margin-top: 2px; } + +/* ── Import Modal ────────────────────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.72); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 20px; +} + +.modal-box { + background: var(--bgcard); + border: 1px solid var(--borderem); + border-radius: var(--rl); + padding: 28px 32px; + max-width: 480px; + width: 100%; +} + +.modal-title { + font-family: var(--font-d); + font-size: 15px; + color: var(--gold); + letter-spacing: 0.07em; + text-transform: uppercase; + margin-bottom: 22px; +} + +.modal-section { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 20px; +} + +.modal-label { + font-size: 12px; + color: var(--t2); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 2px; +} + +.modal-radio-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; + color: var(--t1); + user-select: none; +} +/* Override global input styles that break radio button layout */ +.modal-radio-label input[type="radio"] { + width: auto; + min-width: 0; + padding: 0; + background: none; + border: none; + border-radius: 0; + flex-shrink: 0; +} + +.modal-subsection { + margin-left: 24px; + display: flex; + flex-direction: column; + gap: 6px; +} +.modal-subsection input, +.modal-subsection select { + font-size: 14px; + padding: 6px 10px; +} + +.modal-hint { + font-size: 12px; + color: var(--t3); +} + +.modal-actions { + display: flex; + gap: 8px; + margin-top: 6px; +} diff --git a/js/analysis.js b/js/analysis.js index 17a1f0f..73ac170 100644 --- a/js/analysis.js +++ b/js/analysis.js @@ -654,6 +654,9 @@ 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 = { @@ -672,6 +675,17 @@ 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 ?? '', + }; + }, reset() { lastFightId = null; refEvents = []; @@ -685,6 +699,12 @@ refExtFightSelect.value = ''; refExtFightSelect.style.display = 'none'; refExtPanel.style.display = 'none'; + const exportBtn = document.getElementById('export-to-planner-btn'); + if (exportBtn) exportBtn.style.display = 'none'; }, }; + + document.getElementById('export-to-planner-btn')?.addEventListener('click', () => { + window.plannerTab?.showImportModal(window.analysisTab.exportForPlanner()); + }); })(); diff --git a/js/planner.js b/js/planner.js index c7549e5..717d811 100644 --- a/js/planner.js +++ b/js/planner.js @@ -219,9 +219,14 @@ function renderMechanicList(plan) {
${m.assignments.length === 0 ? 'Keine Zuweisung' - : m.assignments.map(a => - `${escHtml(a.job)} · ${escHtml(a.ability)}` - ).join('') + : m.assignments.map(a => { + const cls = a.buffType === 'debuff' ? 'badge-assign-debuff' + : a.buffType === 'shield' ? 'badge-assign-shield' + : a.buffType === 'buff' ? 'badge-assign-buff' + : ''; + const label = a.job ? `${escHtml(a.job)} · ${escHtml(a.ability)}` : escHtml(a.ability); + return `${label}`; + }).join('') }
${m.notes ? `
${escHtml(m.notes)}
` : ''} @@ -306,6 +311,220 @@ function initNewPlanForm() { }); } +// ── Ability → Job mapping ───────────────────────────────────────────────────── + +const ABILITY_JOB_MAP = { + 'Passage of Arms': 'PLD', 'Divine Veil': 'PLD', 'Guardian': 'PLD', + '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', 'Haima': 'SGE', + 'Eukrasian Prognosis': 'SGE', 'Eukrasian Prognosis II': 'SGE', + 'Eukrasian Diagnosis': 'SGE', 'Differential Diagnosis': 'SGE', + 'Troubadour': 'BRD', + 'Tactician': 'MCH', + 'Shield Samba': 'DNC', 'Improvised Finish': 'DNC', + 'Radiant Aegis': 'SMN', + 'Magick Barrier': 'RDM', + 'Tempera Coat': 'PCT', 'Tempera Grassa': 'PCT', +}; + +const JOB_FROM_TYPE = { + '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', +}; + +const TANK_JOBS = new Set(['PLD', 'WAR', 'DRK', 'GNB']); +const MELEE_JOBS = new Set(['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR']); +const CASTER_JOBS = new Set(['BLM', 'SMN', 'RDM', 'PCT']); + +function guessJob(abilityName, players) { + if (ABILITY_JOB_MAP[abilityName]) return ABILITY_JOB_MAP[abilityName]; + const jobs = (players ?? []).map(p => JOB_FROM_TYPE[p.type] ?? ''); + if (abilityName === 'Reprisal') { + const tanks = jobs.filter(j => TANK_JOBS.has(j)); + return tanks.length === 1 ? tanks[0] : ''; + } + if (abilityName === 'Feint') { + const melees = jobs.filter(j => MELEE_JOBS.has(j)); + return melees.length === 1 ? melees[0] : ''; + } + if (abilityName === 'Addle') { + const casters = jobs.filter(j => CASTER_JOBS.has(j)); + return casters.length === 1 ? casters[0] : ''; + } + return ''; +} + +// ── AoE Events → Plan Mechanics ─────────────────────────────────────────────── + +function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations) { + return aoeEvents.map(ev => { + const relTs = ev.timestamp - fightStart; + const phase = (phases ?? []).filter(p => p.id !== 0).find(p => + ev.timestamp >= p.startTime && ev.timestamp < p.endTime + ); + + const targetsWithData = ev.targets.filter(t => (t.unmitigatedAmount ?? 0) > 0); + const avgUnmit = targetsWithData.length > 0 + ? Math.round(targetsWithData.reduce((s, t) => s + t.unmitigatedAmount, 0) / targetsWithData.length) + : 0; + + let assignments = []; + if (withMitigations) { + const seen = new Set(); + for (const t of ev.targets) { + for (const m of (t.mitigations ?? [])) { + const key = m.key ?? m.name; + if (!seen.has(key)) { + seen.add(key); + assignments.push({ ability: key, job: guessJob(key, players), buffType: m.buffType ?? '' }); + } + } + } + } + + return { + id: crypto.randomUUID(), + name: ev.abilityName, + timestamp: relTs, + phase: phase?.name ?? '', + unmitigatedDamage: avgUnmit, + notes: '', + assignments, + }; + }); +} + +// ── Merge + Create plan from import ────────────────────────────────────────── + +function doImport(data, withMitigations, whereMode, mergeId, newName) { + const { aoeEvents, fightStart, phases, players, fightName, reportCode } = data; + const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations); + + if (whereMode === 'new') { + const plan = createPlan(newName || fightName || 'Importierter Plan'); + return updatePlan(plan.id, { + mechanics, + source: { reportCode, fightName }, + }); + } + + // Merge into existing plan + const plan = getPlan(mergeId); + if (!plan) return null; + + const merged = [...plan.mechanics]; + for (const newM of mechanics) { + const exists = plan.mechanics.some(m => + m.name === newM.name && Math.abs(m.timestamp - newM.timestamp) < 5000 + ); + if (!exists) merged.push(newM); + } + merged.sort((a, b) => a.timestamp - b.timestamp); + + return updatePlan(mergeId, { mechanics: merged }); +} + +// ── Import Modal ────────────────────────────────────────────────────────────── + +let pendingImportData = null; + +function showImportModal(data) { + pendingImportData = data; + + // Pre-fill name + const nameInput = document.getElementById('import-plan-name'); + if (nameInput) { nameInput.value = data.fightName || ''; } + + // Populate merge dropdown + const planSelect = document.getElementById('import-plan-select'); + if (planSelect) { + const plans = loadPlans(); + planSelect.innerHTML = ''; + plans.forEach(p => { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.name; + planSelect.appendChild(opt); + }); + const mergeLabel = document.getElementById('import-merge-label'); + if (mergeLabel) mergeLabel.style.opacity = plans.length === 0 ? '0.4' : ''; + const mergeRadio = document.querySelector('input[name="import-where"][value="merge"]'); + if (mergeRadio) mergeRadio.disabled = plans.length === 0; + } + + // Reset to defaults + document.querySelectorAll('input[name="import-what"]').forEach(r => { r.checked = r.value === 'mechanics'; }); + document.querySelectorAll('input[name="import-where"]').forEach(r => { r.checked = r.value === 'new'; }); + document.getElementById('import-new-section').style.display = ''; + document.getElementById('import-merge-section').style.display = 'none'; + + document.getElementById('planner-import-modal').style.display = 'flex'; + nameInput?.focus(); + nameInput?.select(); +} + +function hideImportModal() { + document.getElementById('planner-import-modal').style.display = 'none'; + pendingImportData = null; +} + +function initImportModal() { + document.querySelectorAll('input[name="import-where"]').forEach(radio => { + radio.addEventListener('change', () => { + document.getElementById('import-new-section').style.display = radio.value === 'new' ? '' : 'none'; + document.getElementById('import-merge-section').style.display = radio.value === 'merge' ? '' : 'none'; + }); + }); + + document.getElementById('import-cancel-btn')?.addEventListener('click', hideImportModal); + + document.getElementById('planner-import-modal')?.addEventListener('click', e => { + if (e.target === e.currentTarget) hideImportModal(); + }); + + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && pendingImportData) hideImportModal(); + }); + + document.getElementById('import-confirm-btn')?.addEventListener('click', () => { + if (!pendingImportData) return; + + const withMitigations = document.querySelector('input[name="import-what"]:checked')?.value === 'with-mitigations'; + const whereMode = document.querySelector('input[name="import-where"]:checked')?.value ?? 'new'; + const newName = document.getElementById('import-plan-name')?.value.trim(); + const mergeId = document.getElementById('import-plan-select')?.value; + + if (whereMode === 'new' && !newName) { + document.getElementById('import-plan-name')?.focus(); + return; + } + if (whereMode === 'merge' && !mergeId) { + document.getElementById('import-plan-select')?.focus(); + return; + } + + const plan = doImport(pendingImportData, withMitigations, whereMode, mergeId, newName); + if (!plan) return; + + hideImportModal(); + renderPlanList(); + openPlan(plan.id); + window.showTab?.('planner'); + }); +} + // ── window.plannerTab (hooks for other tabs) ────────────────────────────────── window.plannerTab = { @@ -318,16 +537,20 @@ window.plannerTab = { } }, + showImportModal(data) { + showImportModal(data); + }, + importFromAnalysis(aoeEvents, _refEvents, _options) { - // Schritt 3 — noch nicht implementiert - console.log('[Planner] importFromAnalysis — not yet implemented', aoeEvents); - } + console.log('[Planner] importFromAnalysis — use showImportModal instead', aoeEvents); + }, }; // ── Init ────────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { initNewPlanForm(); + initImportModal(); renderPlanList(); renderPlanDetail(null); }); diff --git a/js/tabs.js b/js/tabs.js index 1f0dd88..6388c61 100644 --- a/js/tabs.js +++ b/js/tabs.js @@ -17,4 +17,5 @@ document.addEventListener('DOMContentLoaded', () => { } tabs.forEach(btn => btn.addEventListener('click', () => showTab(btn.dataset.tab))); + window.showTab = showTab; }); diff --git a/templates/page.php b/templates/page.php index 5210e42..d119e8d 100644 --- a/templates/page.php +++ b/templates/page.php @@ -35,6 +35,51 @@ + + + diff --git a/templates/tab-analysis.php b/templates/tab-analysis.php index b0a7d53..dfb6d96 100644 --- a/templates/tab-analysis.php +++ b/templates/tab-analysis.php @@ -39,6 +39,7 @@
AoE Timeline
+