add timeline feature

This commit is contained in:
Akurosia Kamo 2026-05-23 12:48:08 +02:00
parent 27b9b0785e
commit 61fecbc576
2 changed files with 879 additions and 6 deletions

View File

@ -4,6 +4,12 @@
grid-template-columns: 280px 1fr; grid-template-columns: 280px 1fr;
gap: 16px; gap: 16px;
align-items: start; align-items: start;
min-width: 0;
}
#plan-detail-panel,
#plan-content {
min-width: 0;
} }
/* ── Plan Sidebar ────────────────────────────────────────────────────────────── */ /* ── Plan Sidebar ────────────────────────────────────────────────────────────── */
@ -654,3 +660,318 @@
} }
.folder-picker-option:hover { background: var(--bg2); color: var(--t1); } .folder-picker-option:hover { background: var(--bg2); color: var(--t1); }
.folder-picker-option.active { color: var(--gold); } .folder-picker-option.active { color: var(--gold); }
/* ── Planner Timeline ───────────────────────────────────────────────────────── */
.timeline-hint {
font-size: 12px;
color: var(--t3);
}
.timeline-empty,
.timeline-settings-empty {
font-size: 13px;
color: var(--t3);
padding: 12px 0;
}
.timeline-scroll {
overflow-x: auto;
overflow-y: hidden;
border: 1px solid var(--border);
background: var(--bg1);
max-width: 100%;
width: 100%;
}
.timeline-grid {
width: calc(180px + var(--timeline-width));
display: grid;
grid-template-columns: 180px var(--timeline-width);
}
.timeline-row,
.timeline-axis {
display: grid;
grid-template-columns: 180px var(--timeline-width);
min-height: 38px;
grid-column: 1 / -1;
}
.timeline-row-label {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
background: var(--bgcard);
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
font-size: 12px;
color: var(--t2);
min-width: 0;
}
.timeline-boss-row {
min-height: var(--boss-row-height, 52px);
}
.timeline-boss-row .timeline-row-label {
color: var(--gold);
font-weight: 600;
}
.timeline-track,
.timeline-axis-track {
position: relative;
min-height: inherit;
border-bottom: 1px solid var(--border);
background:
repeating-linear-gradient(
to right,
transparent 0,
transparent 79px,
rgba(255,255,255,0.07) 80px
);
}
.timeline-hit-line {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
transform: translateX(-50%);
background: rgba(200,168,75,.38);
pointer-events: none;
z-index: 1;
}
.timeline-player-row .timeline-track {
background-color: rgba(255,255,255,0.015);
}
.timeline-job {
width: 36px;
text-align: center;
border-radius: 3px;
padding: 2px 0;
font-size: 11px;
flex-shrink: 0;
}
.timeline-player-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-boss-action {
position: absolute;
top: 8px;
transform: translateX(-50%);
max-width: 150px;
padding: 5px 8px;
border: 1px solid rgba(224,92,92,.35);
border-radius: var(--r);
background: rgba(224,92,92,.14);
color: var(--t1);
font-size: 12px;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
z-index: 3;
}
.timeline-boss-action:hover {
border-color: rgba(224,92,92,.7);
background: rgba(224,92,92,.22);
}
.timeline-mitigation {
position: absolute;
top: 6px;
width: var(--cd-width);
min-width: 28px;
height: 26px;
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px;
border: 1px solid var(--borderem);
border-radius: var(--r);
background: var(--bg2);
color: var(--t1);
font-size: 11px;
cursor: grab;
z-index: 4;
overflow: hidden;
}
.timeline-mitigation-active {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--active-width);
background: currentColor;
opacity: 0.22;
pointer-events: none;
}
.timeline-mitigation:active { cursor: grabbing; }
.timeline-mitigation.selected {
outline: 2px solid var(--gold);
outline-offset: 1px;
}
.timeline-mitigation--candidate {
border-style: dashed;
opacity: 0.78;
}
.timeline-mitigation img {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.timeline-mitigation span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
z-index: 1;
}
.timeline-mitigation .timeline-mitigation-active {
z-index: 0;
}
.timeline-mitigation--buff { border-color: rgba(200,168,75,.5); color: var(--gold); background: rgba(200,168,75,.12); }
.timeline-mitigation--debuff { border-color: rgba(224,92,92,.5); color: var(--red); background: rgba(224,92,92,.12); }
.timeline-mitigation--shield { border-color: rgba(74,158,255,.5); color: var(--blue); background: rgba(74,158,255,.12); }
.timeline-axis {
min-height: 28px;
}
.timeline-axis-track {
border-bottom: none;
}
.timeline-tick {
position: absolute;
top: 6px;
transform: translateX(-50%);
font-size: 11px;
color: var(--t3);
}
.timeline-settings-panel {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.timeline-settings-title {
font-size: 13px;
color: var(--t1);
font-weight: 600;
width: 100%;
}
.timeline-settings-meta {
font-size: 12px;
color: var(--t3);
width: 100%;
margin-top: -6px;
}
.timeline-settings-panel label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--t3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.timeline-setting-input {
width: 86px !important;
font-size: 13px !important;
padding: 5px 7px !important;
}
.timeline-context-menu {
position: fixed;
z-index: 300;
min-width: 190px;
max-width: 280px;
padding: 5px;
border: 1px solid var(--borderem);
border-radius: var(--r);
background: var(--bgcard);
box-shadow: 0 10px 24px rgba(0,0,0,0.45);
}
.timeline-menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 7px;
padding: 7px 9px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--t2);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.timeline-menu-item:hover {
background: var(--bg2);
color: var(--t1);
}
.timeline-menu-item.disabled,
.timeline-menu-item:disabled {
opacity: 0.38;
cursor: not-allowed;
}
.timeline-menu-item img {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
}
.timeline-menu-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-menu-empty {
padding: 8px 10px;
color: var(--t3);
font-size: 12px;
}
@media (max-width: 980px) {
.planner-layout {
grid-template-columns: 1fr;
}
.plan-sidebar {
position: static;
}
}

