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;
}
.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 {
position: absolute;
top: 8px;
@ -848,16 +875,7 @@
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 { display: none; }
.timeline-mitigation:active { cursor: grabbing; }
.timeline-mitigation.selected {
@ -891,9 +909,21 @@
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-mitigation--buff {
border-color: rgba(200,168,75,.5);
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 {
min-height: 28px;

View File

@ -582,12 +582,18 @@ function timelineScale(plan) {
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);
const rows = [];
(plan.jobComposition ?? []).forEach((job, idx) => {
if (!job) return;
const name = roster[idx]?.name ?? '';
const role = JOB_ROLE[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) {
@ -603,8 +609,11 @@ function jobCanUseAbility(job, 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));
return rows.filter(row => {
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) {
@ -707,7 +716,9 @@ function renderTimelineHtml(plan) {
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;
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 durationSec = assignmentDurationSeconds(a);
const cooldownSec = assignmentCooldownSeconds(a);
@ -721,7 +732,7 @@ function renderTimelineHtml(plan) {
: a.buffType === 'shield' ? 'timeline-mitigation--shield'
: 'timeline-mitigation--buff';
const icon = MITIG_ICONS[a.ability] ?? '';
const ability = assignmentAbilityName(a, plan);
const abilityLabel = assignmentAbilityName(a, plan);
blocks.push(`
<button class="timeline-mitigation ${cls}${selected}${unresolved}"
draggable="true"
@ -729,19 +740,25 @@ function renderTimelineHtml(plan) {
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'}">
title="${escHtml(abilityLabel)} · 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>
<span>${escHtml(abilityLabel)}</span>
</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 `
<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">
<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 class="timeline-track" style="width:${width}px">${hitLines}${blocks.join('')}</div>
</div>`;
@ -977,14 +994,11 @@ function initTimeline(planId) {
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);
const rowAbility = row.dataset.ability;
const rowJob = row.dataset.job;
const ab = (JOB_ABILITIES[rowJob] ?? []).find(a => a.name === rowAbility);
if (!ab || assignmentOverlapsJob(plan, rowJob, rowAbility, timestamp)) return;
addTimelineAssignment(planId, mechanic.id, rowAbility, rowJob, ab.buffType, timestamp);
});
timeline.addEventListener('contextmenu', e => {
@ -1023,6 +1037,7 @@ function initTimeline(planId) {
const rect = track.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
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);
});