ff14-mitigator/js/planner.js
2026-05-24 11:23:01 +02:00

2867 lines
117 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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: {},
timelineOptions: { includeShields: false, includePersonal: false },
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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 ?? '';
const localized = localizedAbilityName(key, plan);
if (localized !== key) return localized;
return assignment?.abilityName ?? key;
}
function plannerLanguage() {
return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en';
}
function actionLocalizedName(ability) {
const language = plannerLanguage();
const meta = actionMetaByName[ability] ?? null;
return meta?.names?.[language] || meta?.names?.en || null;
}
function localizedAbilityName(ability, plan = null) {
return plan?.mitigationNames?.[ability] ?? actionLocalizedName(ability) ?? ability;
}
function abilityIcon(ability) {
return MITIG_ICONS[ability] ?? actionMetaByName[ability]?.icon ?? '';
}
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 ?? '',
names: {
en: action.Name_en ?? '',
de: action.Name_de ?? '',
fr: action.Name_fr ?? '',
jp: action.Name_ja ?? '',
},
icon: compact?.[id]?.icon ?? null,
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 ?? {})) {
const names = action.names ?? {};
byId[id] = {
...(byId[id] ?? { id }),
name: byId[id]?.name ?? names.en ?? '',
names: { ...(byId[id]?.names ?? {}), ...names },
icon: byId[id]?.icon ?? action.icon ?? '',
cast: (parseInt(action.cast ?? 0, 10) || 0) / 10,
recast: (parseInt(action.recast ?? 0, 10) || 0) / 10,
};
if (names.en && !byName[names.en]) byName[names.en] = byId[id];
}
actionMetaById = byId;
actionMetaByName = byName;
})();
return actionMetaPromise;
}
function sameMechanic(existing, incoming, source) {
const fightStart = source?.fightStart ?? 0;
const incomingTs = Number(incoming.timestamp);
const incomingRel = incomingTs >= fightStart ? incomingTs - fightStart : incomingTs;
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 `
<div class="plan-item${p.id === activePlanId ? ' active' : ''}" data-id="${escHtml(p.id)}">
<div class="plan-item-info">
<div class="plan-item-name">${escHtml(p.name)}</div>
<div class="plan-item-meta">${p.mechanics.length} Mechaniken &middot; ${fmtDate(p.updatedAt)}</div>
</div>
<div class="plan-item-actions">
<button class="plan-btn plan-btn-move" data-id="${escHtml(p.id)}" title="In Ordner verschieben">📁</button>
<button class="plan-btn plan-btn-copy" data-id="${escHtml(p.id)}" title="Kopieren">⎘</button>
<button class="plan-btn plan-btn-danger plan-btn-delete" data-id="${escHtml(p.id)}" title="Löschen">✕</button>
</div>
</div>`;
}
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 = '<div class="plan-list-empty">Noch keine Pläne vorhanden</div>';
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 `
<div class="folder-section" data-folder-id="${escHtml(folder.id)}">
<div class="folder-row" data-folder-id="${escHtml(folder.id)}">
<span class="folder-chevron${collapsed ? '' : ' open'}">▶</span>
<span class="folder-name-text">${escHtml(folder.name)}</span>
<span class="folder-count">${folderPlans.length}</span>
<div class="folder-actions">
<button class="plan-btn folder-rename-btn" data-id="${escHtml(folder.id)}" title="Umbenennen">✎</button>
<button class="plan-btn plan-btn-danger folder-delete-btn" data-id="${escHtml(folder.id)}" title="Ordner löschen">✕</button>
</div>
</div>
<div class="folder-plans${collapsed ? ' collapsed' : ''}">
${folderPlans.map(p => planItemHtml(p)).join('') || '<div class="folder-empty">Leer</div>'}
</div>
</div>`;
}).join('') + ungrouped.map(p => planItemHtml(p)).join('');
el.innerHTML = html || '<div class="plan-list-empty">Noch keine Pläne vorhanden</div>';
// 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 = `
<div class="card section-gap">
<div class="plan-detail-header">
<div class="plan-name-wrap">
<span id="plan-name-display" class="plan-name-text">${escHtml(plan.name)}</span>
<button id="plan-name-edit-btn" class="plan-btn" title="Umbenennen">✎</button>
</div>
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} &middot; ${visibleMechanics.length} Mechaniken</div>
</div>
</div>
<div class="card section-gap">
<div class="card-title-row">
<div class="card-title">Jobaufstellung</div>
<button id="name-import-open-btn" class="btn btn-sm">Namen + Job Import</button>
</div>
<div class="job-slots-grid" id="job-slots-grid">
${renderJobSlotsHtml(plan)}
</div>
</div>
<div class="card section-gap">
<div class="card-title-row">
<div class="card-title">Zeitstrahl</div>
<div class="timeline-controls">
<label class="timeline-toggle">
<input type="checkbox" id="timeline-include-shields"${timelineOptions(plan).includeShields ? ' checked' : ''}>
<span>Include Shields</span>
</label>
<label class="timeline-toggle">
<input type="checkbox" id="timeline-include-personal"${timelineOptions(plan).includePersonal ? ' checked' : ''}>
<span>Include Personal</span>
</label>
</div>
</div>
<div class="timeline-hint">Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten</div>
<div id="planner-timeline">
${renderTimelineHtml(plan)}
</div>
<div id="timeline-settings">
${renderTimelineSettingsHtml(plan)}
</div>
</div>
<div class="card">
<div class="card-title-row">
<div class="card-title">Mechaniken</div>
</div>
<div id="mechanic-list">
${renderMechanicListHtml(plan)}
</div>
</div>
`;
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);
});
initTimelineOptions(plan.id);
initTimeline(plan.id);
initMechanicClicks(plan.id);
renderInfoPanel(plan);
ensureActionMetaLoaded().then(() => refreshMechanicList(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 `
<div class="empty" style="padding:30px 0">
<div class="empty-icon" style="font-size:26px">📋</div>
<h3>Noch keine Mechaniken</h3>
<p style="font-size:13px;color:var(--t3);margin-top:6px">
Importiere einen Log aus dem Analyse-Tab
</p>
</div>
`;
}
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
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
: 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 = abilityIcon(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 = `<span class="badge badge-assign ${cls}${isMissing ? ' badge-assign--missing-job' : ''}"${title ? ` title="${title}"` : ''}>
${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}
${label}
<button class="badge-remove" data-mechanic-id="${escHtml(a.sourceMechanicId ?? m.id)}" data-ability="${escHtml(a.ability)}" data-job="${escHtml(a.job ?? '')}" title="Entfernen">×</button>
</span>`;
const hintHtml = suggestions.map(s =>
`<span class="badge-equiv-hint">→ ${escHtml(s.ability)} (${escHtml(s.job)})?</span>`
).join('');
const noEquivHint = isMissing && suggestions.length === 0
? `<span class="badge-no-equiv-hint">→ Kein Äquivalent!</span>` : '';
const jobHint = !a.job ? `<span class="badge-job-hint">→ Job zuordnen</span>` : '';
const needsWrap = suggestions.length > 0 || !!noEquivHint || !!jobHint;
return needsWrap
? `<span class="badge-with-hint">${badgeHtml}${hintHtml}${noEquivHint}${jobHint}</span>`
: 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 `
<div class="mechanic-card" data-mechanic-id="${escHtml(m.id)}">
<div class="mechanic-time">${escHtml(fmtTimestamp(m.timestamp))}</div>
<div class="mechanic-body">
${m.phase ? `<div class="mechanic-phase">${escHtml(m.phase)}</div>` : ''}
<div class="mechanic-name">${escHtml(m.name)}</div>
${m.unmitigatedDamage
? `<div class="mechanic-dmg">${fmtNumber(m.unmitigatedDamage)} unmitigiert${avgHp ? ` <span class="mechanic-avg-hp">∅ ${fmtNumber(avgHp)} HP</span>` : ''}</div>`
: ''
}
${hasDrAssign || hasShield ? `<div class="mechanic-dmg mechanic-mitig-row">
${hasDrAssign ? `<span class="mechanic-mitig-val${drOnlyCls ? ' ' + drOnlyCls : ''}">→ ${fmtNumber(drOnly)}</span> mitigiert` : ''}
${hasShield ? `<span class="mechanic-mitig-shield${fullCls ? ' ' + fullCls : ''}">Mitigation mit Schild ${fmtNumber(mitigFull)}</span>` : ''}
</div>` : ''}
<div class="mechanic-assignments">${assignHtml}</div>
${m.notes ? `<div class="mechanic-notes">${escHtml(m.notes)}</div>` : ''}
<div class="mechanic-edit-hint">Klicken zum Bearbeiten</div>
</div>
<button class="mechanic-delete-btn plan-btn plan-btn-danger" data-mechanic-id="${escHtml(m.id)}" title="Mechanik löschen">✕</button>
</div>`;
}).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 `
<div class="job-slot${role ? ` job-slot--${role}` : ''}">
<select class="job-slot-select" data-idx="${i}">
<option value="">—</option>
${ALL_JOBS.map(g =>
`<optgroup label="${escHtml(g.group)}">${g.jobs.map(j =>
`<option value="${j}"${j === job ? ' selected' : ''}>${j}</option>`
).join('')}</optgroup>`
).join('')}
</select>
${playerName ? `<div class="job-slot-name" title="${escHtml(playerName)}">${escHtml(playerName)}</div>` : ''}
</div>`;
}).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);
});
}
function initTimelineOptions(planId) {
const shields = document.getElementById('timeline-include-shields');
const personal = document.getElementById('timeline-include-personal');
const update = () => {
const plan = getPlan(planId);
if (!plan) return;
updatePlan(planId, {
timelineOptions: {
...(plan.timelineOptions ?? {}),
includeShields: !!shields?.checked,
includePersonal: !!personal?.checked,
},
});
refreshMechanicList(planId);
};
shields?.addEventListener('change', update);
personal?.addEventListener('change', update);
}
// ── 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)) };
}
// Sortierreihenfolge im Gantt: Healer → Phys.Range → Caster → Melee → Tank
const JOB_GANTT_ORDER = {
'WHM': 0, 'SCH': 1, 'AST': 2, 'SGE': 3,
'BRD': 10, 'MCH': 11, 'DNC': 12,
'BLM': 20, 'SMN': 21, 'RDM': 22, 'PCT': 23,
'MNK': 30, 'DRG': 31, 'NIN': 32, 'SAM': 33, 'RPR': 34, 'VPR': 35,
'PLD': 40, 'WAR': 41, 'DRK': 42, 'GNB': 43,
};
const TIMELINE_PERSONAL_ABILITIES = new Set([
'Rampart',
'Hallowed Ground',
'Sentinel',
'Guardian',
'Bulwark',
'Holy Sheltron',
'Intervention',
'Holmgang',
'Vengeance',
'Damnation',
'Thrill of Battle',
'Raw Intuition',
'Bloodwhetting',
'Living Dead',
'Shadow Wall',
'Shadowed Vigil',
'Dark Mind',
'The Blackest Night',
'Oblation',
'Superbolide',
'Nebula',
'Great Nebula',
'Camouflage',
'Heart of Stone',
'Heart of Corundum',
'Divine Benison',
'Intersection',
'the Spire',
'Haima',
'Eukrasian Diagnosis',
'Differential Diagnosis',
'Seraphic Veil',
'Radiant Aegis',
'Tempera Coat',
'Riddle of Earth',
'Shade Shift',
'Third Eye',
'Arcane Crest',
'Manaward',
]);
function timelineOptions(plan) {
return {
includeShields: !!plan?.timelineOptions?.includeShields,
includePersonal: !!plan?.timelineOptions?.includePersonal,
};
}
function timelineAbilityVisible(ability, options) {
const isPersonal = TIMELINE_PERSONAL_ABILITIES.has(ability.name);
const isShield = ability.buffType === 'shield';
if (isPersonal) return !!options.includePersonal;
if (isShield && !options.includeShields && ability.name !== 'Panhaima') return false;
return true;
}
function timelinePlayerRows(plan) {
const roster = plan.playerRoster ?? [];
const rows = [];
const options = timelineOptions(plan);
const jobEntries = (plan.jobComposition ?? [])
.map((job, idx) => ({ job, idx }))
.filter(e => !!e.job)
.sort((a, b) => (JOB_GANTT_ORDER[a.job] ?? 99) - (JOB_GANTT_ORDER[b.job] ?? 99));
jobEntries.forEach(({ job, idx }) => {
const name = roster[idx]?.name ?? '';
const role = JOB_ROLE[job] ?? '';
const abilities = (JOB_ABILITIES[job] ?? [])
.filter(ab => timelineAbilityVisible(ab, options));
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 abilityDefinition(ability, job = '') {
if (job) {
const own = (JOB_ABILITIES[job] ?? []).find(a => a.name === ability);
if (own) return own;
}
for (const abilities of Object.values(JOB_ABILITIES)) {
const found = abilities.find(a => a.name === ability);
if (found) return found;
}
return null;
}
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) {
const durationMs = Math.max(1, assignmentDurationSeconds(assignment)) * 1000;
const cooldownMs = assignmentCooldownSeconds(assignment) * 1000;
return Math.max(durationMs, cooldownMs);
}
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 candidateDurationMs = Math.max(candidate ? assignmentDurationSeconds(candidate) : 0, 1) * 1000;
const candidateCooldownMs = candidate ? assignmentCooldownSeconds(candidate) * 1000 : 0;
const candidateWindow = Math.max(candidateDurationMs, candidateCooldownMs);
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 def = abilityDefinition(assignment?.ability, assignment?.job ?? '');
const id = String(assignment?.actionId ?? assignment?.extraAbilityGameID ?? def?.extraAbilityGameID ?? '');
const meta = (id && actionMetaById[id]) || actionMetaByName[assignment?.ability] || null;
if (!def) return meta;
return {
...(meta ?? {}),
id: id || meta?.id,
name: meta?.name ?? assignment?.ability,
recast: meta?.recast ?? def.recast,
duration: def.duration ?? meta?.duration,
};
}
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 '<div class="timeline-empty">Importiere Mechaniken aus dem Analyse-Tab, um den Zeitstrahl zu nutzen.</div>';
}
const { duration, width } = timelineScale(plan);
const rows = timelinePlayerRows(plan);
const marks = [];
const tick = 10000;
for (let t = 0; t <= duration; t += tick) {
marks.push(`<span class="timeline-tick" style="left:${(t / duration) * 100}%">${fmtTimestamp(t)}</span>`);
}
const bossActions = layoutBossActions(mechanics, duration);
const laneCount = Math.max(1, ...bossActions.map(item => item.lane + 1));
const hitLines = mechanics.map(m => `
<span class="timeline-hit-line${m.isHeavyTankbuster ? ' timeline-hit-line--tankbuster' : ''}" style="left:${(m.timestamp / duration) * 100}%"></span>
`).join('');
const bossItems = bossActions.map(({ mechanic: m, left, lane }) => `
<button class="timeline-boss-action${m.isHeavyTankbuster ? ' timeline-boss-action--tankbuster' : ''}"
style="left:${left}%;top:${8 + lane * 30}px"
data-mechanic-id="${escHtml(m.id)}"
title="${escHtml(fmtTimestamp(m.timestamp))} · ${escHtml(m.name)} · ${m.isHeavyTankbuster ? 'Tankbuster' : 'AoE'}">
${escHtml(m.name)}
</button>
`).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 = abilityIcon(a.ability);
const abilityLabel = assignmentAbilityName(a, plan);
blocks.push(`
<button class="timeline-mitigation ${cls}${selected}${unresolved}"
draggable="true"
style="left:${left}%;--cd-width:${cdWidthPct}%;--active-width:${activeWidthPct}%"
data-mechanic-id="${escHtml(m.id)}"
data-ability="${escHtml(a.ability)}"
data-job="${escHtml(a.job ?? '')}"
title="${escHtml(abilityLabel)} · aktiv ${durationSec}s · CD ${cooldownSec}s${a.job ? '' : ' · mögliche Zuordnung'}">
<span class="timeline-mitigation-active"></span>
${icon ? `<img src="${escHtml(icon)}" alt="">` : ''}
<span>${escHtml(abilityLabel)}</span>
</button>
`);
}
const icon = abilityIcon(row.ability);
const abilityDisplayName = localizedAbilityName(row.ability, plan);
const jobStartCls = row.firstForJob ? ' timeline-player-row--job-start' : '';
return `
<div class="timeline-row timeline-player-row${jobStartCls}" data-row-idx="${row.idx}" data-job="${escHtml(row.job)}" data-ability="${escHtml(row.ability)}">
<div class="timeline-row-label">
<span class="timeline-job role-${escHtml(row.role)}">${escHtml(row.job)}</span>
<span class="timeline-row-ability">
${icon ? `<img src="${escHtml(icon)}" alt="" class="timeline-row-ability-icon">` : ''}
<span class="timeline-row-ability-name">${escHtml(abilityDisplayName)}</span>
</span>
</div>
<div class="timeline-track" style="width:${width}px">${hitLines}${blocks.join('')}</div>
</div>`;
}).join('');
return `
<div class="timeline-scroll">
<div class="timeline-grid" style="--timeline-width:${width}px">
<div class="timeline-row timeline-boss-row" style="--boss-row-height:${Math.max(52, 18 + laneCount * 30)}px">
<button class="timeline-row-label timeline-boss-label" type="button">Boss</button>
<div class="timeline-track" style="width:${width}px">${hitLines}${bossItems}</div>
</div>
${playerRows}
<div class="timeline-axis">
<div></div>
<div class="timeline-axis-track" style="width:${width}px">${marks.join('')}</div>
</div>
</div>
</div>`;
}
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 '<div class="timeline-settings-empty">Mitigation im Zeitstrahl auswählen, um Dauer und Cooldown anzupassen.</div>';
}
const { mechanic, assignment } = found;
const ability = assignmentAbilityName(assignment, plan);
const jobLabel = assignment.job || 'Nicht zugewiesen';
return `
<div class="timeline-settings-panel">
<div class="timeline-settings-title">${escHtml(jobLabel)} · ${escHtml(ability)}</div>
<div class="timeline-settings-meta">${escHtml(mechanic.name)} bei ${escHtml(fmtTimestamp(mechanic.timestamp))}</div>
<label>
<span>Start</span>
<input class="timeline-setting-input" data-field="timestamp" type="number" min="0" step="1" value="${Math.round(((assignment.timestamp ?? mechanic.timestamp) || 0) / 1000)}">
</label>
<label>
<span>Dauer</span>
<input class="timeline-setting-input" data-field="durationSeconds" type="number" min="1" step="1" value="${assignmentDurationSeconds(assignment)}">
</label>
<label>
<span>Cooldown</span>
<input class="timeline-setting-input" data-field="cooldownSeconds" type="number" min="0" step="1" value="${assignmentCooldownSeconds(assignment)}">
</label>
</div>`;
}
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) {
const savedScroll = timeline.querySelector('.timeline-scroll')?.scrollLeft ?? 0;
timeline.innerHTML = renderTimelineHtml(plan);
const newScroll = timeline.querySelector('.timeline-scroll');
if (newScroll && savedScroll > 0) newScroll.scrollLeft = savedScroll;
}
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;
const def = abilityDefinition(ability, job);
mechanic.assignments = mechanic.assignments ?? [];
const assignment = {
ability,
abilityName: localizedAbilityName(ability, plan),
actionId: def?.extraAbilityGameID ?? 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);
}
let timelineMenuCleanup = null;
function closeTimelineMenu() {
if (timelineMenuCleanup) {
timelineMenuCleanup();
timelineMenuCleanup = null;
}
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) => `
<button class="timeline-menu-item${item.disabled ? ' disabled' : ''}${item.header ? ' timeline-menu-header' : ''}" data-idx="${idx}"${item.disabled ? ' disabled' : ''}>
${item.icon ? `<img src="${escHtml(item.icon)}" alt="">` : ''}
<span>${escHtml(item.label)}</span>
</button>
`).join('')
: '<div class="timeline-menu-empty">Keine verfügbare Fähigkeit</div>';
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;
const item = items[parseInt(btn.dataset.idx, 10)];
item?.onClick?.();
if (!item?.keepOpen) closeTimelineMenu();
});
const closeOutside = ev => {
if (menu.contains(ev.target)) return;
closeTimelineMenu();
};
timelineMenuCleanup = () => {
document.removeEventListener('click', closeOutside, true);
document.removeEventListener('contextmenu', closeOutside, true);
};
setTimeout(() => {
document.addEventListener('click', closeOutside, true);
document.addEventListener('contextmenu', closeOutside, true);
}, 0);
}
function showTimelineSectionMenu(x, y, sections) {
let openSection = null;
const render = () => {
const items = [];
sections.forEach((section, idx) => {
const isOpen = openSection === idx;
items.push({
label: `${isOpen ? '▾' : '▸'} ${section.label}`,
keepOpen: true,
onClick: () => {
openSection = isOpen ? null : idx;
render();
},
});
if (isOpen) {
items.push(...section.items.map(item => ({
...item,
label: ` ${item.label}`,
})));
}
});
showTimelineMenu(x, y, items.length ? items : [{ label: 'Keine Eintraege', disabled: true }]);
};
render();
}
function setBossMechanicType(planId, mechanicId, isTankbuster) {
const plan = getPlan(planId);
if (!plan) return;
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
if (!mechanic) return;
mechanic.isHeavyTankbuster = !!isTankbuster;
mechanic.mechanicTypeManual = true;
updatePlan(planId, { mechanics: plan.mechanics });
refreshMechanicList(planId);
}
function confirmRemoveMechanic(mechanic) {
const name = mechanic?.name ? `"${mechanic.name}"` : 'diesen Angriff';
return window.confirm(`${name} wirklich aus dem Plan entfernen?`);
}
function removeBossMechanic(planId, mechanicId, ask = true) {
const plan = getPlan(planId);
if (!plan) return;
const mechanic = (plan.mechanics ?? []).find(m => m.id === mechanicId);
if (ask && !confirmRemoveMechanic(mechanic)) return;
const mechanics = (plan.mechanics ?? []).filter(m => m.id !== mechanicId);
if (mechanics.length === (plan.mechanics ?? []).length) return;
if (selectedTimelineAssignment?.mechanicId === mechanicId) {
selectedTimelineAssignment = null;
}
updatePlan(planId, { mechanics });
refreshMechanicList(planId);
}
async function fetchPlanAnalysisSource(plan) {
const source = plan?.source ?? {};
const language = plannerLanguage();
if (!source.reportCode || !source.fightId || !Number.isFinite(Number(source.fightStart)) || !Number.isFinite(Number(source.fightEnd))) {
return { error: 'Keine Report-Quelle fuer diesen Plan gefunden' };
}
const res = await fetch('api/analysis.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
report_code: source.reportCode,
fight_id: source.fightId,
start_time: source.fightStart,
end_time: source.fightEnd,
language,
}),
});
const json = await res.json();
if (json.reauth) {
window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php';
return null;
}
return json;
}
function mechanicExistsInPlan(plan, event) {
return (plan.mechanics ?? []).some(mechanic => sameMechanic(mechanic, event, plan.source ?? {}));
}
function addMechanicsFromEvents(planId, events, analysisJson) {
const plan = getPlan(planId);
if (!plan || !events.length) return;
const source = plan.source ?? {};
const mechanics = aoeEventsToMechanics(
events,
source.fightStart ?? analysisJson?.fight_start ?? 0,
[],
analysisJson?.players ?? [],
false,
analysisJson?.mitigation_names ?? plan.mitigationNames ?? {}
);
const merged = [...(plan.mechanics ?? [])];
for (const mechanic of mechanics) {
if (!merged.some(existing => sameMechanic(existing, {
timestamp: mechanic.timestamp + (source.fightStart ?? 0),
abilityId: mechanic.abilityId,
abilityName: mechanic.name,
}, source))) {
merged.push(mechanic);
}
}
merged.sort((a, b) => a.timestamp - b.timestamp);
updatePlan(planId, {
mechanics: merged,
mitigationNames: { ...(plan.mitigationNames ?? {}), ...(analysisJson?.mitigation_names ?? {}) },
});
refreshMechanicList(planId);
}
async function showMissingBossActionsMenu(planId, x, y) {
const plan = getPlan(planId);
if (!plan) return;
showTimelineMenu(x, y, [{ label: 'Boss-Angriffe werden geladen...', disabled: true }]);
let json = null;
try {
json = await fetchPlanAnalysisSource(plan);
} catch {
showTimelineMenu(x, y, [{ label: 'Boss-Angriffe konnten nicht geladen werden', disabled: true }]);
return;
}
if (!json) return;
if (json.error) {
showTimelineMenu(x, y, [{ label: json.error, disabled: true }]);
return;
}
const genericAttackNames = new Set(['attack', 'attacke', 'auto attack', 'auto-attack', 'angriff', 'attaque', '攻撃']);
const isGenericAttack = event => {
const name = String(event?.abilityName ?? '')
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
const id = parseInt(event?.abilityId ?? 0, 10) || 0;
return id > 0 && id <= 7 || genericAttackNames.has(name);
};
const missing = (json.boss_events ?? json.aoe_events ?? [])
.filter(event => !isGenericAttack(event))
.filter(event => !mechanicExistsInPlan(plan, event))
.sort((a, b) => a.timestamp - b.timestamp);
const sourceStart = plan.source?.fightStart ?? json.fight_start ?? 0;
const byAttack = new Map();
const idsByNameType = new Map();
for (const event of missing) {
const nameKey = String(event.abilityName ?? '').trim().toLowerCase();
const typeKey = event.isHeavyTankbuster ? 'tankbuster' : 'aoe';
const idKey = String(event.abilityId ?? '');
const nameTypeKey = `${nameKey}::${typeKey}`;
if (!idsByNameType.has(nameTypeKey)) idsByNameType.set(nameTypeKey, new Set());
if (idKey) idsByNameType.get(nameTypeKey).add(idKey);
const key = `${idKey || nameKey}::${nameTypeKey}`;
if (!byAttack.has(key)) byAttack.set(key, []);
byAttack.get(key).push(event);
}
const attackItems = [];
const timeItems = [];
if (byAttack.size) {
for (const events of [...byAttack.values()].sort((a, b) => a[0].abilityName.localeCompare(b[0].abilityName))) {
const first = events[0];
const count = events.length > 1 ? ` (${events.length}x)` : '';
const nameTypeKey = `${String(first.abilityName ?? '').trim().toLowerCase()}::${first.isHeavyTankbuster ? 'tankbuster' : 'aoe'}`;
const duplicateName = (idsByNameType.get(nameTypeKey)?.size ?? 0) > 1;
const idLabel = duplicateName && first.abilityId ? ` [${first.abilityId}]` : '';
attackItems.push({
label: `${first.abilityName}${idLabel}${count} · ${first.isHeavyTankbuster ? 'Tankbuster' : 'AoE'}`,
onClick: () => addMechanicsFromEvents(planId, events, json),
});
}
for (const event of missing) {
timeItems.push({
label: `${fmtTimestamp(event.timestamp - sourceStart)} · ${event.abilityName} · ${event.isHeavyTankbuster ? 'Tankbuster' : 'AoE'}`,
onClick: () => addMechanicsFromEvents(planId, [event], json),
});
}
} else {
showTimelineMenu(x, y, [{ label: 'Keine fehlenden Boss-Angriffe', disabled: true }]);
return;
}
if (missing.length > 1) {
attackItems.unshift({
label: `Alle ${missing.length} fehlenden hinzufügen`,
onClick: () => addMechanicsFromEvents(planId, missing, json),
});
}
showTimelineSectionMenu(x, y, [
{ label: 'By Attack', items: attackItems },
{ label: 'By Time', items: timeItems },
]);
}
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 = `
<span class="timeline-drag-preview-active"></span>
${timelineDrag.icon ? `<img src="${escHtml(timelineDrag.icon)}" alt="">` : ''}
<span>${escHtml(timelineDrag.label)}</span>
`;
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-boss-label, .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) > 8) {
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 bossLabel = e.target.closest('.timeline-boss-label');
if (bossLabel) {
showMissingBossActionsMenu(planId, e.clientX, e.clientY);
return;
}
const boss = e.target.closest('.timeline-boss-action');
if (boss) {
const plan = getPlan(planId);
const mechanic = plan?.mechanics?.find(m => m.id === boss.dataset.mechanicId);
showTimelineMenu(e.clientX, e.clientY, [
{
label: 'Als AoE markieren',
disabled: mechanic && !mechanic.isHeavyTankbuster,
onClick: () => setBossMechanicType(planId, boss.dataset.mechanicId, false),
},
{
label: 'Als Tankbuster markieren',
disabled: mechanic && !!mechanic.isHeavyTankbuster,
onClick: () => setBossMechanicType(planId, boss.dataset.mechanicId, true),
},
{
label: 'Mitigation zuweisen',
onClick: () => showAbilityModal(planId, boss.dataset.mechanicId),
},
{
label: 'Angriff entfernen',
onClick: () => removeBossMechanic(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))
.filter((row, idx, arr) => arr.findIndex(r => r.job === row.job) === idx)
.map(row => ({
label: `${row.job}${row.name ? ` · ${row.name}` : ''}`,
icon: abilityIcon(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;
}
// setPointerCapture() leitet compatibility mouse events (inkl. click) an das
// capturing element um, daher e.target nicht verlässlich — echtes Element per Hit-Test:
const actualTarget = document.elementFromPoint(e.clientX, e.clientY);
const track = actualTarget?.closest('.timeline-player-row .timeline-track');
const row = actualTarget?.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: ab.extraAbilityGameID ?? 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 = `
<div class="info-section">
<div class="info-section-title">Farbschema</div>
<div class="info-legend-items">
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--buff"></span><span class="info-legend-label">Mitigation</span></div>
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--debuff"></span><span class="info-legend-label">Debuff</span></div>
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--shield"></span><span class="info-legend-label">Schild</span></div>
</div>
</div>`;
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 = `<div class="info-section info-section--warnings"><div class="info-section-title">Hinweise</div>`;
if (noJobAbilities.size > 0) {
warningsHtml += `<div class="info-warning info-warning--job">Fähigkeiten ohne Job-Zuordnung</div>`;
}
if (missingJobPairs.size > 0) {
warningsHtml += `<div class="info-warning info-warning--missing">Fähigkeiten mit fehlendem Job</div>`;
}
warningsHtml += '</div>';
}
const extraInfoHtml = `
<div class="info-section info-section--extra">
<div class="info-section-title">Zusätzliche Informationen</div>
<div class="info-extra-row">
<label class="info-extra-label">SGE/SCH Schild</label>
<div class="info-extra-input-wrap">
<input type="text" inputmode="numeric" id="plan-shield-k-input" class="info-extra-input"
value="${plan.shieldK ?? ''}" placeholder="0">
<span class="info-extra-unit">k</span>
</div>
</div>
</div>`;
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;
const mechanic = (plan.mechanics ?? []).find(m => m.id === mechanicId);
if (!confirmRemoveMechanic(mechanic)) return;
plan.mechanics = (plan.mechanics ?? []).filter(m => m.id !== mechanicId);
if (selectedTimelineAssignment?.mechanicId === mechanicId) {
selectedTimelineAssignment = null;
}
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 = `<input id="plan-name-input" class="plan-name-input" type="text" value="${escHtml(currentName)}">`;
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 = `<span class="plan-name-text">${escHtml(currentName)}</span>`;
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 => `
<div class="folder-picker-option${(plan.folderId ?? null) === f.id ? ' active' : ''}" data-fid="${escHtml(f.id ?? '')}">
${f.id === null ? '— ' : '📁 '}${escHtml(f.name)}
</div>`).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;
const IMPORT_OFFSET_MS = -3000;
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 ?? '',
timestamp: Math.max(0, relTs + IMPORT_OFFSET_MS),
});
}
}
}
}
return {
id: crypto.randomUUID(),
name: ev.abilityName,
abilityId: ev.abilityId,
timestamp: relTs,
phase: phase?.name ?? '',
unmitigatedDamage: avgUnmit,
isHeavyTankbuster: !!ev.isHeavyTankbuster,
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,
isHeavyTankbuster: mechanic.mechanicTypeManual ? !!mechanic.isHeavyTankbuster : !!match.isHeavyTankbuster,
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 = `
<p style="color:var(--t3);font-size:13px;padding:8px 0">
Bitte zuerst die Jobaufstellung konfigurieren.
</p>`;
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 = abilityIcon(ab.name);
const assignedName = assigned ? assignmentAbilityName(assigned, plan) : '';
const label = assignedName || localizedAbilityName(ab.name, plan);
return `<button class="ability-chip ${cls}${activeClass}${otherClass}"
data-ability="${escHtml(ab.name)}"
data-job="${escHtml(job)}"
data-buff-type="${escHtml(ab.buffType)}"
${title ? `title="${title}"` : ''}
>${icon ? `<img class="badge-icon" src="${escHtml(icon)}" alt="">` : ''}${escHtml(label)}</button>`;
}).join('');
return `
<div class="ability-job-group">
<div class="ability-job-label">
<span class="aoe-target-job role-${role}">${escHtml(job)}</span>
</div>
<div class="ability-chips">${chips}</div>
</div>`;
}).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 def = abilityDefinition(abilityName, job);
const assignment = {
ability: abilityName,
abilityName: localizedAbilityName(abilityName, plan),
actionId: def?.extraAbilityGameID ?? 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 = '<option value="">— Plan auswählen —</option>';
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 = '<option value="">— Fight auswählen —</option>';
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 `
<div class="name-import-row">
<span class="aoe-target-job" style="opacity:0.25">—</span>
<span class="name-import-name--none">Leer</span>
</div>`;
}
const job = JOB_FROM_TYPE[p.type] ?? p.type;
const role = JOB_ROLE[job] ?? 'dps';
return `
<div class="name-import-row">
<span class="aoe-target-job role-${role}">${escHtml(job)}</span>
<span class="name-import-name">${escHtml(p.name)}</span>
</div>`;
}).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 = '<option value="">— Fight auswählen —</option>';
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 = '<div style="padding:10px 12px;color:var(--t3);font-size:13px">Lädt…</div>';
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 = `<div style="padding:10px 12px;color:var(--red);font-size:13px">${escHtml(json.error)}</div>`; 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);
});