// ── Storage ─────────────────────────────────────────────────────────────────── const PLANNER_KEY = 'ff14-planner-plans'; const PLANNER_ACTIVE_KEY = 'ff14-planner-active-plan'; const FOLDERS_KEY = 'ff14-planner-folders'; function loadPlans() { try { return JSON.parse(localStorage.getItem(PLANNER_KEY) || '[]'); } catch { return []; } } function savePlans(plans) { localStorage.setItem(PLANNER_KEY, JSON.stringify(plans)); } function loadFolders() { try { return JSON.parse(localStorage.getItem(FOLDERS_KEY) || '[]'); } catch { return []; } } function saveFolders(f) { localStorage.setItem(FOLDERS_KEY, JSON.stringify(f)); } function createFolder(name) { const folder = { id: crypto.randomUUID(), name: name.trim() || 'Neuer Ordner', createdAt: Date.now(), updatedAt: Date.now() }; const all = loadFolders(); all.push(folder); saveFolders(all); return folder; } function updateFolder(id, changes) { const all = loadFolders(), idx = all.findIndex(f => f.id === id); if (idx === -1) return; all[idx] = { ...all[idx], ...changes, updatedAt: Date.now() }; saveFolders(all); } function deleteFolder(id) { saveFolders(loadFolders().filter(f => f.id !== id)); savePlans(loadPlans().map(p => p.folderId === id ? { ...p, folderId: null, updatedAt: Date.now() } : p)); } // ── CRUD ────────────────────────────────────────────────────────────────────── function createPlan(name) { const plan = { id: crypto.randomUUID(), name: name.trim() || 'Unbenannter Plan', createdAt: Date.now(), updatedAt: Date.now(), source: null, mitigationNames: {}, folderId: null, jobComposition: Array(8).fill(''), mechanics: [] }; const all = loadPlans(); all.push(plan); savePlans(all); return plan; } function getPlan(id) { return loadPlans().find(p => p.id === id) ?? null; } function updatePlan(id, changes) { const all = loadPlans(); const idx = all.findIndex(p => p.id === id); if (idx === -1) return null; all[idx] = { ...all[idx], ...changes, updatedAt: Date.now() }; savePlans(all); return all[idx]; } function deletePlan(id) { savePlans(loadPlans().filter(p => p.id !== id)); } function uniquePlanName(baseName) { const names = new Set(loadPlans().map(p => p.name)); if (!names.has(baseName)) return baseName; let i = 2; while (names.has(`${baseName} ${i}`)) i++; return `${baseName} ${i}`; } function copyPlan(id) { const all = loadPlans(); const orig = all.find(p => p.id === id); if (!orig) return null; const copy = { ...JSON.parse(JSON.stringify(orig)), id: crypto.randomUUID(), name: uniquePlanName(orig.name + ' (Kopie)'), createdAt: Date.now(), updatedAt: Date.now() }; all.push(copy); savePlans(all); return copy; } // ── State ───────────────────────────────────────────────────────────────────── let activePlanId = null; let collapsedFolders = new Set(); let selectedTimelineAssignment = null; let actionMetaPromise = null; let actionMetaById = {}; let actionMetaByName = {}; // ── Helpers ─────────────────────────────────────────────────────────────────── function escHtml(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function fmtDate(ts) { return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); } function fmtTimestamp(ms) { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; } 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'; } async function ensureActionMetaLoaded() { if (actionMetaPromise) return actionMetaPromise; actionMetaPromise = (async () => { let compact = {}; let full = {}; try { const res = await fetch('assets/jsons/Action.json', { cache: 'no-cache' }); if (res.ok) compact = await res.json(); } catch { } try { const res = await fetch('assets/mitigation-actions.json', { cache: 'no-cache' }); if (res.ok) full = await res.json(); } catch { } const byId = {}; const byName = {}; for (const [id, action] of Object.entries(full ?? {})) { const description = action.Description_en ?? ''; const durations = [...description.matchAll(/Duration:\s*(\d+)s/gi)].map(m => parseInt(m[1], 10)); const meta = { id, name: action.Name_en ?? '', cast: (parseInt(compact?.[id]?.cast ?? action.Cast100ms ?? 0, 10) || 0) / 10, recast: (parseInt(compact?.[id]?.recast ?? action.Recast100ms ?? 0, 10) || 0) / 10, duration: durations.find(Number.isFinite) ?? 15, }; byId[id] = meta; if (meta.name) byName[meta.name] = meta; } for (const [id, action] of Object.entries(compact ?? {})) { byId[id] = { ...(byId[id] ?? { id }), cast: (parseInt(action.cast ?? 0, 10) || 0) / 10, recast: (parseInt(action.recast ?? 0, 10) || 0) / 10, }; } actionMetaById = byId; actionMetaByName = byName; })(); return actionMetaPromise; } 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; } function visiblePlanMechanics(plan) { return [...(plan?.mechanics ?? [])] .filter(m => m?.id && String(m.name ?? '').trim() !== '' && Number.isFinite(Number(m.timestamp))) .sort((a, b) => a.timestamp - b.timestamp); } // ── Rendering: Plan List ────────────────────────────────────────────────────── function planItemHtml(p) { return `
${escHtml(p.name)}
${p.mechanics.length} Mechaniken · ${fmtDate(p.updatedAt)}
`; } function renderPlanList() { const el = document.getElementById('plan-list'); if (!el) return; const plans = loadPlans(); const folders = loadFolders(); if (plans.length === 0 && folders.length === 0) { el.innerHTML = '
Noch keine Pläne vorhanden
'; return; } const byFolder = new Map(); const ungrouped = []; for (const p of plans) { const fid = p.folderId ?? null; if (fid && folders.some(f => f.id === fid)) { if (!byFolder.has(fid)) byFolder.set(fid, []); byFolder.get(fid).push(p); } else { ungrouped.push(p); } } let html = folders.map(folder => { const folderPlans = byFolder.get(folder.id) ?? []; const collapsed = collapsedFolders.has(folder.id); return `
${escHtml(folder.name)} ${folderPlans.length}
${folderPlans.map(p => planItemHtml(p)).join('') || '
Leer
'}
`; }).join('') + ungrouped.map(p => planItemHtml(p)).join(''); el.innerHTML = html || '
Noch keine Pläne vorhanden
'; // Plan item: open el.querySelectorAll('.plan-item').forEach(row => { row.addEventListener('click', e => { if (e.target.closest('.plan-item-actions')) return; openPlan(row.dataset.id); }); }); // Plan item: move to folder el.querySelectorAll('.plan-btn-move').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); showFolderPicker(btn.dataset.id, btn); }); }); // Plan item: copy el.querySelectorAll('.plan-btn-copy').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const copy = copyPlan(btn.dataset.id); if (copy) { renderPlanList(); openPlan(copy.id); } }); }); // Plan item: delete el.querySelectorAll('.plan-btn-delete').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const plan = getPlan(btn.dataset.id); if (!plan) return; if (!confirm(`Plan "${plan.name}" löschen?`)) return; deletePlan(btn.dataset.id); if (activePlanId === btn.dataset.id) { activePlanId = null; renderPlanDetail(null); } renderPlanList(); }); }); // Folder row: toggle collapse el.querySelectorAll('.folder-row').forEach(row => { row.addEventListener('click', e => { if (e.target.closest('.folder-actions')) return; const fid = row.dataset.folderId; if (collapsedFolders.has(fid)) collapsedFolders.delete(fid); else collapsedFolders.add(fid); renderPlanList(); }); }); // Folder: rename el.querySelectorAll('.folder-rename-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const fid = btn.dataset.id; const folder = loadFolders().find(f => f.id === fid); if (!folder) return; const span = el.querySelector(`.folder-row[data-folder-id="${fid}"] .folder-name-text`); if (!span) return; const input = document.createElement('input'); input.className = 'folder-name-input'; input.value = folder.name; span.replaceWith(input); input.focus(); input.select(); let saved = false; const save = () => { if (saved) return; saved = true; const name = input.value.trim(); if (name) updateFolder(fid, { name }); renderPlanList(); }; input.addEventListener('blur', save); input.addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); save(); } if (ev.key === 'Escape') { saved = true; renderPlanList(); } }); }); }); // Folder: delete el.querySelectorAll('.folder-delete-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const folder = loadFolders().find(f => f.id === btn.dataset.id); if (!folder) return; const count = (byFolder.get(btn.dataset.id) ?? []).length; const msg = count > 0 ? `Ordner "${folder.name}" löschen? Die ${count} enthaltenen Pläne werden nicht gelöscht.` : `Ordner "${folder.name}" löschen?`; if (!confirm(msg)) return; deleteFolder(btn.dataset.id); renderPlanList(); }); }); } // ── Rendering: Plan Detail ──────────────────────────────────────────────────── function renderPlanDetail(plan) { const noplan = document.getElementById('planner-no-plan'); const content = document.getElementById('plan-content'); if (!noplan || !content) return; if (!plan) { noplan.style.display = ''; content.style.display = 'none'; renderInfoPanel(null); return; } noplan.style.display = 'none'; content.style.display = ''; const visibleMechanics = visiblePlanMechanics(plan); content.innerHTML = `
${escHtml(plan.name)}
Erstellt ${fmtDate(plan.createdAt)} · ${visibleMechanics.length} Mechaniken
Jobaufstellung
${renderJobSlotsHtml(plan)}
Zeitstrahl
Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten
${renderTimelineHtml(plan)}
${renderTimelineSettingsHtml(plan)}
Mechaniken
${renderMechanicListHtml(plan)}
`; document.getElementById('plan-name-edit-btn')?.addEventListener('click', () => { startRename(plan.id, plan.name); }); initJobSlots(plan.id); document.getElementById('name-import-open-btn')?.addEventListener('click', () => { showNameImportModal(plan.id); }); initTimeline(plan.id); initMechanicClicks(plan.id); renderInfoPanel(plan); ensureActionMetaLoaded().then(() => refreshTimeline(plan.id)); } function avgNonTankMaxHp(plan) { const roster = plan.playerRoster ?? []; const jobComp = plan.jobComposition ?? []; const hps = jobComp .map((job, i) => ({ job, maxHp: roster[i]?.maxHp ?? 0 })) .filter(p => p.job && JOB_ROLE[p.job] !== 'tank' && p.maxHp > 0) .map(p => p.maxHp); if (!hps.length) return 0; return Math.round(hps.reduce((s, v) => s + v, 0) / hps.length); } function simulateDrMultiplier(mechanic, assignments = mechanic.assignments ?? []) { let mult = 1; for (const a of assignments) { if (a.buffType === 'shield') continue; mult *= (1 - (ABILITY_DR[a.ability] ?? 0)); } return mult; } function plannedAssignmentsForMechanic(plan, targetMechanic) { const result = []; const targetTime = Number(targetMechanic.timestamp); const tolerance = 50; for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: canonicalMechanicKey })) { if (targetTime < entry.start - tolerance || targetTime > entry.end + tolerance) continue; result.push({ ...entry.assignment, sourceMechanicId: entry.mechanic.id, sourceStart: entry.start, }); } return result; } function renderMechanicListHtml(plan) { const mechanics = visiblePlanMechanics(plan); if (mechanics.length === 0) { return `
📋

Noch keine Mechaniken

Importiere einen Log aus dem Analyse-Tab

`; } const activeJobSet = new Set(plan.jobComposition.filter(j => j)); const avgHp = avgNonTankMaxHp(plan); return mechanics.map(m => { const planned = plannedAssignmentsForMechanic(plan, m); const sorted = sortedAssignments(planned); const assignHtml = sorted.length === 0 ? 'Keine Zuweisung' : sorted.map(a => { const cls = a.buffType === 'debuff' ? 'badge-assign-debuff' : a.buffType === 'shield' ? 'badge-assign-shield' : 'badge-assign-buff'; const isMissing = !!a.job && !activeJobSet.has(a.job); const icon = MITIG_ICONS[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 = ` ${icon ? `` : ''} ${label} `; const hintHtml = suggestions.map(s => `→ ${escHtml(s.ability)} (${escHtml(s.job)})?` ).join(''); const noEquivHint = isMissing && suggestions.length === 0 ? `→ Kein Äquivalent!` : ''; const jobHint = !a.job ? `→ Job zuordnen` : ''; const needsWrap = suggestions.length > 0 || !!noEquivHint || !!jobHint; return needsWrap ? `${badgeHtml}${hintHtml}${noEquivHint}${jobHint}` : badgeHtml; }).join(''); const drOnly = m.unmitigatedDamage ? Math.round(m.unmitigatedDamage * simulateDrMultiplier(m, planned)) : 0; const shieldVal = (plan.shieldK ?? 0) * 1000; const mitigFull = Math.max(0, drOnly - shieldVal); const hasDrAssign = planned.some(a => a.buffType !== 'shield' && (ABILITY_DR[a.ability] ?? 0) > 0); const hasShield = shieldVal > 0; const drOnlyCls = avgHp ? (drOnly <= avgHp ? 'mechanic-mitig--ok' : 'mechanic-mitig--risk') : ''; const fullCls = avgHp ? (mitigFull <= avgHp ? 'mechanic-mitig--ok' : 'mechanic-mitig--risk') : ''; return `
${escHtml(fmtTimestamp(m.timestamp))}
${m.phase ? `
${escHtml(m.phase)}
` : ''}
${escHtml(m.name)}
${m.unmitigatedDamage ? `
${fmtNumber(m.unmitigatedDamage)} unmitigiert${avgHp ? ` ∅ ${fmtNumber(avgHp)} HP` : ''}
` : '' } ${hasDrAssign || hasShield ? `
${hasDrAssign ? `→ ${fmtNumber(drOnly)} mitigiert` : ''} ${hasShield ? `Mitigation mit Schild ${fmtNumber(mitigFull)}` : ''}
` : ''}
${assignHtml}
${m.notes ? `
${escHtml(m.notes)}
` : ''}
Klicken zum Bearbeiten
`; }).join(''); } // ── Job Slots ───────────────────────────────────────────────────────────────── function renderJobSlotsHtml(plan) { const roster = plan.playerRoster ?? []; return Array.from({ length: 8 }, (_, i) => { const job = plan.jobComposition[i] ?? ''; const role = JOB_ROLE[job] ?? ''; const playerName = roster[i]?.name ?? ''; return `
${playerName ? `
${escHtml(playerName)}
` : ''}
`; }).join(''); } function initJobSlots(planId) { const grid = document.getElementById('job-slots-grid'); if (!grid) return; grid.addEventListener('change', e => { const sel = e.target.closest('.job-slot-select'); if (!sel) return; const plan = getPlan(planId); if (!plan) return; const comp = [...plan.jobComposition]; comp[parseInt(sel.dataset.idx, 10)] = sel.value; updatePlan(planId, { jobComposition: comp }); const slot = sel.closest('.job-slot'); if (slot) { const role = JOB_ROLE[sel.value] ?? ''; slot.className = 'job-slot' + (role ? ` job-slot--${role}` : ''); } refreshMechanicList(planId); }); } // ── Timeline ───────────────────────────────────────────────────────────────── function planDurationMs(plan) { const sourceDuration = (plan.source?.fightEnd ?? 0) - (plan.source?.fightStart ?? 0); const mechanicEnd = Math.max(0, ...visiblePlanMechanics(plan).map(m => (m.timestamp ?? 0) + 30000)); return Math.max(sourceDuration, mechanicEnd, 60000); } function timelineScale(plan) { const duration = planDurationMs(plan); const pxPerSecond = 8; return { duration, width: Math.max(720, Math.ceil(duration / 1000 * pxPerSecond)) }; } function timelinePlayerRows(plan) { const roster = plan.playerRoster ?? []; const rows = []; (plan.jobComposition ?? []).forEach((job, idx) => { if (!job) return; const name = roster[idx]?.name ?? ''; const role = JOB_ROLE[job] ?? ''; const abilities = (JOB_ABILITIES[job] ?? []) .filter(ab => ab.buffType !== 'shield' || ab.name === 'Panhaima'); abilities.forEach((ab, abilityIdx) => { rows.push({ idx, job, ability: ab.name, buffType: ab.buffType, name, role, firstForJob: abilityIdx === 0 }); }); }); return rows; } function selectedAssignmentMatches(mechanicId, assignment) { return selectedTimelineAssignment && selectedTimelineAssignment.mechanicId === mechanicId && selectedTimelineAssignment.ability === assignment.ability && selectedTimelineAssignment.job === (assignment.job ?? ''); } function jobCanUseAbility(job, ability) { return (JOB_ABILITIES[job] ?? []).some(a => a.name === ability); } function timelineRowsForAssignment(rows, assignment) { const assignedJob = assignment.job ?? ''; return rows.filter(row => { if (row.ability !== assignment.ability) return false; if (assignedJob) return row.job === assignedJob; return true; // unresolved: show in all rows for this ability }); } function assignmentStartMs(mechanic, assignment) { return Number.isFinite(Number(assignment?.timestamp)) ? Number(assignment.timestamp) : mechanic.timestamp; } function canonicalMechanicKey(entry) { return `${entry.assignment.ability}::${entry.assignment.job || '*'}`; } function canonicalTimelineKey(entry, row) { return `${row.job}::${row.ability}`; } function canonicalAssignmentActivations(plan, { dedupeKey = canonicalMechanicKey, includeEntry = null } = {}) { const entries = []; for (const mechanic of visiblePlanMechanics(plan)) { for (const assignment of mechanic.assignments ?? []) { const start = assignmentStartMs(mechanic, assignment); const durationSec = assignmentDurationSeconds(assignment); entries.push({ mechanic, assignment, start, durationSec, end: start + durationSec * 1000, }); } } entries.sort((a, b) => a.start - b.start); const activeUntilBySkill = new Map(); return entries.filter(entry => { if (includeEntry && !includeEntry(entry)) return false; const key = dedupeKey(entry); const activeUntil = activeUntilBySkill.get(key) ?? -Infinity; if (entry.start <= activeUntil) return false; activeUntilBySkill.set(key, entry.end); return true; }); } function assignmentEntryForRef(plan, ref) { if (!plan || !ref) return null; const mechanic = (plan.mechanics ?? []).find(m => m.id === ref.mechanicId); if (!mechanic) return null; const assignment = (mechanic.assignments ?? []).find(a => a.ability === ref.ability && (a.job ?? '') === ref.job ); if (!assignment) return null; const start = assignmentStartMs(mechanic, assignment); return { mechanic, assignment, start, end: start + assignmentWindowMs(assignment), }; } function assignmentRowKeys(plan, assignment) { const assignedJob = assignment.job ?? ''; if (assignedJob) return new Set([`${assignedJob}::${assignment.ability}`]); const jobs = (plan.jobComposition ?? []).filter(job => jobCanUseAbility(job, assignment.ability)); return new Set(jobs.map(job => `${job}::${assignment.ability}`)); } function assignmentEntriesShareRow(plan, left, right) { const leftRows = assignmentRowKeys(plan, left.assignment); const rightRows = assignmentRowKeys(plan, right.assignment); for (const row of leftRows) { if (rightRows.has(row)) return true; } return false; } function assignmentsOverlapActiveFrame(plan, left, right) { return left.assignment.ability === right.assignment.ability && assignmentEntriesShareRow(plan, left, right) && left.start < right.end && left.end > right.start; } function compactActivationCopies(plan, keeperRef) { const keeper = assignmentEntryForRef(plan, keeperRef); if (!keeper) return false; let changed = false; for (const mechanic of visiblePlanMechanics(plan)) { const assignments = mechanic.assignments ?? []; const next = assignments.filter(assignment => { if (assignment === keeper.assignment) return true; if (assignment.ability !== keeper.assignment.ability) return true; const start = assignmentStartMs(mechanic, assignment); const entry = { mechanic, assignment, start, end: start + assignmentWindowMs(assignment), }; const remove = assignmentsOverlapActiveFrame(plan, keeper, entry); if (remove) changed = true; return !remove; }); if (next.length !== assignments.length) mechanic.assignments = next; } return changed; } function removeActivationGroup(plan, ref) { const keeper = assignmentEntryForRef(plan, ref); if (!keeper) return false; let changed = false; for (const mechanic of visiblePlanMechanics(plan)) { const assignments = mechanic.assignments ?? []; const next = assignments.filter(assignment => { if (assignment.ability !== keeper.assignment.ability) return true; const start = assignmentStartMs(mechanic, assignment); const entry = { mechanic, assignment, start, end: start + assignmentWindowMs(assignment), }; const remove = assignment === keeper.assignment || assignmentsOverlapActiveFrame(plan, keeper, entry); if (remove) changed = true; return !remove; }); if (next.length !== assignments.length) mechanic.assignments = next; } return changed; } function normalizeActivationCopies(plan) { const entries = []; for (const mechanic of visiblePlanMechanics(plan)) { for (const assignment of mechanic.assignments ?? []) { const start = assignmentStartMs(mechanic, assignment); entries.push({ mechanic, assignment, start, end: start + assignmentWindowMs(assignment), }); } } entries.sort((a, b) => a.start - b.start); const keepers = []; const removals = new Set(); for (const entry of entries) { if (keepers.some(keeper => assignmentsOverlapActiveFrame(plan, keeper, entry))) { removals.add(entry.assignment); } else { keepers.push(entry); } } if (!removals.size) return false; for (const mechanic of visiblePlanMechanics(plan)) { const assignments = mechanic.assignments ?? []; const next = assignments.filter(assignment => !removals.has(assignment)); if (next.length !== assignments.length) mechanic.assignments = next; } return true; } function findNearestMechanic(plan, timestamp) { const mechanics = visiblePlanMechanics(plan); if (!mechanics.length) return null; return mechanics.reduce((best, mechanic) => Math.abs(mechanic.timestamp - timestamp) < Math.abs(best.timestamp - timestamp) ? mechanic : best , mechanics[0]); } function timelineTimestampFromEvent(plan, track, event) { const rect = track.getBoundingClientRect(); const x = Math.max(0, Math.min(rect.width, event.clientX - rect.left)); return (x / rect.width) * planDurationMs(plan); } function layoutBossActions(mechanics, duration) { const lanes = []; const minGapPct = 9; return mechanics.map(mechanic => { const left = (mechanic.timestamp / duration) * 100; let lane = lanes.findIndex(lastLeft => left - lastLeft >= minGapPct); if (lane === -1) { lane = lanes.length; lanes.push(left); } else { lanes[lane] = left; } return { mechanic, left, lane }; }); } function assignmentWindowMs(assignment) { return Math.max(1, assignmentDurationSeconds(assignment)) * 1000; } function sameAssignmentRef(mechanic, assignment, ref) { return !!ref && ref.mechanicId === mechanic.id && ref.ability === assignment.ability && ref.job === (assignment.job ?? ''); } function assignmentOverlapsJob(plan, job, ability, timestamp, ignore = null, candidate = null) { const candidateWindow = Math.max(candidate ? assignmentDurationSeconds(candidate) : 0, 1) * 1000; const candidateStart = Math.max(0, timestamp); const candidateEnd = candidateStart + candidateWindow; const ignoredActivation = assignmentEntryForRef(plan, ignore); for (const mechanic of visiblePlanMechanics(plan)) { for (const assignment of mechanic.assignments ?? []) { if (assignment.ability !== ability) continue; if ((assignment.job ?? '') !== job && !(!(assignment.job ?? '') && jobCanUseAbility(job, ability))) continue; if (sameAssignmentRef(mechanic, assignment, ignore)) continue; const start = assignmentStartMs(mechanic, assignment); const end = start + assignmentWindowMs(assignment); if (ignoredActivation) { const entry = { mechanic, assignment, start, end }; if (assignmentsOverlapActiveFrame(plan, ignoredActivation, entry)) continue; } if (candidateStart < end && candidateEnd > start) return true; } } return false; } function actionMetaForAssignment(assignment) { const id = String(assignment?.actionId ?? assignment?.extraAbilityGameID ?? ''); return (id && actionMetaById[id]) || actionMetaByName[assignment?.ability] || null; } function assignmentCooldownSeconds(assignment) { const own = Number(assignment?.cooldownSeconds); if (Number.isFinite(own) && own >= 0) return own; return actionMetaForAssignment(assignment)?.recast ?? 0; } function assignmentDurationSeconds(assignment) { const own = Number(assignment?.durationSeconds); if (Number.isFinite(own) && own > 0) return own; return actionMetaForAssignment(assignment)?.duration ?? (assignment?.buffType === 'shield' ? 20 : 15); } function renderTimelineHtml(plan) { const mechanics = visiblePlanMechanics(plan); if (!mechanics.length) { return '
Importiere Mechaniken aus dem Analyse-Tab, um den Zeitstrahl zu nutzen.
'; } const { duration, width } = timelineScale(plan); const rows = timelinePlayerRows(plan); const marks = []; const tick = 10000; for (let t = 0; t <= duration; t += tick) { marks.push(`${fmtTimestamp(t)}`); } const bossActions = layoutBossActions(mechanics, duration); const laneCount = Math.max(1, ...bossActions.map(item => item.lane + 1)); const hitLines = mechanics.map(m => ` `).join(''); const bossItems = bossActions.map(({ mechanic: m, left, lane }) => ` `).join(''); const playerRows = rows.map(row => { const blocks = []; for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: item => canonicalTimelineKey(item, row), includeEntry: item => { const assignment = item.assignment; if (assignment.ability !== row.ability) return false; const assignedJob = assignment.job ?? ''; return !assignedJob || assignedJob === row.job; }, })) { const item = entry; const m = item.mechanic; const a = item.assignment; const start = item.start; const durationSec = item.durationSec; const cooldownSec = assignmentCooldownSeconds(a); const left = Math.max(0, Math.min(100, (start / duration) * 100)); const widthPct = Math.max(1.2, Math.min(100 - left, (durationSec * 1000 / duration) * 100)); const cdWidthPct = cooldownSec > 0 ? Math.max(widthPct, Math.min(100 - left, (cooldownSec * 1000 / duration) * 100)) : widthPct; const activeWidthPct = Math.min(100, (widthPct / cdWidthPct) * 100); const selected = selectedAssignmentMatches(m.id, a) ? ' selected' : ''; const unresolved = a.job ? '' : ' timeline-mitigation--candidate'; const cls = a.buffType === 'debuff' ? 'timeline-mitigation--debuff' : a.buffType === 'shield' ? 'timeline-mitigation--shield' : 'timeline-mitigation--buff'; const icon = MITIG_ICONS[a.ability] ?? ''; const abilityLabel = assignmentAbilityName(a, plan); blocks.push(` `); } const icon = MITIG_ICONS[row.ability] ?? ''; const abilityDisplayName = plan.mitigationNames?.[row.ability] ?? row.ability; const jobStartCls = row.firstForJob ? ' timeline-player-row--job-start' : ''; return `
${escHtml(row.job)} ${icon ? `` : ''} ${escHtml(abilityDisplayName)}
${hitLines}${blocks.join('')}
`; }).join(''); return `
Boss
${hitLines}${bossItems}
${playerRows}
${marks.join('')}
`; } function findTimelineAssignment(plan, selected = selectedTimelineAssignment) { if (!plan || !selected) return null; const mechanic = (plan.mechanics ?? []).find(m => m.id === selected.mechanicId); if (!mechanic) return null; const assignment = (mechanic.assignments ?? []).find(a => a.ability === selected.ability && (a.job ?? '') === selected.job ); return assignment ? { mechanic, assignment } : null; } function renderTimelineSettingsHtml(plan) { const found = findTimelineAssignment(plan); if (!found) { return '
Mitigation im Zeitstrahl auswählen, um Dauer und Cooldown anzupassen.
'; } const { mechanic, assignment } = found; const ability = assignmentAbilityName(assignment, plan); const jobLabel = assignment.job || 'Nicht zugewiesen'; return `
${escHtml(jobLabel)} · ${escHtml(ability)}
${escHtml(mechanic.name)} bei ${escHtml(fmtTimestamp(mechanic.timestamp))}
`; } function refreshTimeline(planId) { const plan = getPlan(planId); if (!plan) return; if (normalizeActivationCopies(plan)) updatePlan(planId, { mechanics: plan.mechanics }); const timeline = document.getElementById('planner-timeline'); const settings = document.getElementById('timeline-settings'); if (timeline) timeline.innerHTML = renderTimelineHtml(plan); if (settings) settings.innerHTML = renderTimelineSettingsHtml(plan); } function setTimelineAssignmentField(planId, mechanicId, ability, job, field, value) { const plan = getPlan(planId); if (!plan) return; const mechanic = plan.mechanics.find(m => m.id === mechanicId); const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job); if (!assignment) return; if (field === 'timestamp') assignment.timestamp = Math.max(0, Math.round(Number(value) * 1000)); if (field === 'durationSeconds') assignment.durationSeconds = Math.max(1, Math.round(Number(value))); if (field === 'cooldownSeconds') assignment.cooldownSeconds = Math.max(0, Math.round(Number(value))); updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); } function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) { const plan = getPlan(planId); if (!plan || !jobCanUseAbility(nextJob, ability)) return; const mechanic = plan.mechanics.find(m => m.id === mechanicId); const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job); if (!assignment) return; compactActivationCopies(plan, { mechanicId, ability, job }); const timestamp = assignmentStartMs(mechanic, assignment); if (assignmentOverlapsJob(plan, nextJob, ability, timestamp, { mechanicId, ability, job }, assignment)) return; assignment.job = nextJob; selectedTimelineAssignment = { mechanicId, ability, job: nextJob }; updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); } function removeTimelineAssignment(planId, mechanicId, ability, job) { const plan = getPlan(planId); if (!plan) return; if (!removeActivationGroup(plan, { mechanicId, ability, job })) return; if (selectedTimelineAssignment?.mechanicId === mechanicId && selectedTimelineAssignment?.ability === ability && selectedTimelineAssignment?.job === job) { selectedTimelineAssignment = null; } updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); } function addTimelineAssignment(planId, mechanicId, ability, job, buffType, timestamp) { const plan = getPlan(planId); if (!plan || !jobCanUseAbility(job, ability)) return; const mechanic = plan.mechanics.find(m => m.id === mechanicId); if (!mechanic) return; mechanic.assignments = mechanic.assignments ?? []; const assignment = { ability, abilityName: plan.mitigationNames?.[ability], actionId: actionMetaByName[ability]?.id ?? null, job, buffType, timestamp: Math.max(0, Math.round(timestamp)), }; if (assignmentOverlapsJob(plan, job, ability, assignment.timestamp, null, assignment)) return; mechanic.assignments.push(assignment); selectedTimelineAssignment = { mechanicId, ability, job }; updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); } function closeTimelineMenu() { document.getElementById('timeline-context-menu')?.remove(); } function showTimelineMenu(x, y, items) { closeTimelineMenu(); const menu = document.createElement('div'); menu.id = 'timeline-context-menu'; menu.className = 'timeline-context-menu'; menu.innerHTML = items.length ? items.map((item, idx) => ` `).join('') : '
Keine verfügbare Fähigkeit
'; document.body.appendChild(menu); const rect = menu.getBoundingClientRect(); menu.style.left = Math.min(x, window.innerWidth - rect.width - 8) + 'px'; menu.style.top = Math.min(y, window.innerHeight - rect.height - 8) + 'px'; menu.addEventListener('click', e => { const btn = e.target.closest('.timeline-menu-item'); if (!btn || btn.disabled) return; items[parseInt(btn.dataset.idx, 10)]?.onClick?.(); closeTimelineMenu(); document.removeEventListener('click', closeOutside, true); document.removeEventListener('contextmenu', closeOutside, true); }); const closeOutside = ev => { if (menu.contains(ev.target)) return; closeTimelineMenu(); document.removeEventListener('click', closeOutside, true); document.removeEventListener('contextmenu', closeOutside, true); }; setTimeout(() => { document.addEventListener('click', closeOutside, true); document.addEventListener('contextmenu', closeOutside, true); }, 0); } function updateTimelineAssignmentPosition(planId, mechanicId, ability, job, rowJob, timestamp) { const plan = getPlan(planId); if (!plan) return; const mechanic = plan.mechanics.find(m => m.id === mechanicId); const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job); if (!assignment) return; if (!jobCanUseAbility(rowJob, ability)) return; compactActivationCopies(plan, { mechanicId, ability, job }); const nextTimestamp = Math.max(0, Math.round(timestamp)); if (assignmentOverlapsJob(plan, rowJob, ability, nextTimestamp, { mechanicId, ability, job }, assignment)) return; assignment.timestamp = nextTimestamp; assignment.job = rowJob; selectedTimelineAssignment = { mechanicId, ability, job: rowJob }; updatePlan(planId, { mechanics: plan.mechanics }); refreshTimeline(planId); refreshMechanicList(planId, false); } function initTimeline(planId) { const timeline = document.getElementById('planner-timeline'); const settings = document.getElementById('timeline-settings'); if (!timeline || !settings) return; let timelinePan = null; let suppressNextTimelineClick = false; let timelineDrag = null; function removeDragPreview() { document.getElementById('timeline-drag-preview')?.remove(); timeline.querySelectorAll('.timeline-player-row--drop-ok, .timeline-player-row--drop-bad') .forEach(row => row.classList.remove('timeline-player-row--drop-ok', 'timeline-player-row--drop-bad')); } function updateDragPreview(event) { if (!timelineDrag) return; removeDragPreview(); const track = event.target.closest('.timeline-player-row .timeline-track'); const row = event.target.closest('.timeline-player-row'); if (!track || !row) return; const plan = getPlan(planId); if (!plan) return; const rect = track.getBoundingClientRect(); const duration = planDurationMs(plan); const deltaPx = event.clientX - timelineDrag.startClientX; const timestamp = Math.max(0, timelineDrag.startTimestamp + (deltaPx / rect.width) * duration); const left = Math.max(0, Math.min(100, (timestamp / duration) * 100)); const durationPct = Math.max(1.2, Math.min(100 - left, (timelineDrag.durationSec * 1000 / duration) * 100)); const cooldownPct = Math.max(durationPct, Math.min(100 - left, (timelineDrag.cooldownSec * 1000 / duration) * 100)); const activePct = Math.min(100, (durationPct / cooldownPct) * 100); const valid = row.dataset.ability === timelineDrag.ability && jobCanUseAbility(row.dataset.job, timelineDrag.ability) && !assignmentOverlapsJob(plan, row.dataset.job, timelineDrag.ability, timestamp, { mechanicId: timelineDrag.mechanicId, ability: timelineDrag.ability, job: timelineDrag.job, }, { ability: timelineDrag.ability, job: row.dataset.job, durationSeconds: timelineDrag.durationSec, cooldownSeconds: timelineDrag.cooldownSec, }); row.classList.add(valid ? 'timeline-player-row--drop-ok' : 'timeline-player-row--drop-bad'); const preview = document.createElement('div'); preview.id = 'timeline-drag-preview'; preview.className = `timeline-drag-preview ${valid ? '' : 'timeline-drag-preview--bad'}`; preview.style.left = `${left}%`; preview.style.setProperty('--cd-width', `${cooldownPct}%`); preview.style.setProperty('--active-width', `${activePct}%`); preview.innerHTML = ` ${timelineDrag.icon ? `` : ''} ${escHtml(timelineDrag.label)} `; track.appendChild(preview); } timeline.addEventListener('pointerdown', e => { if (e.button !== 0) return; if (!e.target.closest('.timeline-scroll')) return; if (e.target.closest('.timeline-mitigation, .timeline-boss-action, .timeline-context-menu')) return; const scroll = e.target.closest('.timeline-scroll'); timelinePan = { scroll, pointerId: e.pointerId, startX: e.clientX, startScrollLeft: scroll.scrollLeft, moved: false, }; scroll.setPointerCapture?.(e.pointerId); }); timeline.addEventListener('pointermove', e => { if (!timelinePan || timelinePan.pointerId !== e.pointerId) return; const dx = e.clientX - timelinePan.startX; if (Math.abs(dx) > 3) { timelinePan.moved = true; timelinePan.scroll.classList.add('timeline-scroll--dragging'); timelinePan.scroll.scrollLeft = timelinePan.startScrollLeft - dx; e.preventDefault(); } }); timeline.addEventListener('pointerup', e => { if (!timelinePan || timelinePan.pointerId !== e.pointerId) return; timelinePan.scroll.releasePointerCapture?.(e.pointerId); timelinePan.scroll.classList.remove('timeline-scroll--dragging'); suppressNextTimelineClick = timelinePan.moved; timelinePan = null; if (suppressNextTimelineClick) setTimeout(() => { suppressNextTimelineClick = false; }, 0); }); timeline.addEventListener('pointercancel', e => { if (!timelinePan || timelinePan.pointerId !== e.pointerId) return; timelinePan.scroll.releasePointerCapture?.(e.pointerId); timelinePan.scroll.classList.remove('timeline-scroll--dragging'); timelinePan = null; }); timeline.addEventListener('click', e => { if (suppressNextTimelineClick) { e.preventDefault(); e.stopPropagation(); suppressNextTimelineClick = false; return; } closeTimelineMenu(); const boss = e.target.closest('.timeline-boss-action'); if (boss) { showAbilityModal(planId, boss.dataset.mechanicId); return; } const block = e.target.closest('.timeline-mitigation'); if (block) { selectedTimelineAssignment = { mechanicId: block.dataset.mechanicId, ability: block.dataset.ability, job: block.dataset.job, }; refreshTimeline(planId); const plan = getPlan(planId); const rows = timelinePlayerRows(plan ?? {}); const found = findTimelineAssignment(plan, selectedTimelineAssignment); const timestamp = found ? assignmentStartMs(found.mechanic, found.assignment) : 0; const items = rows .filter(row => jobCanUseAbility(row.job, block.dataset.ability)) .map(row => ({ label: `${row.job}${row.name ? ` · ${row.name}` : ''}`, icon: MITIG_ICONS[block.dataset.ability] ?? '', disabled: assignmentOverlapsJob(plan, row.job, block.dataset.ability, timestamp, { mechanicId: block.dataset.mechanicId, ability: block.dataset.ability, job: block.dataset.job, }, found?.assignment ?? null), onClick: () => setTimelineAssignmentJob(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job, row.job), })); showTimelineMenu(e.clientX, e.clientY, items); return; } const track = e.target.closest('.timeline-player-row .timeline-track'); const row = e.target.closest('.timeline-player-row'); if (!track || !row) return; const plan = getPlan(planId); if (!plan) return; const timestamp = timelineTimestampFromEvent(plan, track, e); const mechanic = findNearestMechanic(plan, timestamp); if (!mechanic) return; const rowAbility = row.dataset.ability; const rowJob = row.dataset.job; const ab = (JOB_ABILITIES[rowJob] ?? []).find(a => a.name === rowAbility); const candidate = ab ? { ability: rowAbility, actionId: actionMetaByName[rowAbility]?.id ?? null, job: rowJob, buffType: ab.buffType, } : null; if (!ab || assignmentOverlapsJob(plan, rowJob, rowAbility, timestamp, null, candidate)) return; addTimelineAssignment(planId, mechanic.id, rowAbility, rowJob, ab.buffType, timestamp); }); timeline.addEventListener('contextmenu', e => { const block = e.target.closest('.timeline-mitigation'); if (!block) return; e.preventDefault(); closeTimelineMenu(); removeTimelineAssignment(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job); }); timeline.addEventListener('dragstart', e => { const block = e.target.closest('.timeline-mitigation'); if (!block) return; const plan = getPlan(planId); const found = findTimelineAssignment(plan, { mechanicId: block.dataset.mechanicId, ability: block.dataset.ability, job: block.dataset.job, }); if (!plan || !found) return; const transparent = document.createElement('canvas'); transparent.width = 1; transparent.height = 1; e.dataTransfer.setDragImage(transparent, 0, 0); timelineDrag = { mechanicId: block.dataset.mechanicId, ability: block.dataset.ability, job: block.dataset.job, label: block.querySelector('span:last-child')?.textContent ?? block.dataset.ability, icon: block.querySelector('img')?.getAttribute('src') ?? '', startClientX: e.clientX, startTimestamp: assignmentStartMs(found.mechanic, found.assignment), durationSec: assignmentDurationSeconds(found.assignment), cooldownSec: assignmentCooldownSeconds(found.assignment), }; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', JSON.stringify({ mechanicId: block.dataset.mechanicId, ability: block.dataset.ability, job: block.dataset.job, startClientX: e.clientX, startTimestamp: assignmentStartMs(found.mechanic, found.assignment), })); }); timeline.addEventListener('dragover', e => { if (e.target.closest('.timeline-player-row .timeline-track')) { e.preventDefault(); updateDragPreview(e); } }); timeline.addEventListener('dragleave', e => { if (!timeline.contains(e.relatedTarget)) removeDragPreview(); }); timeline.addEventListener('drop', e => { removeDragPreview(); const track = e.target.closest('.timeline-player-row .timeline-track'); const row = e.target.closest('.timeline-player-row'); if (!track || !row) return; e.preventDefault(); let data; try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; } const plan = getPlan(planId); if (!plan) return; const rect = track.getBoundingClientRect(); const deltaPx = e.clientX - (Number(data.startClientX) || e.clientX); const timestampDelta = (deltaPx / rect.width) * planDurationMs(plan); const timestamp = (Number(data.startTimestamp) || 0) + timestampDelta; if (row.dataset.ability && data.ability !== row.dataset.ability) return; updateTimelineAssignmentPosition(planId, data.mechanicId, data.ability, data.job, row.dataset.job, timestamp); timelineDrag = null; }); timeline.addEventListener('dragend', () => { timelineDrag = null; removeDragPreview(); }); settings.addEventListener('change', e => { const input = e.target.closest('.timeline-setting-input'); if (!input || !selectedTimelineAssignment) return; setTimelineAssignmentField( planId, selectedTimelineAssignment.mechanicId, selectedTimelineAssignment.ability, selectedTimelineAssignment.job, input.dataset.field, input.value ); }); } // ── Mechanic list helpers ───────────────────────────────────────────────────── function refreshMechanicList(planId, includeTimeline = true) { const plan = getPlan(planId); if (!plan) return; if (normalizeActivationCopies(plan)) updatePlan(planId, { mechanics: plan.mechanics }); const el = document.getElementById('mechanic-list'); if (el) el.innerHTML = renderMechanicListHtml(plan); if (includeTimeline) refreshTimeline(planId); renderInfoPanel(plan); } function renderInfoPanel(plan) { const el = document.getElementById('planner-info-panel'); if (!el) return; const legendHtml = `
Farbschema
Mitigation
Debuff
Schild
`; if (!plan) { el.innerHTML = legendHtml; return; } const activeJobSet = new Set(plan.jobComposition.filter(j => j)); const noJobAbilities = new Set(); const missingJobPairs = new Set(); for (const m of visiblePlanMechanics(plan)) { for (const a of m.assignments) { if (!a.job) { noJobAbilities.add(a.ability); } else if (!activeJobSet.has(a.job)) { missingJobPairs.add(`${a.job} · ${a.ability}`); } } } let warningsHtml = ''; if (noJobAbilities.size > 0 || missingJobPairs.size > 0) { warningsHtml = `
Hinweise
`; if (noJobAbilities.size > 0) { warningsHtml += `
Fähigkeiten ohne Job-Zuordnung
`; } if (missingJobPairs.size > 0) { warningsHtml += `
Fähigkeiten mit fehlendem Job
`; } warningsHtml += '
'; } const extraInfoHtml = `
Zusätzliche Informationen
k
`; el.innerHTML = legendHtml + warningsHtml + extraInfoHtml; const shieldInput = el.querySelector('#plan-shield-k-input'); if (shieldInput) { shieldInput.addEventListener('change', () => { const val = parseFloat(shieldInput.value) || 0; updatePlan(plan.id, { shieldK: val > 0 ? val : null }); refreshMechanicList(plan.id); }); } } function removeAssignment(planId, mechanicId, abilityName, job = null) { const plan = getPlan(planId); if (!plan) return; let removed = false; if (job === null) { const mechanic = plan.mechanics.find(m => m.id === mechanicId); const jobs = [...new Set((mechanic?.assignments ?? []) .filter(a => a.ability === abilityName) .map(a => a.job ?? ''))]; jobs.forEach(assignmentJob => { removed = removeActivationGroup(plan, { mechanicId, ability: abilityName, job: assignmentJob }) || removed; }); } else { removed = removeActivationGroup(plan, { mechanicId, ability: abilityName, job }); } if (!removed) return; updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); if (abilityModalMechanicId === mechanicId) renderAbilityModalContent(); } function deleteMechanic(planId, mechanicId) { const plan = getPlan(planId); if (!plan) return; plan.mechanics = plan.mechanics.filter(m => m.id !== mechanicId); updatePlan(planId, { mechanics: plan.mechanics }); refreshMechanicList(planId); renderPlanList(); } function initMechanicClicks(planId) { const list = document.getElementById('mechanic-list'); if (!list) return; list.addEventListener('click', e => { const removeBtn = e.target.closest('.badge-remove'); if (removeBtn) { e.stopPropagation(); removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability, removeBtn.dataset.job ?? null); return; } const deleteBtn = e.target.closest('.mechanic-delete-btn'); if (deleteBtn) { e.stopPropagation(); deleteMechanic(planId, deleteBtn.dataset.mechanicId); return; } const card = e.target.closest('.mechanic-card'); if (!card) return; showAbilityModal(planId, card.dataset.mechanicId); }); list.addEventListener('contextmenu', e => { const badge = e.target.closest('.badge-assign'); if (!badge) return; e.preventDefault(); e.stopPropagation(); const removeBtn = badge.querySelector('.badge-remove'); if (removeBtn) removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability, removeBtn.dataset.job ?? null); }); } // ── Rename ──────────────────────────────────────────────────────────────────── function startRename(id, currentName) { const display = document.getElementById('plan-name-display'); const editBtn = document.getElementById('plan-name-edit-btn'); if (!display) return; display.innerHTML = ``; const input = document.getElementById('plan-name-input'); input.focus(); input.select(); if (editBtn) editBtn.style.display = 'none'; let saved = false; const save = () => { if (saved) return; saved = true; const newName = input.value.trim() || currentName; updatePlan(id, { name: newName }); renderPlanList(); openPlan(id); }; input.addEventListener('keydown', e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { saved = true; display.innerHTML = `${escHtml(currentName)}`; if (editBtn) editBtn.style.display = ''; } }); input.addEventListener('blur', save); } // ── Open plan ───────────────────────────────────────────────────────────────── function openPlan(id) { activePlanId = id; localStorage.setItem(PLANNER_ACTIVE_KEY, id); renderPlanList(); renderPlanDetail(getPlan(id)); refreshPlanLanguage(id); } // ── New plan form ───────────────────────────────────────────────────────────── function initNewPlanForm() { const btn = document.getElementById('planner-new-btn'); const form = document.getElementById('planner-new-form'); const input = document.getElementById('planner-new-name'); const save = document.getElementById('planner-new-save'); const cancel = document.getElementById('planner-new-cancel'); if (!btn) return; btn.addEventListener('click', () => { form.style.display = ''; input.value = ''; input.focus(); }); cancel.addEventListener('click', () => { form.style.display = 'none'; }); const clearErr = () => { input.style.borderColor = ''; document.getElementById('planner-new-name-err')?.remove(); }; const doCreate = () => { const name = input.value.trim(); if (!name) { input.focus(); return; } if (loadPlans().some(p => p.name === name)) { input.style.borderColor = 'var(--red)'; let err = document.getElementById('planner-new-name-err'); if (!err) { err = document.createElement('div'); err.id = 'planner-new-name-err'; err.style.cssText = 'font-size:11px;color:var(--red);margin-top:3px'; input.insertAdjacentElement('afterend', err); } err.textContent = 'Name bereits vergeben'; input.focus(); return; } clearErr(); const plan = createPlan(name); form.style.display = 'none'; renderPlanList(); openPlan(plan.id); }; input.addEventListener('input', clearErr); save.addEventListener('click', doCreate); input.addEventListener('keydown', e => { if (e.key === 'Enter') doCreate(); if (e.key === 'Escape') { clearErr(); form.style.display = 'none'; } }); } // ── Folder Picker ───────────────────────────────────────────────────────────── let folderPickerCleanup = null; function closeFolderPicker() { document.getElementById('folder-picker')?.remove(); if (folderPickerCleanup) { folderPickerCleanup(); folderPickerCleanup = null; } } function showFolderPicker(planId, anchorEl) { closeFolderPicker(); const plan = getPlan(planId); const folders = loadFolders(); if (!plan) return; if (folders.length === 0) return; const rect = anchorEl.getBoundingClientRect(); const picker = document.createElement('div'); picker.id = 'folder-picker'; picker.className = 'folder-picker'; picker.style.cssText = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;z-index:9999`; picker.innerHTML = [{ id: null, name: 'Kein Ordner' }, ...folders].map(f => `
${f.id === null ? '— ' : '📁 '}${escHtml(f.name)}
`).join(''); document.body.appendChild(picker); picker.querySelectorAll('.folder-picker-option').forEach(opt => { opt.addEventListener('click', e => { e.stopPropagation(); updatePlan(planId, { folderId: opt.dataset.fid || null }); closeFolderPicker(); renderPlanList(); }); }); const onOutside = e => { if (!picker.contains(e.target)) closeFolderPicker(); }; setTimeout(() => document.addEventListener('click', onOutside), 0); folderPickerCleanup = () => document.removeEventListener('click', onOutside); } // ── New Folder Form ─────────────────────────────────────────────────────────── function initNewFolderForm() { const btn = document.getElementById('planner-new-folder-btn'); const form = document.getElementById('planner-new-folder-form'); const input = document.getElementById('planner-new-folder-name'); const save = document.getElementById('planner-new-folder-save'); const cancel = document.getElementById('planner-new-folder-cancel'); if (!btn) return; btn.addEventListener('click', () => { form.style.display = ''; input.value = ''; input.focus(); }); cancel.addEventListener('click', () => { form.style.display = 'none'; }); const doCreate = () => { const name = input.value.trim(); if (!name) { input.focus(); return; } createFolder(name); form.style.display = 'none'; renderPlanList(); }; save.addEventListener('click', doCreate); input.addEventListener('keydown', e => { if (e.key === 'Enter') doCreate(); if (e.key === 'Escape') form.style.display = 'none'; }); } // ── Shared FFXIV metadata ───────────────────────────────────────────────────── const { ABILITY_DR, ABILITY_JOB_MAP, ALL_JOBS, CASTER_JOBS, JOB_ABILITIES, JOB_FROM_TYPE, JOB_ROLE, MELEE_JOBS, MITIG_ICONS, TANK_JOBS, } = window.FF14_DATA; // Groups of abilities that are functionally equivalent across different jobs. // Used to suggest replacements when a job is missing from the composition. const ABILITY_EQUIVALENTS = [ ['Troubadour', 'Tactician', 'Shield Samba'], ['Sacred Soil', 'Kerachole'], ]; function findEquivSuggestions(abilityName, activeJobSet) { const group = ABILITY_EQUIVALENTS.find(g => g.includes(abilityName)); if (!group) return []; const suggestions = []; for (const equiv of group) { if (equiv === abilityName) continue; for (const job of activeJobSet) { if ((JOB_ABILITIES[job] ?? []).some(a => a.name === equiv)) { suggestions.push({ ability: equiv, job }); break; } } } return suggestions; } const ASSIGN_ORDER = { debuff: 0, buff: 1, shield: 2 }; function sortedAssignments(assignments) { return [...assignments].sort((a, b) => (ASSIGN_ORDER[a.buffType] ?? 1) - (ASSIGN_ORDER[b.buffType] ?? 1) ); } 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 ''; } 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, mitigationNames = {}) { 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 nonTankTargets = ev.targets.filter(t => t.role !== 'tank' && (t.unmitigatedAmount ?? 0) > 0); const fallbackTargets = ev.targets.filter(t => (t.unmitigatedAmount ?? 0) > 0); const relevantTargets = nonTankTargets.length > 0 ? nonTankTargets : fallbackTargets; const avgUnmit = relevantTargets.length > 0 ? Math.round(relevantTargets.reduce((s, t) => s + t.unmitigatedAmount, 0) / relevantTargets.length) : 0; let assignments = []; if (withMitigations) { const seen = new Set(); for (const t of ev.targets) { for (const m of (t.mitigations ?? [])) { const key = mitigationKey(m); if (!seen.has(key)) { seen.add(key); assignments.push({ ability: key, abilityName: mitigationDisplayName(m) || mitigationNames[key], actionId: m.extraAbilityGameID ?? null, job: guessJob(key, players), buffType: m.buffType ?? '', }); } } } } return { id: crypto.randomUUID(), name: ev.abilityName, abilityId: ev.abilityId, timestamp: relTs, phase: phase?.name ?? '', unmitigatedDamage: avgUnmit, notes: '', assignments, }; }); } // ── Merge + Create plan from import ────────────────────────────────────────── function buildPlayerRoster(players, aoeEvents) { const maxHpByName = {}; for (const ev of aoeEvents ?? []) { for (const t of ev.targets ?? []) { if (t.name && t.maxHp > 0 && !(t.name in maxHpByName)) { maxHpByName[t.name] = t.maxHp; } } } const order = { healer: 0, dps: 1, tank: 2 }; const sorted = [...(players ?? [])] .filter(p => JOB_FROM_TYPE[p.type]) .sort((a, b) => { const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1); return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); }); const roster = sorted.slice(0, 8).map(p => ({ name: p.name, maxHp: maxHpByName[p.name] ?? 0, })); while (roster.length < 8) roster.push({ name: '', maxHp: 0 }); return roster; } function extractJobComp(players) { const order = { healer: 0, dps: 1, tank: 2 }; const sorted = [...(players ?? [])] .filter(p => JOB_FROM_TYPE[p.type]) .sort((a, b) => { const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1); return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); }); const comp = sorted.map(p => JOB_FROM_TYPE[p.type] ?? '').slice(0, 8); while (comp.length < 8) comp.push(''); return comp; } function doImport(data, withMitigations, whereMode, mergeId, newName) { 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, mitigationNames, jobComposition: extractJobComp(players), playerRoster: buildPlayerRoster(players, aoeEvents), }); } // 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, 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 ────────────────────────────────────────────────── let abilityModalPlanId = null; let abilityModalMechanicId = null; function showAbilityModal(planId, mechanicId) { abilityModalPlanId = planId; abilityModalMechanicId = mechanicId; renderAbilityModalContent(); document.getElementById('planner-ability-modal').style.display = 'flex'; } function hideAbilityModal() { document.getElementById('planner-ability-modal').style.display = 'none'; abilityModalPlanId = null; abilityModalMechanicId = null; } function renderAbilityModalContent() { const plan = getPlan(abilityModalPlanId); if (!plan) return; const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId); if (!mechanic) return; document.getElementById('ability-modal-title').textContent = mechanic.name; const activeJobs = [...new Set(plan.jobComposition.filter(j => j))]; const content = document.getElementById('ability-modal-content'); if (activeJobs.length === 0) { content.innerHTML = `