View File

@ -96,6 +96,10 @@ function copyPlan(id) {
let activePlanId = null; let activePlanId = null;
let collapsedFolders = new Set(); let collapsedFolders = new Set();
let selectedTimelineAssignment = null;
let actionMetaPromise = null;
let actionMetaById = {};
let actionMetaByName = {};
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@ -129,6 +133,48 @@ function plannerLanguage() {
return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en'; 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) { function sameMechanic(existing, incoming, source) {
const fightStart = source?.fightStart ?? 0; const fightStart = source?.fightStart ?? 0;
const incomingRel = incoming.timestamp - fightStart; const incomingRel = incoming.timestamp - fightStart;
@ -138,6 +184,12 @@ function sameMechanic(existing, incoming, source) {
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 ────────────────────────────────────────────────────── // ── Rendering: Plan List ──────────────────────────────────────────────────────
function planItemHtml(p) { function planItemHtml(p) {
@ -313,6 +365,8 @@ function renderPlanDetail(plan) {
noplan.style.display = 'none'; noplan.style.display = 'none';
content.style.display = ''; content.style.display = '';
const visibleMechanics = visiblePlanMechanics(plan);
content.innerHTML = ` content.innerHTML = `
<div class="card section-gap"> <div class="card section-gap">
<div class="plan-detail-header"> <div class="plan-detail-header">
@ -320,7 +374,7 @@ function renderPlanDetail(plan) {
<span id="plan-name-display" class="plan-name-text">${escHtml(plan.name)}</span> <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> <button id="plan-name-edit-btn" class="plan-btn" title="Umbenennen"></button>
</div> </div>
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} &middot; ${plan.mechanics.length} Mechaniken</div> <div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} &middot; ${visibleMechanics.length} Mechaniken</div>
</div> </div>
</div> </div>
@ -334,6 +388,19 @@ function renderPlanDetail(plan) {
</div> </div>
</div> </div>
<div class="card section-gap">
<div class="card-title-row">
<div class="card-title">Zeitstrahl</div>
<div class="timeline-hint">Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten</div>
</div>
<div id="planner-timeline">
${renderTimelineHtml(plan)}
</div>
<div id="timeline-settings">
${renderTimelineSettingsHtml(plan)}
</div>
</div>
<div class="card"> <div class="card">
<div class="card-title-row"> <div class="card-title-row">
<div class="card-title">Mechaniken</div> <div class="card-title">Mechaniken</div>
@ -351,12 +418,15 @@ function renderPlanDetail(plan) {
document.getElementById('name-import-open-btn')?.addEventListener('click', () => { document.getElementById('name-import-open-btn')?.addEventListener('click', () => {
showNameImportModal(plan.id); showNameImportModal(plan.id);
}); });
initTimeline(plan.id);
initMechanicClicks(plan.id); initMechanicClicks(plan.id);
renderInfoPanel(plan); renderInfoPanel(plan);
ensureActionMetaLoaded().then(() => refreshTimeline(plan.id));
} }
function renderMechanicListHtml(plan) { function renderMechanicListHtml(plan) {
if (plan.mechanics.length === 0) { const mechanics = visiblePlanMechanics(plan);
if (mechanics.length === 0) {
return ` return `
<div class="empty" style="padding:30px 0"> <div class="empty" style="padding:30px 0">
<div class="empty-icon" style="font-size:26px">📋</div> <div class="empty-icon" style="font-size:26px">📋</div>
@ -370,7 +440,7 @@ function renderMechanicListHtml(plan) {
const activeJobSet = new Set(plan.jobComposition.filter(j => j)); const activeJobSet = new Set(plan.jobComposition.filter(j => j));
return plan.mechanics.map(m => { return mechanics.map(m => {
const sorted = sortedAssignments(m.assignments); const sorted = sortedAssignments(m.assignments);
const assignHtml = sorted.length === 0 const assignHtml = sorted.length === 0
? '<span class="mechanic-no-assign">Keine Zuweisung</span>' ? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
@ -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 '<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" style="left:${(m.timestamp / duration) * 100}%"></span>
`).join('');
const bossItems = bossActions.map(({ mechanic: m, left, lane }) => `
<button class="timeline-boss-action"
style="left:${left}%;top:${8 + lane * 30}px"
data-mechanic-id="${escHtml(m.id)}"
title="${escHtml(fmtTimestamp(m.timestamp))} · ${escHtml(m.name)}">
${escHtml(m.name)}
</button>
`).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(`
<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(ability)} · 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(ability)}</span>
</button>
`);
}
}
return `
<div class="timeline-row timeline-player-row" data-row-idx="${row.idx}" data-job="${escHtml(row.job)}">
<div class="timeline-row-label">
<span class="timeline-job role-${escHtml(row.role)}">${escHtml(row.job)}</span>
<span class="timeline-player-name">${escHtml(row.name || `Slot ${row.idx + 1}`)}</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">
<div class="timeline-row-label">Boss</div>
<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;
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) => `
<button class="timeline-menu-item${item.disabled ? ' disabled' : ''}" 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;
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 ───────────────────────────────────────────────────── // ── Mechanic list helpers ─────────────────────────────────────────────────────
function refreshMechanicList(planId) { function refreshMechanicList(planId, includeTimeline = true) {
const plan = getPlan(planId); const plan = getPlan(planId);
if (!plan) return; if (!plan) return;
const el = document.getElementById('mechanic-list'); const el = document.getElementById('mechanic-list');
if (el) el.innerHTML = renderMechanicListHtml(plan); if (el) el.innerHTML = renderMechanicListHtml(plan);
if (includeTimeline) refreshTimeline(planId);
renderInfoPanel(plan); renderInfoPanel(plan);
} }
@ -493,7 +1038,7 @@ function renderInfoPanel(plan) {
const noJobAbilities = new Set(); const noJobAbilities = new Set();
const missingJobPairs = new Set(); const missingJobPairs = new Set();
for (const m of plan.mechanics) { for (const m of visiblePlanMechanics(plan)) {
for (const a of m.assignments) { for (const a of m.assignments) {
if (!a.job) { if (!a.job) {
noJobAbilities.add(a.ability); noJobAbilities.add(a.ability);
@ -995,6 +1540,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
assignments.push({ assignments.push({
ability: key, ability: key,
abilityName: mitigationDisplayName(m) || mitigationNames[key], abilityName: mitigationDisplayName(m) || mitigationNames[key],
actionId: m.extraAbilityGameID ?? null,
job: guessJob(key, players), job: guessJob(key, players),
buffType: m.buffType ?? '', buffType: m.buffType ?? '',
}); });
@ -1254,7 +1800,13 @@ function toggleAbilityAssignment(abilityName, job, buffType) {
mechanic.assignments[idx].job = job; mechanic.assignments[idx].job = job;
} }
} else { } 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 }); updatePlan(abilityModalPlanId, { mechanics: plan.mechanics });