@@ -351,12 +418,15 @@ function renderPlanDetail(plan) {
document.getElementById('name-import-open-btn')?.addEventListener('click', () => {
showNameImportModal(plan.id);
});
+ initTimeline(plan.id);
initMechanicClicks(plan.id);
renderInfoPanel(plan);
+ ensureActionMetaLoaded().then(() => refreshTimeline(plan.id));
}
function renderMechanicListHtml(plan) {
- if (plan.mechanics.length === 0) {
+ const mechanics = visiblePlanMechanics(plan);
+ if (mechanics.length === 0) {
return `
📋
@@ -370,7 +440,7 @@ function renderMechanicListHtml(plan) {
const activeJobSet = new Set(plan.jobComposition.filter(j => j));
- return plan.mechanics.map(m => {
+ return mechanics.map(m => {
const sorted = sortedAssignments(m.assignments);
const assignHtml = sorted.length === 0
? '
Keine Zuweisung'
@@ -463,13 +533,488 @@ function initJobSlots(planId) {
});
}
+// ── Timeline ─────────────────────────────────────────────────────────────────
+
+function planDurationMs(plan) {
+ const sourceDuration = (plan.source?.fightEnd ?? 0) - (plan.source?.fightStart ?? 0);
+ const mechanicEnd = Math.max(0, ...visiblePlanMechanics(plan).map(m => (m.timestamp ?? 0) + 30000));
+ return Math.max(sourceDuration, mechanicEnd, 60000);
+}
+
+function timelineScale(plan) {
+ const duration = planDurationMs(plan);
+ const pxPerSecond = 8;
+ return { duration, width: Math.max(720, Math.ceil(duration / 1000 * pxPerSecond)) };
+}
+
+function timelinePlayerRows(plan) {
+ const roster = plan.playerRoster ?? [];
+ return (plan.jobComposition ?? []).map((job, idx) => ({
+ idx,
+ job,
+ name: roster[idx]?.name ?? '',
+ role: JOB_ROLE[job] ?? '',
+ })).filter(row => row.job);
+}
+
+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 ?? '';
+ if (assignedJob) return rows.filter(row => row.job === assignedJob);
+ return rows.filter(row => jobCanUseAbility(row.job, assignment.ability));
+}
+
+function assignmentStartMs(mechanic, assignment) {
+ return Number.isFinite(Number(assignment?.timestamp)) ? Number(assignment.timestamp) : mechanic.timestamp;
+}
+
+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 assignmentOverlapsJob(plan, job, ability, timestamp, ignore = null) {
+ 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 (ignore && ignore.mechanicId === mechanic.id && ignore.ability === ability && ignore.job === (assignment.job ?? '')) continue;
+
+ const start = assignmentStartMs(mechanic, assignment);
+ const cooldownMs = Math.max(assignmentCooldownSeconds(assignment), assignmentDurationSeconds(assignment)) * 1000;
+ const end = start + cooldownMs;
+ if (timestamp >= start && timestamp < end) return true;
+ }
+ }
+ return false;
+}
+
+function actionMetaForAssignment(assignment) {
+ const id = String(assignment?.actionId ?? assignment?.extraAbilityGameID ?? '');
+ return (id && actionMetaById[id]) || actionMetaByName[assignment?.ability] || null;
+}
+
+function assignmentCooldownSeconds(assignment) {
+ const own = Number(assignment?.cooldownSeconds);
+ if (Number.isFinite(own) && own >= 0) return own;
+ return actionMetaForAssignment(assignment)?.recast ?? 0;
+}
+
+function assignmentDurationSeconds(assignment) {
+ const own = Number(assignment?.durationSeconds);
+ if (Number.isFinite(own) && own > 0) return own;
+ return actionMetaForAssignment(assignment)?.duration ?? (assignment?.buffType === 'shield' ? 20 : 15);
+}
+
+function renderTimelineHtml(plan) {
+ const mechanics = visiblePlanMechanics(plan);
+ if (!mechanics.length) {
+ return '
Importiere Mechaniken aus dem Analyse-Tab, um den Zeitstrahl zu nutzen.
';
+ }
+
+ const { duration, width } = timelineScale(plan);
+ const rows = timelinePlayerRows(plan);
+ const marks = [];
+ const tick = 10000;
+ for (let t = 0; t <= duration; t += tick) {
+ marks.push(`
${fmtTimestamp(t)}`);
+ }
+
+ const bossActions = layoutBossActions(mechanics, duration);
+ const laneCount = Math.max(1, ...bossActions.map(item => item.lane + 1));
+ const hitLines = mechanics.map(m => `
+
+ `).join('');
+
+ const bossItems = bossActions.map(({ mechanic: m, left, lane }) => `
+
+ `).join('');
+
+ const playerRows = rows.map(row => {
+ const blocks = [];
+ for (const m of mechanics) {
+ for (const a of sortedAssignments(m.assignments ?? [])) {
+ if (!timelineRowsForAssignment(rows, a).some(target => target.job === row.job)) continue;
+ const start = Number.isFinite(Number(a.timestamp)) ? Number(a.timestamp) : m.timestamp;
+ const durationSec = assignmentDurationSeconds(a);
+ 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 ability = assignmentAbilityName(a, plan);
+ blocks.push(`
+
+ `);
+ }
+ }
+ return `
+
+
+ ${escHtml(row.job)}
+ ${escHtml(row.name || `Slot ${row.idx + 1}`)}
+
+
${hitLines}${blocks.join('')}
+
`;
+ }).join('');
+
+ return `
+
`;
+}
+
+function findTimelineAssignment(plan, selected = selectedTimelineAssignment) {
+ if (!plan || !selected) return null;
+ const mechanic = (plan.mechanics ?? []).find(m => m.id === selected.mechanicId);
+ if (!mechanic) return null;
+ const assignment = (mechanic.assignments ?? []).find(a =>
+ a.ability === selected.ability && (a.job ?? '') === selected.job
+ );
+ return assignment ? { mechanic, assignment } : null;
+}
+
+function renderTimelineSettingsHtml(plan) {
+ const found = findTimelineAssignment(plan);
+ if (!found) {
+ return '
Mitigation im Zeitstrahl auswählen, um Dauer und Cooldown anzupassen.
';
+ }
+
+ const { mechanic, assignment } = found;
+ const ability = assignmentAbilityName(assignment, plan);
+ const jobLabel = assignment.job || 'Nicht zugewiesen';
+ return `
+
`;
+}
+
+function refreshTimeline(planId) {
+ const plan = getPlan(planId);
+ if (!plan) return;
+ const timeline = document.getElementById('planner-timeline');
+ const settings = document.getElementById('timeline-settings');
+ if (timeline) timeline.innerHTML = renderTimelineHtml(plan);
+ if (settings) settings.innerHTML = renderTimelineSettingsHtml(plan);
+}
+
+function setTimelineAssignmentField(planId, mechanicId, ability, job, field, value) {
+ const plan = getPlan(planId);
+ if (!plan) return;
+ const mechanic = plan.mechanics.find(m => m.id === mechanicId);
+ const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job);
+ if (!assignment) return;
+
+ if (field === 'timestamp') assignment.timestamp = Math.max(0, Math.round(Number(value) * 1000));
+ if (field === 'durationSeconds') assignment.durationSeconds = Math.max(1, Math.round(Number(value)));
+ if (field === 'cooldownSeconds') assignment.cooldownSeconds = Math.max(0, Math.round(Number(value)));
+ updatePlan(planId, { mechanics: plan.mechanics });
+ refreshTimeline(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;
+ const timestamp = assignmentStartMs(mechanic, assignment);
+ if (assignmentOverlapsJob(plan, nextJob, ability, timestamp, { mechanicId, ability, job })) 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;
+ const mechanic = plan.mechanics.find(m => m.id === mechanicId);
+ if (!mechanic) return;
+ mechanic.assignments = (mechanic.assignments ?? []).filter(a => !(a.ability === ability && (a.job ?? '') === job));
+ 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 ?? [];
+ mechanic.assignments.push({
+ ability,
+ abilityName: plan.mitigationNames?.[ability],
+ actionId: actionMetaByName[ability]?.id ?? null,
+ job,
+ buffType,
+ timestamp: Math.max(0, Math.round(timestamp)),
+ });
+ selectedTimelineAssignment = { mechanicId, ability, job };
+ updatePlan(planId, { mechanics: plan.mechanics });
+ refreshMechanicList(planId);
+}
+
+function closeTimelineMenu() {
+ document.getElementById('timeline-context-menu')?.remove();
+}
+
+function showTimelineMenu(x, y, items) {
+ closeTimelineMenu();
+ const menu = document.createElement('div');
+ menu.id = 'timeline-context-menu';
+ menu.className = 'timeline-context-menu';
+ menu.innerHTML = items.length
+ ? items.map((item, idx) => `
+
+ `).join('')
+ : '';
+ document.body.appendChild(menu);
+
+ const rect = menu.getBoundingClientRect();
+ menu.style.left = Math.min(x, window.innerWidth - rect.width - 8) + 'px';
+ menu.style.top = Math.min(y, window.innerHeight - rect.height - 8) + 'px';
+
+ menu.addEventListener('click', e => {
+ const btn = e.target.closest('.timeline-menu-item');
+ if (!btn || btn.disabled) return;
+ items[parseInt(btn.dataset.idx, 10)]?.onClick?.();
+ closeTimelineMenu();
+ document.removeEventListener('click', closeOutside, true);
+ document.removeEventListener('contextmenu', closeOutside, true);
+ });
+
+ const closeOutside = ev => {
+ if (menu.contains(ev.target)) return;
+ closeTimelineMenu();
+ document.removeEventListener('click', closeOutside, true);
+ document.removeEventListener('contextmenu', closeOutside, true);
+ };
+ setTimeout(() => {
+ document.addEventListener('click', closeOutside, true);
+ document.addEventListener('contextmenu', closeOutside, true);
+ }, 0);
+}
+
+function updateTimelineAssignmentPosition(planId, mechanicId, ability, job, rowJob, timestamp) {
+ const plan = getPlan(planId);
+ if (!plan) return;
+ const mechanic = plan.mechanics.find(m => m.id === mechanicId);
+ const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job);
+ if (!assignment) return;
+ if (!jobCanUseAbility(rowJob, ability)) return;
+ assignment.timestamp = Math.max(0, Math.round(timestamp));
+ 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;
+
+ timeline.addEventListener('click', e => {
+ closeTimelineMenu();
+ const boss = e.target.closest('.timeline-boss-action');
+ if (boss) {
+ showAbilityModal(planId, boss.dataset.mechanicId);
+ return;
+ }
+ const block = e.target.closest('.timeline-mitigation');
+ if (block) {
+ selectedTimelineAssignment = {
+ mechanicId: block.dataset.mechanicId,
+ ability: block.dataset.ability,
+ job: block.dataset.job,
+ };
+ refreshTimeline(planId);
+ const plan = getPlan(planId);
+ const rows = timelinePlayerRows(plan ?? {});
+ const found = findTimelineAssignment(plan, selectedTimelineAssignment);
+ const timestamp = found ? assignmentStartMs(found.mechanic, found.assignment) : 0;
+ const items = rows
+ .filter(row => jobCanUseAbility(row.job, block.dataset.ability))
+ .map(row => ({
+ label: `${row.job}${row.name ? ` · ${row.name}` : ''}`,
+ icon: MITIG_ICONS[block.dataset.ability] ?? '',
+ disabled: assignmentOverlapsJob(plan, row.job, block.dataset.ability, timestamp, {
+ mechanicId: block.dataset.mechanicId,
+ ability: block.dataset.ability,
+ job: block.dataset.job,
+ }),
+ onClick: () => setTimelineAssignmentJob(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job, row.job),
+ }));
+ showTimelineMenu(e.clientX, e.clientY, items);
+ return;
+ }
+
+ const track = e.target.closest('.timeline-player-row .timeline-track');
+ const row = e.target.closest('.timeline-player-row');
+ if (!track || !row) return;
+ const plan = getPlan(planId);
+ if (!plan) return;
+ const timestamp = timelineTimestampFromEvent(plan, track, e);
+ const mechanic = findNearestMechanic(plan, timestamp);
+ if (!mechanic) return;
+ const items = (JOB_ABILITIES[row.dataset.job] ?? [])
+ .filter(ab => !assignmentOverlapsJob(plan, row.dataset.job, ab.name, timestamp))
+ .map(ab => ({
+ label: `${plan.mitigationNames?.[ab.name] ?? ab.name} · ${fmtTimestamp(timestamp)}`,
+ icon: MITIG_ICONS[ab.name] ?? '',
+ onClick: () => addTimelineAssignment(planId, mechanic.id, ab.name, row.dataset.job, ab.buffType, timestamp),
+ }));
+ showTimelineMenu(e.clientX, e.clientY, items);
+ });
+
+ 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;
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', JSON.stringify({
+ mechanicId: block.dataset.mechanicId,
+ ability: block.dataset.ability,
+ job: block.dataset.job,
+ }));
+ });
+
+ timeline.addEventListener('dragover', e => {
+ if (e.target.closest('.timeline-player-row .timeline-track')) e.preventDefault();
+ });
+
+ timeline.addEventListener('drop', e => {
+ 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 x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
+ const timestamp = (x / rect.width) * planDurationMs(plan);
+ updateTimelineAssignmentPosition(planId, data.mechanicId, data.ability, data.job, row.dataset.job, timestamp);
+ });
+
+ 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) {
+function refreshMechanicList(planId, includeTimeline = true) {
const plan = getPlan(planId);
if (!plan) return;
const el = document.getElementById('mechanic-list');
if (el) el.innerHTML = renderMechanicListHtml(plan);
+ if (includeTimeline) refreshTimeline(planId);
renderInfoPanel(plan);
}
@@ -493,7 +1038,7 @@ function renderInfoPanel(plan) {
const noJobAbilities = new Set();
const missingJobPairs = new Set();
- for (const m of plan.mechanics) {
+ for (const m of visiblePlanMechanics(plan)) {
for (const a of m.assignments) {
if (!a.job) {
noJobAbilities.add(a.ability);
@@ -995,6 +1540,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
assignments.push({
ability: key,
abilityName: mitigationDisplayName(m) || mitigationNames[key],
+ actionId: m.extraAbilityGameID ?? null,
job: guessJob(key, players),
buffType: m.buffType ?? '',
});
@@ -1254,7 +1800,13 @@ function toggleAbilityAssignment(abilityName, job, buffType) {
mechanic.assignments[idx].job = job;
}
} else {
- mechanic.assignments.push({ ability: abilityName, abilityName: plan.mitigationNames?.[abilityName], job, buffType });
+ mechanic.assignments.push({
+ ability: abilityName,
+ abilityName: plan.mitigationNames?.[abilityName],
+ actionId: actionMetaByName[abilityName]?.id ?? null,
+ job,
+ buffType,
+ });
}
updatePlan(abilityModalPlanId, { mechanics: plan.mechanics });