2788 lines
114 KiB
JavaScript
2788 lines
114 KiB
JavaScript
// ── 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, '<')
|
||
.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 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 · ${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)} · ${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(() => 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 `
|
||
<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 = 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 = `<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([
|
||
'Guardian',
|
||
'Bloodwhetting',
|
||
'Divine Benison',
|
||
'Intersection',
|
||
'the Spire',
|
||
'Haima',
|
||
'Eukrasian Diagnosis',
|
||
'Differential Diagnosis',
|
||
'Seraphic Veil',
|
||
'Radiant Aegis',
|
||
'Tempera Coat',
|
||
]);
|
||
|
||
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 && !options.includePersonal) return false;
|
||
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 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 '<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 = MITIG_ICONS[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 = MITIG_ICONS[row.ability] ?? '';
|
||
const abilityDisplayName = plan.mitigationNames?.[row.ability] ?? row.ability;
|
||
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;
|
||
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);
|
||
}
|
||
|
||
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: 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 = `
|
||
<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 = MITIG_ICONS[ab.name] ?? '';
|
||
const assignedName = assigned ? assignmentAbilityName(assigned, plan) : '';
|
||
const label = assignedName || plan.mitigationNames?.[ab.name] || ab.name;
|
||
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 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 = '<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);
|
||
});
|