diff --git a/js/planner.js b/js/planner.js index 276cfa5..a759132 100644 --- a/js/planner.js +++ b/js/planner.js @@ -2133,58 +2133,141 @@ function initTimeline(planId) { let timelinePan = null; let suppressNextTimelineClick = false; let timelineDrag = null; + let dragPreviewEl = null; + let dragPreviewRow = null; + let dragPreviewTrack = null; + let pendingDragPreview = null; + let dragPreviewFrame = 0; 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')); + if (dragPreviewFrame) cancelAnimationFrame(dragPreviewFrame); + dragPreviewFrame = 0; + pendingDragPreview = null; + dragPreviewEl?.remove(); + dragPreviewEl = null; + dragPreviewTrack = null; + dragPreviewRow?.classList.remove('timeline-player-row--drop-ok', 'timeline-player-row--drop-bad'); + dragPreviewRow = null; } - function updateDragPreview(event) { - if (!timelineDrag) return; - removeDragPreview(); + function setDragPreviewRow(row, valid) { + if (dragPreviewRow && dragPreviewRow !== row) { + dragPreviewRow.classList.remove('timeline-player-row--drop-ok', 'timeline-player-row--drop-bad'); + } + dragPreviewRow = row; + row.classList.toggle('timeline-player-row--drop-ok', valid); + row.classList.toggle('timeline-player-row--drop-bad', !valid); + } - const track = event.target.closest('.timeline-player-row .timeline-track'); - const row = event.target.closest('.timeline-player-row'); + function ensureDragPreview(track) { + if (!dragPreviewEl) { + dragPreviewEl = document.createElement('div'); + dragPreviewEl.id = 'timeline-drag-preview'; + dragPreviewEl.innerHTML = ` + + ${timelineDrag.icon ? `` : ''} + ${escHtml(timelineDrag.label)} + `; + } + if (dragPreviewTrack !== track) { + track.appendChild(dragPreviewEl); + dragPreviewTrack = track; + } + return dragPreviewEl; + } + + function updateDragPreview(target, clientX) { + if (!timelineDrag) return; + + const track = target.closest('.timeline-player-row .timeline-track'); + const row = target.closest('.timeline-player-row'); if (!track || !row) return; - const plan = getPlan(planId); + const plan = timelineDrag.plan ?? getPlan(planId); if (!plan) return; - const rect = track.getBoundingClientRect(); - const duration = planDurationMs(plan); - const deltaPx = event.clientX - timelineDrag.startClientX; + const rect = timelineDrag.track === track && timelineDrag.trackRect + ? timelineDrag.trackRect + : track.getBoundingClientRect(); + timelineDrag.track = track; + timelineDrag.trackRect = rect; + const duration = timelineDrag.planDuration || planDurationMs(plan); + const deltaPx = 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 rowJob = row.dataset.job; 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, - }); + && jobCanUseAbility(rowJob, timelineDrag.ability) + && !dragTimestampOverlapsJob(rowJob, timestamp); - row.classList.add(valid ? 'timeline-player-row--drop-ok' : 'timeline-player-row--drop-bad'); + setDragPreviewRow(row, valid); - const preview = document.createElement('div'); - preview.id = 'timeline-drag-preview'; + const preview = ensureDragPreview(track); preview.className = `timeline-drag-preview ${valid ? '' : 'timeline-drag-preview--bad'}`; preview.style.left = `${left}%`; preview.style.setProperty('--cd-width', `${cooldownPct}%`); preview.style.setProperty('--active-width', `${activePct}%`); - preview.innerHTML = ` - - ${timelineDrag.icon ? `` : ''} - ${escHtml(timelineDrag.label)} - `; - track.appendChild(preview); + } + + function scheduleDragPreview(event) { + pendingDragPreview = { + target: event.target, + clientX: event.clientX, + }; + if (dragPreviewFrame) return; + dragPreviewFrame = requestAnimationFrame(() => { + dragPreviewFrame = 0; + const next = pendingDragPreview; + pendingDragPreview = null; + if (next) updateDragPreview(next.target, next.clientX); + }); + } + + function buildDragOverlapEntries(plan, ref) { + const ignoredActivation = assignmentEntryForRef(plan, ref); + const byJob = new Map(); + const addEntry = (job, entry) => { + if (!byJob.has(job)) byJob.set(job, []); + byJob.get(job).push(entry); + }; + + for (const mechanic of visiblePlanMechanics(plan)) { + for (const assignment of mechanic.assignments ?? []) { + if (assignment.ability !== ref.ability) continue; + if (sameAssignmentRef(mechanic, assignment, ref)) continue; + + const start = assignmentStartMs(mechanic, assignment); + const entry = { + start, + end: start + assignmentWindowMs(assignment), + }; + if (ignoredActivation) { + const fullEntry = { mechanic, assignment, ...entry }; + if (assignmentsOverlapActiveFrame(plan, ignoredActivation, fullEntry)) continue; + } + + const assignedJob = assignment.job ?? ''; + if (assignedJob) { + addEntry(assignedJob, entry); + } else { + (plan.jobComposition ?? []) + .filter(job => jobCanUseAbility(job, assignment.ability)) + .forEach(job => addEntry(job, entry)); + } + } + } + + return byJob; + } + + function dragTimestampOverlapsJob(rowJob, timestamp) { + if (!timelineDrag) return false; + const candidateStart = Math.max(0, timestamp); + const candidateEnd = candidateStart + timelineDrag.windowMs; + const entries = timelineDrag.overlapEntriesByJob?.get(rowJob) ?? []; + return entries.some(entry => candidateStart < entry.end && candidateEnd > entry.start); } timeline.addEventListener('pointerdown', e => { @@ -2386,6 +2469,8 @@ function initTimeline(planId) { transparent.height = 1; e.dataTransfer.setDragImage(transparent, 0, 0); timelineDrag = { + plan, + planDuration: planDurationMs(plan), mechanicId: block.dataset.mechanicId, ability: block.dataset.ability, job: block.dataset.job, @@ -2395,6 +2480,14 @@ function initTimeline(planId) { startTimestamp: assignmentStartMs(found.mechanic, found.assignment), durationSec: assignmentDurationSeconds(found.assignment), cooldownSec: assignmentCooldownSeconds(found.assignment), + windowMs: assignmentWindowMs(found.assignment), + overlapEntriesByJob: buildDragOverlapEntries(plan, { + mechanicId: block.dataset.mechanicId, + ability: block.dataset.ability, + job: block.dataset.job, + }), + track: null, + trackRect: null, }; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', JSON.stringify({ @@ -2409,7 +2502,7 @@ function initTimeline(planId) { timeline.addEventListener('dragover', e => { if (e.target.closest('.timeline-player-row .timeline-track')) { e.preventDefault(); - updateDragPreview(e); + scheduleDragPreview(e); } }); @@ -2418,6 +2511,9 @@ function initTimeline(planId) { }); timeline.addEventListener('drop', e => { + if (dragPreviewFrame) cancelAnimationFrame(dragPreviewFrame); + dragPreviewFrame = 0; + pendingDragPreview = null; removeDragPreview(); const track = e.target.closest('.timeline-player-row .timeline-track'); const row = e.target.closest('.timeline-player-row');