// ── Storage ───────────────────────────────────────────────────────────────────
const PLANNER_KEY = 'ff14-planner-plans';
const PLANNER_ACTIVE_KEY = 'ff14-planner-active-plan';
const FOLDERS_KEY = 'ff14-planner-folders';
function loadPlans() {
try { return JSON.parse(localStorage.getItem(PLANNER_KEY) || '[]'); }
catch { return []; }
}
function savePlans(plans) { localStorage.setItem(PLANNER_KEY, JSON.stringify(plans)); }
function loadFolders() {
try { return JSON.parse(localStorage.getItem(FOLDERS_KEY) || '[]'); }
catch { return []; }
}
function saveFolders(f) { localStorage.setItem(FOLDERS_KEY, JSON.stringify(f)); }
function createFolder(name) {
const folder = { id: crypto.randomUUID(), name: name.trim() || 'Neuer Ordner', createdAt: Date.now(), updatedAt: Date.now() };
const all = loadFolders(); all.push(folder); saveFolders(all); return folder;
}
function updateFolder(id, changes) {
const all = loadFolders(), idx = all.findIndex(f => f.id === id);
if (idx === -1) return;
all[idx] = { ...all[idx], ...changes, updatedAt: Date.now() };
saveFolders(all);
}
function deleteFolder(id) {
saveFolders(loadFolders().filter(f => f.id !== id));
savePlans(loadPlans().map(p => p.folderId === id ? { ...p, folderId: null, updatedAt: Date.now() } : p));
}
// ── CRUD ──────────────────────────────────────────────────────────────────────
function createPlan(name) {
const plan = {
id: crypto.randomUUID(),
name: name.trim() || 'Unbenannter Plan',
createdAt: Date.now(),
updatedAt: Date.now(),
source: null,
mitigationNames: {},
folderId: null,
jobComposition: Array(8).fill(''),
mechanics: []
};
const all = loadPlans();
all.push(plan);
savePlans(all);
return plan;
}
function getPlan(id) {
return loadPlans().find(p => p.id === id) ?? null;
}
function updatePlan(id, changes) {
const all = loadPlans();
const idx = all.findIndex(p => p.id === id);
if (idx === -1) return null;
all[idx] = { ...all[idx], ...changes, updatedAt: Date.now() };
savePlans(all);
return all[idx];
}
function deletePlan(id) {
savePlans(loadPlans().filter(p => p.id !== id));
}
function uniquePlanName(baseName) {
const names = new Set(loadPlans().map(p => p.name));
if (!names.has(baseName)) return baseName;
let i = 2;
while (names.has(`${baseName} ${i}`)) i++;
return `${baseName} ${i}`;
}
function copyPlan(id) {
const all = loadPlans();
const orig = all.find(p => p.id === id);
if (!orig) return null;
const copy = {
...JSON.parse(JSON.stringify(orig)),
id: crypto.randomUUID(),
name: uniquePlanName(orig.name + ' (Kopie)'),
createdAt: Date.now(),
updatedAt: Date.now()
};
all.push(copy);
savePlans(all);
return copy;
}
// ── State ─────────────────────────────────────────────────────────────────────
let activePlanId = null;
let collapsedFolders = new Set();
let selectedTimelineAssignment = null;
let actionMetaPromise = null;
let actionMetaById = {};
let actionMetaByName = {};
// ── Helpers ───────────────────────────────────────────────────────────────────
function escHtml(str) {
return String(str ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function fmtDate(ts) {
return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function fmtTimestamp(ms) {
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function fmtNumber(n) {
return Number(n).toLocaleString('de-DE');
}
function assignmentAbilityName(assignment, plan = null) {
const key = assignment?.ability ?? '';
return assignment?.abilityName ?? plan?.mitigationNames?.[key] ?? key;
}
function plannerLanguage() {
return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en';
}
async function ensureActionMetaLoaded() {
if (actionMetaPromise) return actionMetaPromise;
actionMetaPromise = (async () => {
let compact = {};
let full = {};
try {
const res = await fetch('assets/jsons/Action.json', { cache: 'no-cache' });
if (res.ok) compact = await res.json();
} catch { }
try {
const res = await fetch('assets/mitigation-actions.json', { cache: 'no-cache' });
if (res.ok) full = await res.json();
} catch { }
const byId = {};
const byName = {};
for (const [id, action] of Object.entries(full ?? {})) {
const description = action.Description_en ?? '';
const durations = [...description.matchAll(/Duration:\s*(\d+)s/gi)].map(m => parseInt(m[1], 10));
const meta = {
id,
name: action.Name_en ?? '',
cast: (parseInt(compact?.[id]?.cast ?? action.Cast100ms ?? 0, 10) || 0) / 10,
recast: (parseInt(compact?.[id]?.recast ?? action.Recast100ms ?? 0, 10) || 0) / 10,
duration: durations.find(Number.isFinite) ?? 15,
};
byId[id] = meta;
if (meta.name) byName[meta.name] = meta;
}
for (const [id, action] of Object.entries(compact ?? {})) {
byId[id] = {
...(byId[id] ?? { id }),
cast: (parseInt(action.cast ?? 0, 10) || 0) / 10,
recast: (parseInt(action.recast ?? 0, 10) || 0) / 10,
};
}
actionMetaById = byId;
actionMetaByName = byName;
})();
return actionMetaPromise;
}
function sameMechanic(existing, incoming, source) {
const fightStart = source?.fightStart ?? 0;
const incomingRel = incoming.timestamp - fightStart;
if (existing.abilityId && incoming.abilityId && existing.abilityId === incoming.abilityId) {
return Math.abs(existing.timestamp - incomingRel) < 1500;
}
return Math.abs(existing.timestamp - incomingRel) < 1500;
}
function visiblePlanMechanics(plan) {
return [...(plan?.mechanics ?? [])]
.filter(m => m?.id && String(m.name ?? '').trim() !== '' && Number.isFinite(Number(m.timestamp)))
.sort((a, b) => a.timestamp - b.timestamp);
}
// ── Rendering: Plan List ──────────────────────────────────────────────────────
function planItemHtml(p) {
return `
${escHtml(p.name)}
${p.mechanics.length} Mechaniken · ${fmtDate(p.updatedAt)}
📁
⎘
✕
`;
}
function renderPlanList() {
const el = document.getElementById('plan-list');
if (!el) return;
const plans = loadPlans();
const folders = loadFolders();
if (plans.length === 0 && folders.length === 0) {
el.innerHTML = 'Noch keine Pläne vorhanden
';
return;
}
const byFolder = new Map();
const ungrouped = [];
for (const p of plans) {
const fid = p.folderId ?? null;
if (fid && folders.some(f => f.id === fid)) {
if (!byFolder.has(fid)) byFolder.set(fid, []);
byFolder.get(fid).push(p);
} else {
ungrouped.push(p);
}
}
let html = folders.map(folder => {
const folderPlans = byFolder.get(folder.id) ?? [];
const collapsed = collapsedFolders.has(folder.id);
return `
▶
${escHtml(folder.name)}
${folderPlans.length}
✎
✕
${folderPlans.map(p => planItemHtml(p)).join('') || '
Leer
'}
`;
}).join('') + ungrouped.map(p => planItemHtml(p)).join('');
el.innerHTML = html || 'Noch keine Pläne vorhanden
';
// Plan item: open
el.querySelectorAll('.plan-item').forEach(row => {
row.addEventListener('click', e => {
if (e.target.closest('.plan-item-actions')) return;
openPlan(row.dataset.id);
});
});
// Plan item: move to folder
el.querySelectorAll('.plan-btn-move').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
showFolderPicker(btn.dataset.id, btn);
});
});
// Plan item: copy
el.querySelectorAll('.plan-btn-copy').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const copy = copyPlan(btn.dataset.id);
if (copy) { renderPlanList(); openPlan(copy.id); }
});
});
// Plan item: delete
el.querySelectorAll('.plan-btn-delete').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const plan = getPlan(btn.dataset.id);
if (!plan) return;
if (!confirm(`Plan "${plan.name}" löschen?`)) return;
deletePlan(btn.dataset.id);
if (activePlanId === btn.dataset.id) { activePlanId = null; renderPlanDetail(null); }
renderPlanList();
});
});
// Folder row: toggle collapse
el.querySelectorAll('.folder-row').forEach(row => {
row.addEventListener('click', e => {
if (e.target.closest('.folder-actions')) return;
const fid = row.dataset.folderId;
if (collapsedFolders.has(fid)) collapsedFolders.delete(fid);
else collapsedFolders.add(fid);
renderPlanList();
});
});
// Folder: rename
el.querySelectorAll('.folder-rename-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const fid = btn.dataset.id;
const folder = loadFolders().find(f => f.id === fid);
if (!folder) return;
const span = el.querySelector(`.folder-row[data-folder-id="${fid}"] .folder-name-text`);
if (!span) return;
const input = document.createElement('input');
input.className = 'folder-name-input';
input.value = folder.name;
span.replaceWith(input);
input.focus(); input.select();
let saved = false;
const save = () => {
if (saved) return; saved = true;
const name = input.value.trim();
if (name) updateFolder(fid, { name });
renderPlanList();
};
input.addEventListener('blur', save);
input.addEventListener('keydown', ev => {
if (ev.key === 'Enter') { ev.preventDefault(); save(); }
if (ev.key === 'Escape') { saved = true; renderPlanList(); }
});
});
});
// Folder: delete
el.querySelectorAll('.folder-delete-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const folder = loadFolders().find(f => f.id === btn.dataset.id);
if (!folder) return;
const count = (byFolder.get(btn.dataset.id) ?? []).length;
const msg = count > 0
? `Ordner "${folder.name}" löschen? Die ${count} enthaltenen Pläne werden nicht gelöscht.`
: `Ordner "${folder.name}" löschen?`;
if (!confirm(msg)) return;
deleteFolder(btn.dataset.id);
renderPlanList();
});
});
}
// ── Rendering: Plan Detail ────────────────────────────────────────────────────
function renderPlanDetail(plan) {
const noplan = document.getElementById('planner-no-plan');
const content = document.getElementById('plan-content');
if (!noplan || !content) return;
if (!plan) {
noplan.style.display = '';
content.style.display = 'none';
renderInfoPanel(null);
return;
}
noplan.style.display = 'none';
content.style.display = '';
const visibleMechanics = visiblePlanMechanics(plan);
content.innerHTML = `
Jobaufstellung
Namen + Job Import
${renderJobSlotsHtml(plan)}
Zeitstrahl
Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten
${renderTimelineHtml(plan)}
${renderTimelineSettingsHtml(plan)}
${renderMechanicListHtml(plan)}
`;
document.getElementById('plan-name-edit-btn')?.addEventListener('click', () => {
startRename(plan.id, plan.name);
});
initJobSlots(plan.id);
document.getElementById('name-import-open-btn')?.addEventListener('click', () => {
showNameImportModal(plan.id);
});
initTimeline(plan.id);
initMechanicClicks(plan.id);
renderInfoPanel(plan);
ensureActionMetaLoaded().then(() => refreshTimeline(plan.id));
}
function avgNonTankMaxHp(plan) {
const roster = plan.playerRoster ?? [];
const jobComp = plan.jobComposition ?? [];
const hps = jobComp
.map((job, i) => ({ job, maxHp: roster[i]?.maxHp ?? 0 }))
.filter(p => p.job && JOB_ROLE[p.job] !== 'tank' && p.maxHp > 0)
.map(p => p.maxHp);
if (!hps.length) return 0;
return Math.round(hps.reduce((s, v) => s + v, 0) / hps.length);
}
function simulateDrMultiplier(mechanic, assignments = mechanic.assignments ?? []) {
let mult = 1;
for (const a of assignments) {
if (a.buffType === 'shield') continue;
mult *= (1 - (ABILITY_DR[a.ability] ?? 0));
}
return mult;
}
function plannedAssignmentsForMechanic(plan, targetMechanic) {
const result = [];
const targetTime = Number(targetMechanic.timestamp);
const tolerance = 50;
for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: canonicalMechanicKey })) {
if (targetTime < entry.start - tolerance || targetTime > entry.end + tolerance) continue;
result.push({
...entry.assignment,
sourceMechanicId: entry.mechanic.id,
sourceStart: entry.start,
});
}
return result;
}
function renderMechanicListHtml(plan) {
const mechanics = visiblePlanMechanics(plan);
if (mechanics.length === 0) {
return `
📋
Noch keine Mechaniken
Importiere einen Log aus dem Analyse-Tab
`;
}
const activeJobSet = new Set(plan.jobComposition.filter(j => j));
const avgHp = avgNonTankMaxHp(plan);
return mechanics.map(m => {
const planned = plannedAssignmentsForMechanic(plan, m);
const sorted = sortedAssignments(planned);
const assignHtml = sorted.length === 0
? 'Keine Zuweisung '
: sorted.map(a => {
const cls = a.buffType === 'debuff' ? 'badge-assign-debuff'
: a.buffType === 'shield' ? 'badge-assign-shield'
: 'badge-assign-buff';
const isMissing = !!a.job && !activeJobSet.has(a.job);
const icon = MITIG_ICONS[a.ability] ?? '';
const ability = assignmentAbilityName(a, plan);
const label = a.job ? `${escHtml(a.job)} · ${escHtml(ability)}` : escHtml(ability);
const title = isMissing ? `${escHtml(a.job)} nicht in Jobaufstellung` : '';
const suggestions = isMissing ? findEquivSuggestions(a.ability, activeJobSet) : [];
const badgeHtml = `
${icon ? ` ` : ''}
${label}
×
`;
const hintHtml = suggestions.map(s =>
`→ ${escHtml(s.ability)} (${escHtml(s.job)})? `
).join('');
const noEquivHint = isMissing && suggestions.length === 0
? `→ Kein Äquivalent! ` : '';
const jobHint = !a.job ? `→ Job zuordnen ` : '';
const needsWrap = suggestions.length > 0 || !!noEquivHint || !!jobHint;
return needsWrap
? `${badgeHtml}${hintHtml}${noEquivHint}${jobHint} `
: badgeHtml;
}).join('');
const drOnly = m.unmitigatedDamage ? Math.round(m.unmitigatedDamage * simulateDrMultiplier(m, planned)) : 0;
const shieldVal = (plan.shieldK ?? 0) * 1000;
const mitigFull = Math.max(0, drOnly - shieldVal);
const hasDrAssign = planned.some(a => a.buffType !== 'shield' && (ABILITY_DR[a.ability] ?? 0) > 0);
const hasShield = shieldVal > 0;
const drOnlyCls = avgHp ? (drOnly <= avgHp ? 'mechanic-mitig--ok' : 'mechanic-mitig--risk') : '';
const fullCls = avgHp ? (mitigFull <= avgHp ? 'mechanic-mitig--ok' : 'mechanic-mitig--risk') : '';
return `
${escHtml(fmtTimestamp(m.timestamp))}
${m.phase ? `
${escHtml(m.phase)}
` : ''}
${escHtml(m.name)}
${m.unmitigatedDamage
? `
${fmtNumber(m.unmitigatedDamage)} unmitigiert${avgHp ? ` ∅ ${fmtNumber(avgHp)} HP ` : ''}
`
: ''
}
${hasDrAssign || hasShield ? `
${hasDrAssign ? `→ ${fmtNumber(drOnly)} mitigiert` : ''}
${hasShield ? `Mitigation mit Schild ${fmtNumber(mitigFull)} ` : ''}
` : ''}
${assignHtml}
${m.notes ? `
${escHtml(m.notes)}
` : ''}
Klicken zum Bearbeiten
✕
`;
}).join('');
}
// ── Job Slots ─────────────────────────────────────────────────────────────────
function renderJobSlotsHtml(plan) {
const roster = plan.playerRoster ?? [];
return Array.from({ length: 8 }, (_, i) => {
const job = plan.jobComposition[i] ?? '';
const role = JOB_ROLE[job] ?? '';
const playerName = roster[i]?.name ?? '';
return `
—
${ALL_JOBS.map(g =>
`${g.jobs.map(j =>
`${j} `
).join('')} `
).join('')}
${playerName ? `
${escHtml(playerName)}
` : ''}
`;
}).join('');
}
function initJobSlots(planId) {
const grid = document.getElementById('job-slots-grid');
if (!grid) return;
grid.addEventListener('change', e => {
const sel = e.target.closest('.job-slot-select');
if (!sel) return;
const plan = getPlan(planId);
if (!plan) return;
const comp = [...plan.jobComposition];
comp[parseInt(sel.dataset.idx, 10)] = sel.value;
updatePlan(planId, { jobComposition: comp });
const slot = sel.closest('.job-slot');
if (slot) {
const role = JOB_ROLE[sel.value] ?? '';
slot.className = 'job-slot' + (role ? ` job-slot--${role}` : '');
}
refreshMechanicList(planId);
});
}
// ── Timeline ─────────────────────────────────────────────────────────────────
function planDurationMs(plan) {
const sourceDuration = (plan.source?.fightEnd ?? 0) - (plan.source?.fightStart ?? 0);
const mechanicEnd = Math.max(0, ...visiblePlanMechanics(plan).map(m => (m.timestamp ?? 0) + 30000));
return Math.max(sourceDuration, mechanicEnd, 60000);
}
function timelineScale(plan) {
const duration = planDurationMs(plan);
const pxPerSecond = 8;
return { duration, width: Math.max(720, Math.ceil(duration / 1000 * pxPerSecond)) };
}
function timelinePlayerRows(plan) {
const roster = plan.playerRoster ?? [];
const rows = [];
(plan.jobComposition ?? []).forEach((job, idx) => {
if (!job) return;
const name = roster[idx]?.name ?? '';
const role = JOB_ROLE[job] ?? '';
const abilities = (JOB_ABILITIES[job] ?? [])
.filter(ab => ab.buffType !== 'shield' || ab.name === 'Panhaima');
abilities.forEach((ab, abilityIdx) => {
rows.push({ idx, job, ability: ab.name, buffType: ab.buffType, name, role, firstForJob: abilityIdx === 0 });
});
});
return rows;
}
function selectedAssignmentMatches(mechanicId, assignment) {
return selectedTimelineAssignment
&& selectedTimelineAssignment.mechanicId === mechanicId
&& selectedTimelineAssignment.ability === assignment.ability
&& selectedTimelineAssignment.job === (assignment.job ?? '');
}
function jobCanUseAbility(job, ability) {
return (JOB_ABILITIES[job] ?? []).some(a => a.name === ability);
}
function timelineRowsForAssignment(rows, assignment) {
const assignedJob = assignment.job ?? '';
return rows.filter(row => {
if (row.ability !== assignment.ability) return false;
if (assignedJob) return row.job === assignedJob;
return true; // unresolved: show in all rows for this ability
});
}
function assignmentStartMs(mechanic, assignment) {
return Number.isFinite(Number(assignment?.timestamp)) ? Number(assignment.timestamp) : mechanic.timestamp;
}
function canonicalMechanicKey(entry) {
return `${entry.assignment.ability}::${entry.assignment.job || '*'}`;
}
function canonicalTimelineKey(entry, row) {
return `${row.job}::${row.ability}`;
}
function canonicalAssignmentActivations(plan, { dedupeKey = canonicalMechanicKey, includeEntry = null } = {}) {
const entries = [];
for (const mechanic of visiblePlanMechanics(plan)) {
for (const assignment of mechanic.assignments ?? []) {
const start = assignmentStartMs(mechanic, assignment);
const durationSec = assignmentDurationSeconds(assignment);
entries.push({
mechanic,
assignment,
start,
durationSec,
end: start + durationSec * 1000,
});
}
}
entries.sort((a, b) => a.start - b.start);
const activeUntilBySkill = new Map();
return entries.filter(entry => {
if (includeEntry && !includeEntry(entry)) return false;
const key = dedupeKey(entry);
const activeUntil = activeUntilBySkill.get(key) ?? -Infinity;
if (entry.start <= activeUntil) return false;
activeUntilBySkill.set(key, entry.end);
return true;
});
}
function assignmentEntryForRef(plan, ref) {
if (!plan || !ref) return null;
const mechanic = (plan.mechanics ?? []).find(m => m.id === ref.mechanicId);
if (!mechanic) return null;
const assignment = (mechanic.assignments ?? []).find(a =>
a.ability === ref.ability && (a.job ?? '') === ref.job
);
if (!assignment) return null;
const start = assignmentStartMs(mechanic, assignment);
return {
mechanic,
assignment,
start,
end: start + assignmentWindowMs(assignment),
};
}
function assignmentRowKeys(plan, assignment) {
const assignedJob = assignment.job ?? '';
if (assignedJob) return new Set([`${assignedJob}::${assignment.ability}`]);
const jobs = (plan.jobComposition ?? []).filter(job => jobCanUseAbility(job, assignment.ability));
return new Set(jobs.map(job => `${job}::${assignment.ability}`));
}
function assignmentEntriesShareRow(plan, left, right) {
const leftRows = assignmentRowKeys(plan, left.assignment);
const rightRows = assignmentRowKeys(plan, right.assignment);
for (const row of leftRows) {
if (rightRows.has(row)) return true;
}
return false;
}
function assignmentsOverlapActiveFrame(plan, left, right) {
return left.assignment.ability === right.assignment.ability
&& assignmentEntriesShareRow(plan, left, right)
&& left.start < right.end
&& left.end > right.start;
}
function compactActivationCopies(plan, keeperRef) {
const keeper = assignmentEntryForRef(plan, keeperRef);
if (!keeper) return false;
let changed = false;
for (const mechanic of visiblePlanMechanics(plan)) {
const assignments = mechanic.assignments ?? [];
const next = assignments.filter(assignment => {
if (assignment === keeper.assignment) return true;
if (assignment.ability !== keeper.assignment.ability) return true;
const start = assignmentStartMs(mechanic, assignment);
const entry = {
mechanic,
assignment,
start,
end: start + assignmentWindowMs(assignment),
};
const remove = assignmentsOverlapActiveFrame(plan, keeper, entry);
if (remove) changed = true;
return !remove;
});
if (next.length !== assignments.length) mechanic.assignments = next;
}
return changed;
}
function removeActivationGroup(plan, ref) {
const keeper = assignmentEntryForRef(plan, ref);
if (!keeper) return false;
let changed = false;
for (const mechanic of visiblePlanMechanics(plan)) {
const assignments = mechanic.assignments ?? [];
const next = assignments.filter(assignment => {
if (assignment.ability !== keeper.assignment.ability) return true;
const start = assignmentStartMs(mechanic, assignment);
const entry = {
mechanic,
assignment,
start,
end: start + assignmentWindowMs(assignment),
};
const remove = assignment === keeper.assignment || assignmentsOverlapActiveFrame(plan, keeper, entry);
if (remove) changed = true;
return !remove;
});
if (next.length !== assignments.length) mechanic.assignments = next;
}
return changed;
}
function normalizeActivationCopies(plan) {
const entries = [];
for (const mechanic of visiblePlanMechanics(plan)) {
for (const assignment of mechanic.assignments ?? []) {
const start = assignmentStartMs(mechanic, assignment);
entries.push({
mechanic,
assignment,
start,
end: start + assignmentWindowMs(assignment),
});
}
}
entries.sort((a, b) => a.start - b.start);
const keepers = [];
const removals = new Set();
for (const entry of entries) {
if (keepers.some(keeper => assignmentsOverlapActiveFrame(plan, keeper, entry))) {
removals.add(entry.assignment);
} else {
keepers.push(entry);
}
}
if (!removals.size) return false;
for (const mechanic of visiblePlanMechanics(plan)) {
const assignments = mechanic.assignments ?? [];
const next = assignments.filter(assignment => !removals.has(assignment));
if (next.length !== assignments.length) mechanic.assignments = next;
}
return true;
}
function findNearestMechanic(plan, timestamp) {
const mechanics = visiblePlanMechanics(plan);
if (!mechanics.length) return null;
return mechanics.reduce((best, mechanic) =>
Math.abs(mechanic.timestamp - timestamp) < Math.abs(best.timestamp - timestamp) ? mechanic : best
, mechanics[0]);
}
function timelineTimestampFromEvent(plan, track, event) {
const rect = track.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, event.clientX - rect.left));
return (x / rect.width) * planDurationMs(plan);
}
function layoutBossActions(mechanics, duration) {
const lanes = [];
const minGapPct = 9;
return mechanics.map(mechanic => {
const left = (mechanic.timestamp / duration) * 100;
let lane = lanes.findIndex(lastLeft => left - lastLeft >= minGapPct);
if (lane === -1) {
lane = lanes.length;
lanes.push(left);
} else {
lanes[lane] = left;
}
return { mechanic, left, lane };
});
}
function assignmentWindowMs(assignment) {
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 id = String(assignment?.actionId ?? assignment?.extraAbilityGameID ?? '');
return (id && actionMetaById[id]) || actionMetaByName[assignment?.ability] || null;
}
function assignmentCooldownSeconds(assignment) {
const own = Number(assignment?.cooldownSeconds);
if (Number.isFinite(own) && own >= 0) return own;
return actionMetaForAssignment(assignment)?.recast ?? 0;
}
function assignmentDurationSeconds(assignment) {
const own = Number(assignment?.durationSeconds);
if (Number.isFinite(own) && own > 0) return own;
return actionMetaForAssignment(assignment)?.duration ?? (assignment?.buffType === 'shield' ? 20 : 15);
}
function renderTimelineHtml(plan) {
const mechanics = visiblePlanMechanics(plan);
if (!mechanics.length) {
return 'Importiere Mechaniken aus dem Analyse-Tab, um den Zeitstrahl zu nutzen.
';
}
const { duration, width } = timelineScale(plan);
const rows = timelinePlayerRows(plan);
const marks = [];
const tick = 10000;
for (let t = 0; t <= duration; t += tick) {
marks.push(`${fmtTimestamp(t)} `);
}
const bossActions = layoutBossActions(mechanics, duration);
const laneCount = Math.max(1, ...bossActions.map(item => item.lane + 1));
const hitLines = mechanics.map(m => `
`).join('');
const bossItems = bossActions.map(({ mechanic: m, left, lane }) => `
${escHtml(m.name)}
`).join('');
const playerRows = rows.map(row => {
const blocks = [];
for (const entry of canonicalAssignmentActivations(plan, {
dedupeKey: item => canonicalTimelineKey(item, row),
includeEntry: item => {
const assignment = item.assignment;
if (assignment.ability !== row.ability) return false;
const assignedJob = assignment.job ?? '';
return !assignedJob || assignedJob === row.job;
},
})) {
const item = entry;
const m = item.mechanic;
const a = item.assignment;
const start = item.start;
const durationSec = item.durationSec;
const cooldownSec = assignmentCooldownSeconds(a);
const left = Math.max(0, Math.min(100, (start / duration) * 100));
const widthPct = Math.max(1.2, Math.min(100 - left, (durationSec * 1000 / duration) * 100));
const cdWidthPct = cooldownSec > 0 ? Math.max(widthPct, Math.min(100 - left, (cooldownSec * 1000 / duration) * 100)) : widthPct;
const activeWidthPct = Math.min(100, (widthPct / cdWidthPct) * 100);
const selected = selectedAssignmentMatches(m.id, a) ? ' selected' : '';
const unresolved = a.job ? '' : ' timeline-mitigation--candidate';
const cls = a.buffType === 'debuff' ? 'timeline-mitigation--debuff'
: a.buffType === 'shield' ? 'timeline-mitigation--shield'
: 'timeline-mitigation--buff';
const icon = MITIG_ICONS[a.ability] ?? '';
const abilityLabel = assignmentAbilityName(a, plan);
blocks.push(`
${icon ? ` ` : ''}
${escHtml(abilityLabel)}
`);
}
const icon = MITIG_ICONS[row.ability] ?? '';
const abilityDisplayName = plan.mitigationNames?.[row.ability] ?? row.ability;
const jobStartCls = row.firstForJob ? ' timeline-player-row--job-start' : '';
return `
${escHtml(row.job)}
${icon ? ` ` : ''}
${escHtml(abilityDisplayName)}
${hitLines}${blocks.join('')}
`;
}).join('');
return `
`;
}
function findTimelineAssignment(plan, selected = selectedTimelineAssignment) {
if (!plan || !selected) return null;
const mechanic = (plan.mechanics ?? []).find(m => m.id === selected.mechanicId);
if (!mechanic) return null;
const assignment = (mechanic.assignments ?? []).find(a =>
a.ability === selected.ability && (a.job ?? '') === selected.job
);
return assignment ? { mechanic, assignment } : null;
}
function renderTimelineSettingsHtml(plan) {
const found = findTimelineAssignment(plan);
if (!found) {
return 'Mitigation im Zeitstrahl auswählen, um Dauer und Cooldown anzupassen.
';
}
const { mechanic, assignment } = found;
const ability = assignmentAbilityName(assignment, plan);
const jobLabel = assignment.job || 'Nicht zugewiesen';
return `
`;
}
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;
mechanic.assignments = mechanic.assignments ?? [];
const assignment = {
ability,
abilityName: plan.mitigationNames?.[ability],
actionId: actionMetaByName[ability]?.id ?? null,
job,
buffType,
timestamp: Math.max(0, Math.round(timestamp)),
};
if (assignmentOverlapsJob(plan, job, ability, assignment.timestamp, null, assignment)) return;
mechanic.assignments.push(assignment);
selectedTimelineAssignment = { mechanicId, ability, job };
updatePlan(planId, { mechanics: plan.mechanics });
refreshMechanicList(planId);
}
function closeTimelineMenu() {
document.getElementById('timeline-context-menu')?.remove();
}
function showTimelineMenu(x, y, items) {
closeTimelineMenu();
const menu = document.createElement('div');
menu.id = 'timeline-context-menu';
menu.className = 'timeline-context-menu';
menu.innerHTML = items.length
? items.map((item, idx) => `
`).join('')
: '';
document.body.appendChild(menu);
const rect = menu.getBoundingClientRect();
menu.style.left = Math.min(x, window.innerWidth - rect.width - 8) + 'px';
menu.style.top = Math.min(y, window.innerHeight - rect.height - 8) + 'px';
menu.addEventListener('click', e => {
const btn = e.target.closest('.timeline-menu-item');
if (!btn || btn.disabled) return;
items[parseInt(btn.dataset.idx, 10)]?.onClick?.();
closeTimelineMenu();
document.removeEventListener('click', closeOutside, true);
document.removeEventListener('contextmenu', closeOutside, true);
});
const closeOutside = ev => {
if (menu.contains(ev.target)) return;
closeTimelineMenu();
document.removeEventListener('click', closeOutside, true);
document.removeEventListener('contextmenu', closeOutside, true);
};
setTimeout(() => {
document.addEventListener('click', closeOutside, true);
document.addEventListener('contextmenu', closeOutside, true);
}, 0);
}
function updateTimelineAssignmentPosition(planId, mechanicId, ability, job, rowJob, timestamp) {
const plan = getPlan(planId);
if (!plan) return;
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job);
if (!assignment) return;
if (!jobCanUseAbility(rowJob, ability)) return;
compactActivationCopies(plan, { mechanicId, ability, job });
const nextTimestamp = Math.max(0, Math.round(timestamp));
if (assignmentOverlapsJob(plan, rowJob, ability, nextTimestamp, { mechanicId, ability, job }, assignment)) return;
assignment.timestamp = nextTimestamp;
assignment.job = rowJob;
selectedTimelineAssignment = { mechanicId, ability, job: rowJob };
updatePlan(planId, { mechanics: plan.mechanics });
refreshTimeline(planId);
refreshMechanicList(planId, false);
}
function initTimeline(planId) {
const timeline = document.getElementById('planner-timeline');
const settings = document.getElementById('timeline-settings');
if (!timeline || !settings) return;
let timelinePan = null;
let suppressNextTimelineClick = false;
let timelineDrag = null;
function removeDragPreview() {
document.getElementById('timeline-drag-preview')?.remove();
timeline.querySelectorAll('.timeline-player-row--drop-ok, .timeline-player-row--drop-bad')
.forEach(row => row.classList.remove('timeline-player-row--drop-ok', 'timeline-player-row--drop-bad'));
}
function updateDragPreview(event) {
if (!timelineDrag) return;
removeDragPreview();
const track = event.target.closest('.timeline-player-row .timeline-track');
const row = event.target.closest('.timeline-player-row');
if (!track || !row) return;
const plan = getPlan(planId);
if (!plan) return;
const rect = track.getBoundingClientRect();
const duration = planDurationMs(plan);
const deltaPx = event.clientX - timelineDrag.startClientX;
const timestamp = Math.max(0, timelineDrag.startTimestamp + (deltaPx / rect.width) * duration);
const left = Math.max(0, Math.min(100, (timestamp / duration) * 100));
const durationPct = Math.max(1.2, Math.min(100 - left, (timelineDrag.durationSec * 1000 / duration) * 100));
const cooldownPct = Math.max(durationPct, Math.min(100 - left, (timelineDrag.cooldownSec * 1000 / duration) * 100));
const activePct = Math.min(100, (durationPct / cooldownPct) * 100);
const valid = row.dataset.ability === timelineDrag.ability
&& jobCanUseAbility(row.dataset.job, timelineDrag.ability)
&& !assignmentOverlapsJob(plan, row.dataset.job, timelineDrag.ability, timestamp, {
mechanicId: timelineDrag.mechanicId,
ability: timelineDrag.ability,
job: timelineDrag.job,
}, {
ability: timelineDrag.ability,
job: row.dataset.job,
durationSeconds: timelineDrag.durationSec,
cooldownSeconds: timelineDrag.cooldownSec,
});
row.classList.add(valid ? 'timeline-player-row--drop-ok' : 'timeline-player-row--drop-bad');
const preview = document.createElement('div');
preview.id = 'timeline-drag-preview';
preview.className = `timeline-drag-preview ${valid ? '' : 'timeline-drag-preview--bad'}`;
preview.style.left = `${left}%`;
preview.style.setProperty('--cd-width', `${cooldownPct}%`);
preview.style.setProperty('--active-width', `${activePct}%`);
preview.innerHTML = `
${timelineDrag.icon ? ` ` : ''}
${escHtml(timelineDrag.label)}
`;
track.appendChild(preview);
}
timeline.addEventListener('pointerdown', e => {
if (e.button !== 0) return;
if (!e.target.closest('.timeline-scroll')) return;
if (e.target.closest('.timeline-mitigation, .timeline-boss-action, .timeline-context-menu')) return;
const scroll = e.target.closest('.timeline-scroll');
timelinePan = {
scroll,
pointerId: e.pointerId,
startX: e.clientX,
startScrollLeft: scroll.scrollLeft,
moved: false,
};
scroll.setPointerCapture?.(e.pointerId);
});
timeline.addEventListener('pointermove', e => {
if (!timelinePan || timelinePan.pointerId !== e.pointerId) return;
const dx = e.clientX - timelinePan.startX;
if (Math.abs(dx) > 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 boss = e.target.closest('.timeline-boss-action');
if (boss) {
showAbilityModal(planId, boss.dataset.mechanicId);
return;
}
const block = e.target.closest('.timeline-mitigation');
if (block) {
selectedTimelineAssignment = {
mechanicId: block.dataset.mechanicId,
ability: block.dataset.ability,
job: block.dataset.job,
};
refreshTimeline(planId);
const plan = getPlan(planId);
const rows = timelinePlayerRows(plan ?? {});
const found = findTimelineAssignment(plan, selectedTimelineAssignment);
const timestamp = found ? assignmentStartMs(found.mechanic, found.assignment) : 0;
const items = rows
.filter(row => jobCanUseAbility(row.job, block.dataset.ability))
.filter((row, idx, arr) => arr.findIndex(r => r.job === row.job) === idx)
.map(row => ({
label: `${row.job}${row.name ? ` · ${row.name}` : ''}`,
icon: MITIG_ICONS[block.dataset.ability] ?? '',
disabled: assignmentOverlapsJob(plan, row.job, block.dataset.ability, timestamp, {
mechanicId: block.dataset.mechanicId,
ability: block.dataset.ability,
job: block.dataset.job,
}, found?.assignment ?? null),
onClick: () => setTimelineAssignmentJob(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job, row.job),
}));
showTimelineMenu(e.clientX, e.clientY, items);
return;
}
// 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: 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 = `
`;
if (!plan) { el.innerHTML = legendHtml; return; }
const activeJobSet = new Set(plan.jobComposition.filter(j => j));
const noJobAbilities = new Set();
const missingJobPairs = new Set();
for (const m of visiblePlanMechanics(plan)) {
for (const a of m.assignments) {
if (!a.job) {
noJobAbilities.add(a.ability);
} else if (!activeJobSet.has(a.job)) {
missingJobPairs.add(`${a.job} · ${a.ability}`);
}
}
}
let warningsHtml = '';
if (noJobAbilities.size > 0 || missingJobPairs.size > 0) {
warningsHtml = `Hinweise
`;
if (noJobAbilities.size > 0) {
warningsHtml += `
Fähigkeiten ohne Job-Zuordnung
`;
}
if (missingJobPairs.size > 0) {
warningsHtml += `
Fähigkeiten mit fehlendem Job
`;
}
warningsHtml += '
';
}
const extraInfoHtml = `
`;
el.innerHTML = legendHtml + warningsHtml + extraInfoHtml;
const shieldInput = el.querySelector('#plan-shield-k-input');
if (shieldInput) {
shieldInput.addEventListener('change', () => {
const val = parseFloat(shieldInput.value) || 0;
updatePlan(plan.id, { shieldK: val > 0 ? val : null });
refreshMechanicList(plan.id);
});
}
}
function removeAssignment(planId, mechanicId, abilityName, job = null) {
const plan = getPlan(planId);
if (!plan) return;
let removed = false;
if (job === null) {
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
const jobs = [...new Set((mechanic?.assignments ?? [])
.filter(a => a.ability === abilityName)
.map(a => a.job ?? ''))];
jobs.forEach(assignmentJob => {
removed = removeActivationGroup(plan, { mechanicId, ability: abilityName, job: assignmentJob }) || removed;
});
} else {
removed = removeActivationGroup(plan, { mechanicId, ability: abilityName, job });
}
if (!removed) return;
updatePlan(planId, { mechanics: plan.mechanics });
refreshMechanicList(planId);
if (abilityModalMechanicId === mechanicId) renderAbilityModalContent();
}
function deleteMechanic(planId, mechanicId) {
const plan = getPlan(planId);
if (!plan) return;
plan.mechanics = plan.mechanics.filter(m => m.id !== mechanicId);
updatePlan(planId, { mechanics: plan.mechanics });
refreshMechanicList(planId);
renderPlanList();
}
function initMechanicClicks(planId) {
const list = document.getElementById('mechanic-list');
if (!list) return;
list.addEventListener('click', e => {
const removeBtn = e.target.closest('.badge-remove');
if (removeBtn) {
e.stopPropagation();
removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability, removeBtn.dataset.job ?? null);
return;
}
const deleteBtn = e.target.closest('.mechanic-delete-btn');
if (deleteBtn) {
e.stopPropagation();
deleteMechanic(planId, deleteBtn.dataset.mechanicId);
return;
}
const card = e.target.closest('.mechanic-card');
if (!card) return;
showAbilityModal(planId, card.dataset.mechanicId);
});
list.addEventListener('contextmenu', e => {
const badge = e.target.closest('.badge-assign');
if (!badge) return;
e.preventDefault();
e.stopPropagation();
const removeBtn = badge.querySelector('.badge-remove');
if (removeBtn) removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability, removeBtn.dataset.job ?? null);
});
}
// ── Rename ────────────────────────────────────────────────────────────────────
function startRename(id, currentName) {
const display = document.getElementById('plan-name-display');
const editBtn = document.getElementById('plan-name-edit-btn');
if (!display) return;
display.innerHTML = ` `;
const input = document.getElementById('plan-name-input');
input.focus();
input.select();
if (editBtn) editBtn.style.display = 'none';
let saved = false;
const save = () => {
if (saved) return;
saved = true;
const newName = input.value.trim() || currentName;
updatePlan(id, { name: newName });
renderPlanList();
openPlan(id);
};
input.addEventListener('keydown', e => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') {
saved = true;
display.innerHTML = `${escHtml(currentName)} `;
if (editBtn) editBtn.style.display = '';
}
});
input.addEventListener('blur', save);
}
// ── Open plan ─────────────────────────────────────────────────────────────────
function openPlan(id) {
activePlanId = id;
localStorage.setItem(PLANNER_ACTIVE_KEY, id);
renderPlanList();
renderPlanDetail(getPlan(id));
refreshPlanLanguage(id);
}
// ── New plan form ─────────────────────────────────────────────────────────────
function initNewPlanForm() {
const btn = document.getElementById('planner-new-btn');
const form = document.getElementById('planner-new-form');
const input = document.getElementById('planner-new-name');
const save = document.getElementById('planner-new-save');
const cancel = document.getElementById('planner-new-cancel');
if (!btn) return;
btn.addEventListener('click', () => {
form.style.display = '';
input.value = '';
input.focus();
});
cancel.addEventListener('click', () => { form.style.display = 'none'; });
const clearErr = () => {
input.style.borderColor = '';
document.getElementById('planner-new-name-err')?.remove();
};
const doCreate = () => {
const name = input.value.trim();
if (!name) { input.focus(); return; }
if (loadPlans().some(p => p.name === name)) {
input.style.borderColor = 'var(--red)';
let err = document.getElementById('planner-new-name-err');
if (!err) {
err = document.createElement('div');
err.id = 'planner-new-name-err';
err.style.cssText = 'font-size:11px;color:var(--red);margin-top:3px';
input.insertAdjacentElement('afterend', err);
}
err.textContent = 'Name bereits vergeben';
input.focus();
return;
}
clearErr();
const plan = createPlan(name);
form.style.display = 'none';
renderPlanList();
openPlan(plan.id);
};
input.addEventListener('input', clearErr);
save.addEventListener('click', doCreate);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') doCreate();
if (e.key === 'Escape') { clearErr(); form.style.display = 'none'; }
});
}
// ── Folder Picker ─────────────────────────────────────────────────────────────
let folderPickerCleanup = null;
function closeFolderPicker() {
document.getElementById('folder-picker')?.remove();
if (folderPickerCleanup) { folderPickerCleanup(); folderPickerCleanup = null; }
}
function showFolderPicker(planId, anchorEl) {
closeFolderPicker();
const plan = getPlan(planId);
const folders = loadFolders();
if (!plan) return;
if (folders.length === 0) return;
const rect = anchorEl.getBoundingClientRect();
const picker = document.createElement('div');
picker.id = 'folder-picker';
picker.className = 'folder-picker';
picker.style.cssText = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;z-index:9999`;
picker.innerHTML = [{ id: null, name: 'Kein Ordner' }, ...folders].map(f => `
${f.id === null ? '— ' : '📁 '}${escHtml(f.name)}
`).join('');
document.body.appendChild(picker);
picker.querySelectorAll('.folder-picker-option').forEach(opt => {
opt.addEventListener('click', e => {
e.stopPropagation();
updatePlan(planId, { folderId: opt.dataset.fid || null });
closeFolderPicker();
renderPlanList();
});
});
const onOutside = e => { if (!picker.contains(e.target)) closeFolderPicker(); };
setTimeout(() => document.addEventListener('click', onOutside), 0);
folderPickerCleanup = () => document.removeEventListener('click', onOutside);
}
// ── New Folder Form ───────────────────────────────────────────────────────────
function initNewFolderForm() {
const btn = document.getElementById('planner-new-folder-btn');
const form = document.getElementById('planner-new-folder-form');
const input = document.getElementById('planner-new-folder-name');
const save = document.getElementById('planner-new-folder-save');
const cancel = document.getElementById('planner-new-folder-cancel');
if (!btn) return;
btn.addEventListener('click', () => { form.style.display = ''; input.value = ''; input.focus(); });
cancel.addEventListener('click', () => { form.style.display = 'none'; });
const doCreate = () => {
const name = input.value.trim();
if (!name) { input.focus(); return; }
createFolder(name);
form.style.display = 'none';
renderPlanList();
};
save.addEventListener('click', doCreate);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') doCreate();
if (e.key === 'Escape') form.style.display = 'none';
});
}
// ── Shared FFXIV metadata ─────────────────────────────────────────────────────
const {
ABILITY_DR,
ABILITY_JOB_MAP,
ALL_JOBS,
CASTER_JOBS,
JOB_ABILITIES,
JOB_FROM_TYPE,
JOB_ROLE,
MELEE_JOBS,
MITIG_ICONS,
TANK_JOBS,
} = window.FF14_DATA;
// Groups of abilities that are functionally equivalent across different jobs.
// Used to suggest replacements when a job is missing from the composition.
const ABILITY_EQUIVALENTS = [
['Troubadour', 'Tactician', 'Shield Samba'],
['Sacred Soil', 'Kerachole'],
];
function findEquivSuggestions(abilityName, activeJobSet) {
const group = ABILITY_EQUIVALENTS.find(g => g.includes(abilityName));
if (!group) return [];
const suggestions = [];
for (const equiv of group) {
if (equiv === abilityName) continue;
for (const job of activeJobSet) {
if ((JOB_ABILITIES[job] ?? []).some(a => a.name === equiv)) {
suggestions.push({ ability: equiv, job });
break;
}
}
}
return suggestions;
}
const ASSIGN_ORDER = { debuff: 0, buff: 1, shield: 2 };
function sortedAssignments(assignments) {
return [...assignments].sort((a, b) =>
(ASSIGN_ORDER[a.buffType] ?? 1) - (ASSIGN_ORDER[b.buffType] ?? 1)
);
}
function guessJob(abilityName, players) {
if (ABILITY_JOB_MAP[abilityName]) return ABILITY_JOB_MAP[abilityName];
const jobs = (players ?? []).map(p => JOB_FROM_TYPE[p.type] ?? '');
if (abilityName === 'Reprisal') {
const tanks = jobs.filter(j => TANK_JOBS.has(j));
return tanks.length === 1 ? tanks[0] : '';
}
if (abilityName === 'Feint') {
const melees = jobs.filter(j => MELEE_JOBS.has(j));
return melees.length === 1 ? melees[0] : '';
}
if (abilityName === 'Addle') {
const casters = jobs.filter(j => CASTER_JOBS.has(j));
return casters.length === 1 ? casters[0] : '';
}
return '';
}
function mitigationDisplayName(mitigation) {
return mitigation?.name ?? mitigation?.key ?? '';
}
function mitigationKey(mitigation) {
return mitigation?.key ?? mitigation?.name ?? '';
}
// ── AoE Events → Plan Mechanics ───────────────────────────────────────────────
function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations, mitigationNames = {}) {
return aoeEvents.map(ev => {
const relTs = ev.timestamp - fightStart;
const phase = (phases ?? []).filter(p => p.id !== 0).find(p =>
ev.timestamp >= p.startTime && ev.timestamp < p.endTime
);
const nonTankTargets = ev.targets.filter(t => t.role !== 'tank' && (t.unmitigatedAmount ?? 0) > 0);
const fallbackTargets = ev.targets.filter(t => (t.unmitigatedAmount ?? 0) > 0);
const relevantTargets = nonTankTargets.length > 0 ? nonTankTargets : fallbackTargets;
const avgUnmit = relevantTargets.length > 0
? Math.round(relevantTargets.reduce((s, t) => s + t.unmitigatedAmount, 0) / relevantTargets.length)
: 0;
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,
notes: '',
assignments,
};
});
}
// ── Merge + Create plan from import ──────────────────────────────────────────
function buildPlayerRoster(players, aoeEvents) {
const maxHpByName = {};
for (const ev of aoeEvents ?? []) {
for (const t of ev.targets ?? []) {
if (t.name && t.maxHp > 0 && !(t.name in maxHpByName)) {
maxHpByName[t.name] = t.maxHp;
}
}
}
const order = { healer: 0, dps: 1, tank: 2 };
const sorted = [...(players ?? [])]
.filter(p => JOB_FROM_TYPE[p.type])
.sort((a, b) => {
const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1);
return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name);
});
const roster = sorted.slice(0, 8).map(p => ({
name: p.name,
maxHp: maxHpByName[p.name] ?? 0,
}));
while (roster.length < 8) roster.push({ name: '', maxHp: 0 });
return roster;
}
function extractJobComp(players) {
const order = { healer: 0, dps: 1, tank: 2 };
const sorted = [...(players ?? [])]
.filter(p => JOB_FROM_TYPE[p.type])
.sort((a, b) => {
const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1);
return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name);
});
const comp = sorted.map(p => JOB_FROM_TYPE[p.type] ?? '').slice(0, 8);
while (comp.length < 8) comp.push('');
return comp;
}
function doImport(data, withMitigations, whereMode, mergeId, newName) {
const { aoeEvents, fightStart, fightEnd, phases, players, fightName, reportCode, fightId, mitigationNames = {} } = data;
const mechanics = aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitigations, mitigationNames);
const source = { reportCode, fightId, fightName, fightStart, fightEnd, language: plannerLanguage() };
if (whereMode === 'new') {
const plan = createPlan(uniquePlanName(newName || fightName || 'Importierter Plan'));
return updatePlan(plan.id, {
mechanics,
source,
mitigationNames,
jobComposition: extractJobComp(players),
playerRoster: buildPlayerRoster(players, aoeEvents),
});
}
// Merge into existing plan
const plan = getPlan(mergeId);
if (!plan) return null;
const merged = [...plan.mechanics];
for (const newM of mechanics) {
const exists = plan.mechanics.some(m =>
m.name === newM.name && Math.abs(m.timestamp - newM.timestamp) < 5000
);
if (!exists) merged.push(newM);
}
merged.sort((a, b) => a.timestamp - b.timestamp);
return updatePlan(mergeId, {
mechanics: merged,
source: { ...(plan.source ?? {}), ...source },
mitigationNames: { ...(plan.mitigationNames ?? {}), ...mitigationNames },
});
}
const refreshingPlans = new Set();
async function refreshPlanLanguage(planId) {
const plan = getPlan(planId);
const source = plan?.source ?? {};
const language = plannerLanguage();
if (!plan || refreshingPlans.has(planId)) return;
if (!source.reportCode || !source.fightId || !source.fightStart || !source.fightEnd) return;
if (source.language === language && plan.mitigationNames && Object.keys(plan.mitigationNames).length) return;
refreshingPlans.add(planId);
try {
const params = new URLSearchParams({
report_code: source.reportCode,
fight_id: source.fightId,
start_time: source.fightStart,
end_time: source.fightEnd,
language,
});
const res = await fetch('api/analysis.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
});
const json = await res.json();
if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; }
if (json.error) return;
const refreshed = (json.aoe_events ?? []);
const mechanics = plan.mechanics.map(mechanic => {
const match = refreshed.find(ev => sameMechanic(mechanic, ev, source));
if (!match) return mechanic;
const assignments = (mechanic.assignments ?? []).map(a => ({
...a,
abilityName: json.mitigation_names?.[a.ability] ?? a.abilityName,
}));
return {
...mechanic,
name: match.abilityName ?? mechanic.name,
abilityId: match.abilityId ?? mechanic.abilityId,
assignments,
};
});
let fightName = source.fightName;
try {
const fightRes = await fetch('api/fight.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ report_code: source.reportCode, language }),
});
const fightJson = await fightRes.json();
const fight = (fightJson?.data?.reportData?.report?.fights ?? []).find(f => f.id === source.fightId);
if (fight?.name) fightName = fight.name;
} catch { }
const nextSource = { ...source, fightName, language };
const nextName = plan.name === source.fightName && fightName ? fightName : plan.name;
const updated = updatePlan(planId, {
name: nextName,
mechanics,
source: nextSource,
mitigationNames: json.mitigation_names ?? plan.mitigationNames ?? {},
});
if (updated && activePlanId === planId) {
renderPlanList();
renderPlanDetail(updated);
}
} catch { }
finally {
refreshingPlans.delete(planId);
}
}
// ── Ability Assignment Modal ──────────────────────────────────────────────────
let abilityModalPlanId = null;
let abilityModalMechanicId = null;
function showAbilityModal(planId, mechanicId) {
abilityModalPlanId = planId;
abilityModalMechanicId = mechanicId;
renderAbilityModalContent();
document.getElementById('planner-ability-modal').style.display = 'flex';
}
function hideAbilityModal() {
document.getElementById('planner-ability-modal').style.display = 'none';
abilityModalPlanId = null;
abilityModalMechanicId = null;
}
function renderAbilityModalContent() {
const plan = getPlan(abilityModalPlanId);
if (!plan) return;
const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId);
if (!mechanic) return;
document.getElementById('ability-modal-title').textContent = mechanic.name;
const activeJobs = [...new Set(plan.jobComposition.filter(j => j))];
const content = document.getElementById('ability-modal-content');
if (activeJobs.length === 0) {
content.innerHTML = `
Bitte zuerst die Jobaufstellung konfigurieren.
`;
return;
}
content.innerHTML = activeJobs.map(job => {
const abilities = JOB_ABILITIES[job] ?? [];
if (!abilities.length) return '';
const role = JOB_ROLE[job] ?? 'dps';
const chips = abilities.map(ab => {
const assigned = mechanic.assignments.find(a => a.ability === ab.name);
const isActive = !!assigned;
const byOtherJob = isActive && assigned.job !== job;
const cls = ab.buffType === 'debuff' ? 'badge-assign-debuff'
: ab.buffType === 'shield' ? 'badge-assign-shield'
: 'badge-assign-buff';
const activeClass = isActive ? ' ability-chip--active' : '';
const otherClass = byOtherJob ? ' ability-chip--other-job' : '';
const title = byOtherJob ? `Bereits von ${escHtml(assigned.job)} zugewiesen` : '';
const icon = MITIG_ICONS[ab.name] ?? '';
const assignedName = assigned ? assignmentAbilityName(assigned, plan) : '';
const label = assignedName || plan.mitigationNames?.[ab.name] || ab.name;
return `${icon ? ` ` : ''}${escHtml(label)} `;
}).join('');
return `
`;
}).join('');
}
function toggleAbilityAssignment(abilityName, job, buffType) {
const plan = getPlan(abilityModalPlanId);
if (!plan) return;
const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId);
if (!mechanic) return;
const idx = mechanic.assignments.findIndex(a => a.ability === abilityName);
if (idx !== -1) {
const assignment = mechanic.assignments[idx];
if (assignment.job === job) {
removeActivationGroup(plan, {
mechanicId: mechanic.id,
ability: abilityName,
job: assignment.job ?? '',
});
} else {
compactActivationCopies(plan, {
mechanicId: mechanic.id,
ability: abilityName,
job: assignment.job ?? '',
});
if (assignmentOverlapsJob(plan, job, abilityName, assignmentStartMs(mechanic, assignment), {
mechanicId: mechanic.id,
ability: abilityName,
job: assignment.job ?? '',
}, assignment)) return;
assignment.job = job;
}
} else {
const assignment = {
ability: abilityName,
abilityName: plan.mitigationNames?.[abilityName],
actionId: actionMetaByName[abilityName]?.id ?? null,
job,
buffType,
};
if (assignmentOverlapsJob(plan, job, abilityName, mechanic.timestamp, null, assignment)) return;
mechanic.assignments.push(assignment);
}
updatePlan(abilityModalPlanId, { mechanics: plan.mechanics });
refreshMechanicList(abilityModalPlanId);
renderAbilityModalContent();
}
function initAbilityModal() {
const overlay = document.getElementById('planner-ability-modal');
if (!overlay) return;
document.getElementById('ability-modal-close')?.addEventListener('click', hideAbilityModal);
overlay.addEventListener('click', e => { if (e.target === overlay) hideAbilityModal(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && abilityModalPlanId) hideAbilityModal();
});
document.getElementById('ability-modal-content')?.addEventListener('click', e => {
const btn = e.target.closest('.ability-chip');
if (!btn) return;
toggleAbilityAssignment(btn.dataset.ability, btn.dataset.job, btn.dataset.buffType);
});
}
// ── Import Modal ──────────────────────────────────────────────────────────────
let pendingImportData = null;
function showImportModal(data) {
pendingImportData = data;
// Pre-fill name — auto-unique so the user sees immediately what name will be used
const nameInput = document.getElementById('import-plan-name');
if (nameInput) { nameInput.value = uniquePlanName(data.fightName || 'Importierter Plan'); }
// Populate merge dropdown
const planSelect = document.getElementById('import-plan-select');
if (planSelect) {
const plans = loadPlans();
planSelect.innerHTML = '— Plan auswählen — ';
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 = '— Fight auswählen — ';
document.getElementById('name-import-preview').style.display = 'none';
document.getElementById('name-import-preview').innerHTML = '';
document.getElementById('name-import-confirm-btn').style.display = 'none';
document.getElementById('planner-name-import-modal').style.display = 'flex';
document.getElementById('name-import-report-input').focus();
}
function hideNameImportModal() {
document.getElementById('planner-name-import-modal').style.display = 'none';
nameImportPlanId = null;
}
function refreshJobSlots(planId) {
const plan = getPlan(planId);
if (!plan) return;
const grid = document.getElementById('job-slots-grid');
if (grid) grid.innerHTML = renderJobSlotsHtml(plan);
}
function renderNameImportPreview(_plan, fetchedPlayers) {
const order = { healer: 0, dps: 1, tank: 2 };
const sorted = [...fetchedPlayers]
.filter(p => JOB_FROM_TYPE[p.type])
.sort((a, b) => {
const roleCmp = (order[a.role] ?? 1) - (order[b.role] ?? 1);
return roleCmp !== 0 ? roleCmp : a.name.localeCompare(b.name);
});
nameImportMatchData = sorted.slice(0, 8);
const preview = document.getElementById('name-import-preview');
const rows = Array.from({ length: 8 }, (_, i) => {
const p = sorted[i];
if (!p) {
return `
—
Leer
`;
}
const job = JOB_FROM_TYPE[p.type] ?? p.type;
const role = JOB_ROLE[job] ?? 'dps';
return `
${escHtml(job)}
${escHtml(p.name)}
`;
}).join('');
preview.innerHTML = rows;
preview.style.display = '';
}
function initNameImportModal() {
const modal = document.getElementById('planner-name-import-modal');
const reportInput = document.getElementById('name-import-report-input');
const loadBtn = document.getElementById('name-import-load-btn');
const fightSection = document.getElementById('name-import-fight-section');
const fightSelect = document.getElementById('name-import-fight-select');
const preview = document.getElementById('name-import-preview');
const confirmBtn = document.getElementById('name-import-confirm-btn');
const cancelBtn = document.getElementById('name-import-cancel-btn');
if (!modal) return;
cancelBtn.addEventListener('click', hideNameImportModal);
modal.addEventListener('click', e => { if (e.target === modal) hideNameImportModal(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && nameImportPlanId) hideNameImportModal();
});
reportInput.addEventListener('input', () => {
const match = reportInput.value.match(/fflogs\.com\/reports\/([A-Za-z0-9]+)/);
if (match) reportInput.value = match[1];
});
loadBtn.addEventListener('click', async () => {
const code = reportInput.value.trim();
if (!code) return;
loadBtn.disabled = true;
loadBtn.textContent = 'Lädt…';
fightSection.style.display = 'none';
preview.style.display = 'none';
confirmBtn.style.display = 'none';
try {
const res = await fetch('api/fight.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ report_code: code, language: 'en' }),
});
const json = await res.json();
if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; }
nameImportFights = json?.data?.reportData?.report?.fights ?? [];
nameImportReportCode = code;
fightSelect.innerHTML = '— Fight auswählen — ';
nameImportFights.forEach(f => {
const ms = f.endTime - f.startTime;
const dur = `${Math.floor(ms / 60000)}:${String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')}`;
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = `${f.name} — ${dur} — ${hp}`;
fightSelect.appendChild(opt);
});
fightSection.style.display = '';
} catch { }
loadBtn.disabled = false;
loadBtn.textContent = 'Laden';
});
fightSelect.addEventListener('change', async () => {
const fightId = parseInt(fightSelect.value, 10);
if (!fightId) {
preview.style.display = 'none';
confirmBtn.style.display = 'none';
return;
}
const fight = nameImportFights.find(f => f.id === fightId);
if (!fight) return;
fightSelect.disabled = true;
preview.innerHTML = 'Lädt…
';
preview.style.display = '';
confirmBtn.style.display = 'none';
try {
const res = await fetch('api/players.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
report_code: nameImportReportCode,
fight_id: fightId,
start_time: fight.startTime,
end_time: fight.endTime,
}),
});
const json = await res.json();
if (json.reauth) { window.location.href = window.App?.authStartUrl?.() ?? 'auth/start.php'; return; }
if (json.error) { preview.innerHTML = `${escHtml(json.error)}
`; return; }
const plan = getPlan(nameImportPlanId);
if (!plan) return;
renderNameImportPreview(plan, json.players ?? []);
confirmBtn.style.display = '';
} catch { }
fightSelect.disabled = false;
});
confirmBtn.addEventListener('click', () => {
if (!nameImportPlanId) return;
const jobComposition = Array.from({ length: 8 }, (_, i) => {
const p = nameImportMatchData[i];
return p ? (JOB_FROM_TYPE[p.type] ?? '') : '';
});
const playerRoster = Array.from({ length: 8 }, (_, i) => {
const p = nameImportMatchData[i];
return p ? { name: p.name, maxHp: p.maxHp ?? 0 } : { name: '', maxHp: 0 };
});
updatePlan(nameImportPlanId, { jobComposition, playerRoster });
openPlan(nameImportPlanId);
hideNameImportModal();
});
}
// ── window.plannerTab (hooks for other tabs) ──────────────────────────────────
window.plannerTab = {
onTabOpen() {
renderPlanList();
if (activePlanId) {
openPlan(activePlanId);
} else {
renderPlanDetail(null);
}
},
showImportModal(data) {
showImportModal(data);
},
importFromAnalysis(aoeEvents, _refEvents, _options) {
console.log('[Planner] importFromAnalysis — use showImportModal instead', aoeEvents);
},
};
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initNewPlanForm();
initNewFolderForm();
initImportModal();
initAbilityModal();
initNameImportModal();
activePlanId = localStorage.getItem(PLANNER_ACTIVE_KEY);
renderPlanList();
if (activePlanId && getPlan(activePlanId)) {
renderPlanDetail(getPlan(activePlanId));
} else {
activePlanId = null;
renderPlanDetail(null);
}
});
window.addEventListener('ff14-language-change', () => {
if (!activePlanId) return;
refreshPlanLanguage(activePlanId);
});