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');