Bitte zuerst die Jobaufstellung konfigurieren.

`; return; } content.innerHTML = activeJobs.map(job => { const abilities = JOB_ABILITIES[job] ?? []; if (!abilities.length) return ''; const role = JOB_ROLE[job] ?? 'dps'; const chips = abilities.map(ab => { const assigned = mechanic.assignments.find(a => a.ability === ab.name); const isActive = !!assigned; const byOtherJob = isActive && assigned.job !== job; const cls = ab.buffType === 'debuff' ? 'badge-assign-debuff' : ab.buffType === 'shield' ? 'badge-assign-shield' : 'badge-assign-buff'; const activeClass = isActive ? ' ability-chip--active' : ''; 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 ``; }).join(''); return `
${escHtml(job)}
${chips}
`; }).join(''); } function toggleAbilityAssignment(abilityName, job, buffType) { const plan = getPlan(abilityModalPlanId); if (!plan) return; const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId); if (!mechanic) return; const idx = mechanic.assignments.findIndex(a => a.ability === abilityName); if (idx !== -1) { const assignment = mechanic.assignments[idx]; if (assignment.job === job) { removeActivationGroup(plan, { mechanicId: mechanic.id, ability: abilityName, job: assignment.job ?? '', }); } else { compactActivationCopies(plan, { mechanicId: mechanic.id, ability: abilityName, job: assignment.job ?? '', }); if (assignmentOverlapsJob(plan, job, abilityName, assignmentStartMs(mechanic, assignment), { mechanicId: mechanic.id, ability: abilityName, job: assignment.job ?? '', }, assignment)) return; assignment.job = job; } } else { const assignment = { ability: abilityName, abilityName: plan.mitigationNames?.[abilityName], actionId: actionMetaByName[abilityName]?.id ?? null, job, buffType, }; if (assignmentOverlapsJob(plan, job, abilityName, mechanic.timestamp, null, assignment)) return; mechanic.assignments.push(assignment); } updatePlan(abilityModalPlanId, { mechanics: plan.mechanics }); refreshMechanicList(abilityModalPlanId); renderAbilityModalContent(); } function initAbilityModal() { const overlay = document.getElementById('planner-ability-modal'); if (!overlay) return; document.getElementById('ability-modal-close')?.addEventListener('click', hideAbilityModal); overlay.addEventListener('click', e => { if (e.target === overlay) hideAbilityModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && abilityModalPlanId) hideAbilityModal(); }); document.getElementById('ability-modal-content')?.addEventListener('click', e => { const btn = e.target.closest('.ability-chip'); if (!btn) return; toggleAbilityAssignment(btn.dataset.ability, btn.dataset.job, btn.dataset.buffType); }); } // ── Import Modal ────────────────────────────────────────────────────────────── let pendingImportData = null; function showImportModal(data) { pendingImportData = data; // Pre-fill name — auto-unique so the user sees immediately what name will be used const nameInput = document.getElementById('import-plan-name'); if (nameInput) { nameInput.value = uniquePlanName(data.fightName || 'Importierter Plan'); } // 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 === 'with-mitigations'; }); 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'); }); } // ── Name Import Modal ───────────────────────────────────────────────────────── let nameImportPlanId = null; let nameImportFights = []; let nameImportReportCode = ''; let nameImportMatchData = []; // length-8 array: null | {job, matched:[{name,type,role,maxHp}], selected:0} function showNameImportModal(planId) { nameImportPlanId = planId; nameImportFights = []; nameImportReportCode = ''; nameImportMatchData = []; document.getElementById('name-import-report-input').value = ''; document.getElementById('name-import-fight-section').style.display = 'none'; document.getElementById('name-import-fight-select').innerHTML = ''; document.getElementById('name-import-preview').style.display = 'none'; document.getElementById('name-import-preview').innerHTML = ''; document.getElementById('name-import-confirm-btn').style.display = 'none'; document.getElementById('planner-name-import-modal').style.display = 'flex'; document.getElementById('name-import-report-input').focus(); } function hideNameImportModal() { document.getElementById('planner-name-import-modal').style.display = 'none'; nameImportPlanId = null; } function refreshJobSlots(planId) { const plan = getPlan(planId); if (!plan) return; const grid = document.getElementById('job-slots-grid'); if (grid) grid.innerHTML = renderJobSlotsHtml(plan); } function renderNameImportPreview(_plan, fetchedPlayers) { const order = { healer: 0, dps: 1, tank: 2 }; const sorted = [...fetchedPlayers] .filter(p => JOB_FROM_TYPE[p.type]) .sort((a, b) => { const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1); return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name); }); nameImportMatchData = sorted.slice(0, 8); const preview = document.getElementById('name-import-preview'); const rows = Array.from({ length: 8 }, (_, i) => { const p = sorted[i]; if (!p) { return `
Leer
`; } const job = JOB_FROM_TYPE[p.type] ?? p.type; const role = JOB_ROLE[job] ?? 'dps'; return `
${escHtml(job)} ${escHtml(p.name)}
`; }).join(''); preview.innerHTML = rows; preview.style.display = ''; } function initNameImportModal() { const modal = document.getElementById('planner-name-import-modal'); const reportInput = document.getElementById('name-import-report-input'); const loadBtn = document.getElementById('name-import-load-btn'); const fightSection = document.getElementById('name-import-fight-section'); const fightSelect = document.getElementById('name-import-fight-select'); const preview = document.getElementById('name-import-preview'); const confirmBtn = document.getElementById('name-import-confirm-btn'); const cancelBtn = document.getElementById('name-import-cancel-btn'); if (!modal) return; cancelBtn.addEventListener('click', hideNameImportModal); modal.addEventListener('click', e => { if (e.target === modal) hideNameImportModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && nameImportPlanId) hideNameImportModal(); }); reportInput.addEventListener('input', () => { const match = reportInput.value.match(/fflogs\.com\/reports\/([A-Za-z0-9]+)/); if (match) reportInput.value = match[1]; }); loadBtn.addEventListener('click', async () => { const code = reportInput.value.trim(); if (!code) return; loadBtn.disabled = true; loadBtn.textContent = 'Lädt…'; fightSection.style.display = 'none'; preview.style.display = 'none'; confirmBtn.style.display = 'none'; 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: 'en' }), }); const json = await res.json(); if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } nameImportFights = json?.data?.reportData?.report?.fights ?? []; nameImportReportCode = code; fightSelect.innerHTML = ''; nameImportFights.forEach(f => { const ms = f.endTime - f.startTime; const dur = `${Math.floor(ms / 60000)}:${String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')}`; 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} — ${dur} — ${hp}`; fightSelect.appendChild(opt); }); fightSection.style.display = ''; } catch { } loadBtn.disabled = false; loadBtn.textContent = 'Laden'; }); fightSelect.addEventListener('change', async () => { const fightId = parseInt(fightSelect.value, 10); if (!fightId) { preview.style.display = 'none'; confirmBtn.style.display = 'none'; return; } const fight = nameImportFights.find(f => f.id === fightId); if (!fight) return; fightSelect.disabled = true; preview.innerHTML = '
Lädt…
'; preview.style.display = ''; confirmBtn.style.display = 'none'; try { const res = await fetch('api/players.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ report_code: nameImportReportCode, fight_id: fightId, start_time: fight.startTime, end_time: fight.endTime, }), }); const json = await res.json(); if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; } if (json.error) { preview.innerHTML = `
${escHtml(json.error)}
`; return; } const plan = getPlan(nameImportPlanId); if (!plan) return; renderNameImportPreview(plan, json.players ?? []); confirmBtn.style.display = ''; } catch { } fightSelect.disabled = false; }); confirmBtn.addEventListener('click', () => { if (!nameImportPlanId) return; const jobComposition = Array.from({ length: 8 }, (_, i) => { const p = nameImportMatchData[i]; return p ? (JOB_FROM_TYPE[p.type] ?? '') : ''; }); const playerRoster = Array.from({ length: 8 }, (_, i) => { const p = nameImportMatchData[i]; return p ? { name: p.name, maxHp: p.maxHp ?? 0 } : { name: '', maxHp: 0 }; }); updatePlan(nameImportPlanId, { jobComposition, playerRoster }); openPlan(nameImportPlanId); hideNameImportModal(); }); } // ── window.plannerTab (hooks for other tabs) ────────────────────────────────── window.plannerTab = { onTabOpen() { renderPlanList(); if (activePlanId) { openPlan(activePlanId); } else { renderPlanDetail(null); } }, showImportModal(data) { showImportModal(data); }, importFromAnalysis(aoeEvents, _refEvents, _options) { console.log('[Planner] importFromAnalysis — use showImportModal instead', aoeEvents); }, }; // ── Init ────────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { initNewPlanForm(); initNewFolderForm(); initImportModal(); initAbilityModal(); initNameImportModal(); activePlanId = localStorage.getItem(PLANNER_ACTIVE_KEY); renderPlanList(); if (activePlanId && getPlan(activePlanId)) { renderPlanDetail(getPlan(activePlanId)); } else { activePlanId = null; renderPlanDetail(null); } }); window.addEventListener('ff14-language-change', () => { if (!activePlanId) return; refreshPlanLanguage(activePlanId); });