Planer: Gantt-Zeilen pro Ability + aktive Dauer im Balken sichtbar

- Eine Timeline-Zeile pro Ability statt pro Job
- Schilde ausgeblendet (außer Panhaima SGE)
- Balken zeigt aktive Dauer vs. Cooldown via Gradient
- Klick auf leere Zeile fügt Ability direkt hinzu
- Drop nur auf passende Ability-Zeile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xziino 2026-05-23 17:15:58 +02:00
parent cdd594e43e
commit da226d54a2
2 changed files with 80 additions and 35 deletions

View File

@ -805,6 +805,33 @@
white-space: nowrap; white-space: nowrap;
} }
.timeline-row-ability {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.timeline-row-ability-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
border-radius: 2px;
}
.timeline-row-ability-name {
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-player-row--job-start .timeline-row-label,
.timeline-player-row--job-start .timeline-track {
border-top: 1px solid var(--border);
}
.timeline-boss-action { .timeline-boss-action {
position: absolute; position: absolute;
top: 8px; top: 8px;
@ -848,16 +875,7 @@
overflow: hidden; overflow: hidden;
} }
.timeline-mitigation-active { .timeline-mitigation-active { display: none; }
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:active { cursor: grabbing; }
.timeline-mitigation.selected { .timeline-mitigation.selected {
@ -891,9 +909,21 @@
z-index: 0; z-index: 0;
} }
.timeline-mitigation--buff { border-color: rgba(200,168,75,.5); color: var(--gold); background: rgba(200,168,75,.12); } .timeline-mitigation--buff {
.timeline-mitigation--debuff { border-color: rgba(224,92,92,.5); color: var(--red); background: rgba(224,92,92,.12); } border-color: rgba(200,168,75,.5);
.timeline-mitigation--shield { border-color: rgba(74,158,255,.5); color: var(--blue); background: rgba(74,158,255,.12); } color: var(--gold);
background: linear-gradient(to right, rgba(200,168,75,.30) 0%, rgba(200,168,75,.30) var(--active-width), rgba(200,168,75,.06) var(--active-width), rgba(200,168,75,.06) 100%);
}
.timeline-mitigation--debuff {
border-color: rgba(224,92,92,.5);
color: var(--red);
background: linear-gradient(to right, rgba(224,92,92,.30) 0%, rgba(224,92,92,.30) var(--active-width), rgba(224,92,92,.06) var(--active-width), rgba(224,92,92,.06) 100%);
}
.timeline-mitigation--shield {
border-color: rgba(74,158,255,.5);
color: var(--blue);
background: linear-gradient(to right, rgba(74,158,255,.30) 0%, rgba(74,158,255,.30) var(--active-width), rgba(74,158,255,.06) var(--active-width), rgba(74,158,255,.06) 100%);
}
.timeline-axis { .timeline-axis {
min-height: 28px; min-height: 28px;

View File

@ -582,12 +582,18 @@ function timelineScale(plan) {
function timelinePlayerRows(plan) { function timelinePlayerRows(plan) {
const roster = plan.playerRoster ?? []; const roster = plan.playerRoster ?? [];
return (plan.jobComposition ?? []).map((job, idx) => ({ const rows = [];
idx, (plan.jobComposition ?? []).forEach((job, idx) => {
job, if (!job) return;
name: roster[idx]?.name ?? '', const name = roster[idx]?.name ?? '';
role: JOB_ROLE[job] ?? '', const role = JOB_ROLE[job] ?? '';
})).filter(row => row.job); const abilities = (JOB_ABILITIES[job] ?? [])
.filter(ab => ab.buffType !== 'shield' || ab.name === 'Panhaima');
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) { function selectedAssignmentMatches(mechanicId, assignment) {
@ -603,8 +609,11 @@ function jobCanUseAbility(job, ability) {
function timelineRowsForAssignment(rows, assignment) { function timelineRowsForAssignment(rows, assignment) {
const assignedJob = assignment.job ?? ''; const assignedJob = assignment.job ?? '';
if (assignedJob) return rows.filter(row => row.job === assignedJob); return rows.filter(row => {
return rows.filter(row => jobCanUseAbility(row.job, assignment.ability)); 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) { function assignmentStartMs(mechanic, assignment) {
@ -707,7 +716,9 @@ function renderTimelineHtml(plan) {
const blocks = []; const blocks = [];
for (const m of mechanics) { for (const m of mechanics) {
for (const a of sortedAssignments(m.assignments ?? [])) { for (const a of sortedAssignments(m.assignments ?? [])) {
if (!timelineRowsForAssignment(rows, a).some(target => target.job === row.job)) continue; if (a.ability !== row.ability) continue;
const assignedJob = a.job ?? '';
if (assignedJob && assignedJob !== row.job) continue;
const start = Number.isFinite(Number(a.timestamp)) ? Number(a.timestamp) : m.timestamp; const start = Number.isFinite(Number(a.timestamp)) ? Number(a.timestamp) : m.timestamp;
const durationSec = assignmentDurationSeconds(a); const durationSec = assignmentDurationSeconds(a);
const cooldownSec = assignmentCooldownSeconds(a); const cooldownSec = assignmentCooldownSeconds(a);
@ -721,7 +732,7 @@ function renderTimelineHtml(plan) {
: a.buffType === 'shield' ? 'timeline-mitigation--shield' : a.buffType === 'shield' ? 'timeline-mitigation--shield'
: 'timeline-mitigation--buff'; : 'timeline-mitigation--buff';
const icon = MITIG_ICONS[a.ability] ?? ''; const icon = MITIG_ICONS[a.ability] ?? '';
const ability = assignmentAbilityName(a, plan); const abilityLabel = assignmentAbilityName(a, plan);
blocks.push(` blocks.push(`
<button class="timeline-mitigation ${cls}${selected}${unresolved}" <button class="timeline-mitigation ${cls}${selected}${unresolved}"
draggable="true" draggable="true"
@ -729,19 +740,25 @@ function renderTimelineHtml(plan) {
data-mechanic-id="${escHtml(m.id)}" data-mechanic-id="${escHtml(m.id)}"
data-ability="${escHtml(a.ability)}" data-ability="${escHtml(a.ability)}"
data-job="${escHtml(a.job ?? '')}" data-job="${escHtml(a.job ?? '')}"
title="${escHtml(ability)} · aktiv ${durationSec}s · CD ${cooldownSec}s${a.job ? '' : ' · mögliche Zuordnung'}"> title="${escHtml(abilityLabel)} · aktiv ${durationSec}s · CD ${cooldownSec}s${a.job ? '' : ' · mögliche Zuordnung'}">
<span class="timeline-mitigation-active"></span> <span class="timeline-mitigation-active"></span>
${icon ? `<img src="${escHtml(icon)}" alt="">` : ''} ${icon ? `<img src="${escHtml(icon)}" alt="">` : ''}
<span>${escHtml(ability)}</span> <span>${escHtml(abilityLabel)}</span>
</button> </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 ` return `
<div class="timeline-row timeline-player-row" data-row-idx="${row.idx}" data-job="${escHtml(row.job)}"> <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"> <div class="timeline-row-label">
<span class="timeline-job role-${escHtml(row.role)}">${escHtml(row.job)}</span> <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> <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>
<div class="timeline-track" style="width:${width}px">${hitLines}${blocks.join('')}</div> <div class="timeline-track" style="width:${width}px">${hitLines}${blocks.join('')}</div>
</div>`; </div>`;
@ -977,14 +994,11 @@ function initTimeline(planId) {
const timestamp = timelineTimestampFromEvent(plan, track, e); const timestamp = timelineTimestampFromEvent(plan, track, e);
const mechanic = findNearestMechanic(plan, timestamp); const mechanic = findNearestMechanic(plan, timestamp);
if (!mechanic) return; if (!mechanic) return;
const items = (JOB_ABILITIES[row.dataset.job] ?? []) const rowAbility = row.dataset.ability;
.filter(ab => !assignmentOverlapsJob(plan, row.dataset.job, ab.name, timestamp)) const rowJob = row.dataset.job;
.map(ab => ({ const ab = (JOB_ABILITIES[rowJob] ?? []).find(a => a.name === rowAbility);
label: `${plan.mitigationNames?.[ab.name] ?? ab.name} · ${fmtTimestamp(timestamp)}`, if (!ab || assignmentOverlapsJob(plan, rowJob, rowAbility, timestamp)) return;
icon: MITIG_ICONS[ab.name] ?? '', addTimelineAssignment(planId, mechanic.id, rowAbility, rowJob, ab.buffType, timestamp);
onClick: () => addTimelineAssignment(planId, mechanic.id, ab.name, row.dataset.job, ab.buffType, timestamp),
}));
showTimelineMenu(e.clientX, e.clientY, items);
}); });
timeline.addEventListener('contextmenu', e => { timeline.addEventListener('contextmenu', e => {
@ -1023,6 +1037,7 @@ function initTimeline(planId) {
const rect = track.getBoundingClientRect(); const rect = track.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)); const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
const timestamp = (x / rect.width) * planDurationMs(plan); const timestamp = (x / rect.width) * planDurationMs(plan);
if (row.dataset.ability && data.ability !== row.dataset.ability) return;
updateTimelineAssignmentPosition(planId, data.mechanicId, data.ability, data.job, row.dataset.job, timestamp); updateTimelineAssignmentPosition(planId, data.mechanicId, data.ability, data.job, row.dataset.job, timestamp);
}); });