Files
scenar-creator/app/static/js/canvas.js
Daneel 751ffe6f82
Some checks failed
Build & Push Docker / build (push) Has been cancelled
feat: v4.3.0 - cross-day drag (blocks can move between day rows)
2026-02-20 18:36:51 +01:00

388 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Canvas editor for Scenar Creator v4.
* Horizontal layout: X = time, Y = days.
* interact.js for drag (horizontal) and resize (right edge).
*/
const Canvas = {
GRID_MINUTES: 15,
ROW_H: 52, // px height of one day row
TIME_LABEL_W: 110, // px width of date label column (Czech day names: "Pondělí (20.2)")
HEADER_H: 28, // px height of time axis header
MIN_BLOCK_MIN: 15, // minimum block duration in minutes
// Time range (auto-derived from blocks, with fallback)
_startMin: 7 * 60,
_endMin: 22 * 60,
get pxPerMinute() {
const slots = (this._endMin - this._startMin) / this.GRID_MINUTES;
const container = document.getElementById('canvasScrollArea');
if (!container) return 2;
const avail = container.clientWidth - this.TIME_LABEL_W - 4;
return avail / ((this._endMin - this._startMin));
},
minutesToPx(minutes) {
return (minutes - this._startMin) * this.pxPerMinute;
},
pxToMinutes(px) {
return px / this.pxPerMinute + this._startMin;
},
snapMinutes(minutes) {
return Math.round(minutes / this.GRID_MINUTES) * this.GRID_MINUTES;
},
formatTime(totalMinutes) {
const norm = ((totalMinutes % 1440) + 1440) % 1440;
const h = Math.floor(norm / 60);
const m = norm % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
},
parseTime(str) {
if (!str) return 0;
const [h, m] = str.split(':').map(Number);
return (h || 0) * 60 + (m || 0);
},
isLightColor(hex) {
const h = (hex || '#888888').replace('#', '');
const r = parseInt(h.substring(0, 2), 16) || 128;
const g = parseInt(h.substring(2, 4), 16) || 128;
const b = parseInt(h.substring(4, 6), 16) || 128;
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
},
// Derive time range from blocks (with default fallback)
_computeRange(blocks) {
if (!blocks || blocks.length === 0) {
this._startMin = 7 * 60;
this._endMin = 22 * 60;
return;
}
let minStart = 24 * 60;
let maxEnd = 0;
for (const b of blocks) {
const s = this.parseTime(b.start);
let e = this.parseTime(b.end);
if (e <= s) e += 24 * 60; // overnight
if (s < minStart) minStart = s;
if (e > maxEnd) maxEnd = e;
}
// Round to hour boundaries, add small padding
this._startMin = Math.max(0, Math.floor(minStart / 60) * 60);
this._endMin = Math.min(48 * 60, Math.ceil(maxEnd / 60) * 60);
// Ensure minimum range of 2h
if (this._endMin - this._startMin < 120) {
this._endMin = this._startMin + 120;
}
},
render(state) {
const dates = App.getDates();
this._computeRange(state.blocks);
const timeAxisEl = document.getElementById('timeAxis');
const dayRowsEl = document.getElementById('dayRows');
if (!timeAxisEl || !dayRowsEl) return;
timeAxisEl.innerHTML = '';
dayRowsEl.innerHTML = '';
this._renderTimeAxis(timeAxisEl, dates);
this._renderDayRows(dayRowsEl, dates, state);
},
_renderTimeAxis(container, dates) {
container.style.display = 'flex';
container.style.alignItems = 'stretch';
container.style.height = this.HEADER_H + 'px';
container.style.minWidth = (this.TIME_LABEL_W + this._canvasWidth()) + 'px';
// Date label placeholder
const corner = document.createElement('div');
corner.className = 'time-corner';
corner.style.cssText = `width:${this.TIME_LABEL_W}px;min-width:${this.TIME_LABEL_W}px;`;
container.appendChild(corner);
// Time labels wrapper
const labelsWrap = document.createElement('div');
labelsWrap.style.cssText = `position:relative;flex:1;height:${this.HEADER_H}px;`;
const totalMin = this._endMin - this._startMin;
for (let m = this._startMin; m <= this._endMin; m += 60) {
const pct = (m - this._startMin) / totalMin * 100;
const label = document.createElement('div');
label.className = 'time-tick';
label.style.left = `calc(${pct}% - 1px)`;
label.textContent = this.formatTime(m);
labelsWrap.appendChild(label);
}
container.appendChild(labelsWrap);
},
_canvasWidth() {
const container = document.getElementById('canvasScrollArea');
if (!container) return 800;
return Math.max(600, container.clientWidth - this.TIME_LABEL_W - 20);
},
_renderDayRows(container, dates, state) {
const typeMap = {};
for (const pt of state.program_types) typeMap[pt.id] = pt;
for (const date of dates) {
const row = document.createElement('div');
row.className = 'day-row';
row.style.height = this.ROW_H + 'px';
row.dataset.date = date;
// Date label
const label = document.createElement('div');
label.className = 'day-label';
label.style.width = this.TIME_LABEL_W + 'px';
label.style.minWidth = this.TIME_LABEL_W + 'px';
label.textContent = this._formatDate(date);
row.appendChild(label);
// Timeline area
const timeline = document.createElement('div');
timeline.className = 'day-timeline';
timeline.style.position = 'relative';
timeline.dataset.date = date;
// Grid lines (every hour)
const totalMin = this._endMin - this._startMin;
for (let m = this._startMin; m < this._endMin; m += 60) {
const line = document.createElement('div');
line.className = 'grid-line';
line.style.left = ((m - this._startMin) / totalMin * 100) + '%';
timeline.appendChild(line);
}
// Click on empty timeline to add block
timeline.addEventListener('click', (e) => {
if (e.target !== timeline) return;
const rect = timeline.getBoundingClientRect();
const relX = e.clientX - rect.left;
const totalW = rect.width;
const clickMin = this._startMin + (relX / totalW) * (this._endMin - this._startMin);
const snapStart = this.snapMinutes(clickMin);
const snapEnd = snapStart + 60; // default 1h
App.openNewBlockModal(date, this.formatTime(snapStart), this.formatTime(Math.min(snapEnd, this._endMin)));
});
// Render blocks for this date
const dayBlocks = state.blocks.filter(b => b.date === date);
for (const block of dayBlocks) {
const el = this._createBlockEl(block, typeMap, totalMin);
if (el) timeline.appendChild(el);
}
row.appendChild(timeline);
container.appendChild(row);
}
},
_formatDate(dateStr) {
const d = new Date(dateStr + 'T12:00:00');
const weekday = d.toLocaleDateString('cs-CZ', { weekday: 'long' });
// Capitalize first letter
const weekdayCap = weekday.charAt(0).toUpperCase() + weekday.slice(1);
const day = d.getDate();
const month = d.getMonth() + 1;
return `${weekdayCap} (${day}.${month})`;
},
_createBlockEl(block, typeMap, totalMin) {
const pt = typeMap[block.type_id];
const color = pt ? pt.color : '#888888';
const light = this.isLightColor(color);
const s = this.parseTime(block.start);
let e = this.parseTime(block.end);
const isOvernight = e <= s;
if (isOvernight) e = this._endMin; // clip to end of day
// Clamp to time range
const cs = Math.max(s, this._startMin);
const ce = Math.min(e, this._endMin);
if (ce <= cs) return null;
const totalRange = this._endMin - this._startMin;
const leftPct = (cs - this._startMin) / totalRange * 100;
const widthPct = (ce - cs) / totalRange * 100;
const el = document.createElement('div');
el.className = 'block-el' + (isOvernight ? ' overnight' : '');
el.dataset.id = block.id;
el.style.cssText = `
left:${leftPct}%;
width:${widthPct}%;
background:${color};
color:${light ? '#1a1a1a' : '#ffffff'};
top:4px;
height:${this.ROW_H - 8}px;
position:absolute;
`;
// Block label
const inner = document.createElement('div');
inner.className = 'block-inner';
const timeLabel = `${block.start}${block.end}`;
const nameEl = document.createElement('span');
nameEl.className = 'block-title';
nameEl.textContent = block.title + (block.notes ? ' *' : '');
const timeEl = document.createElement('span');
timeEl.className = 'block-time';
timeEl.textContent = timeLabel + (isOvernight ? ' →' : '');
inner.appendChild(nameEl);
inner.appendChild(timeEl);
if (block.responsible) {
const respEl = document.createElement('span');
respEl.className = 'block-responsible';
respEl.textContent = block.responsible;
inner.appendChild(respEl);
}
el.appendChild(inner);
// Click to edit
el.addEventListener('click', (e) => {
e.stopPropagation();
App.openBlockModal(block.id);
});
// interact.js drag (X = time, Y = cross-day)
if (window.interact) {
interact(el)
.draggable({
listeners: {
start: (event) => this._onDragStart(event, block),
move: (event) => this._onDragMove(event, block),
end: (event) => this._onDragEnd(event, block),
},
modifiers: [
interact.modifiers.snap({
targets: [interact.snappers.grid({ x: this._minutesPx(this.GRID_MINUTES), y: 10000 })],
range: Infinity,
relativePoints: [{ x: 0, y: 0 }],
}),
],
inertia: false,
})
.resizable({
edges: { right: true },
listeners: {
move: (event) => this._onResizeMove(event, block),
end: (event) => this._onResizeEnd(event, block),
},
modifiers: [
interact.modifiers.snapSize({
targets: [interact.snappers.grid({ width: this._minutesPx(this.GRID_MINUTES) })]
}),
interact.modifiers.restrictSize({ minWidth: this._minutesPx(this.MIN_BLOCK_MIN) })
],
});
}
return el;
},
_minutesPx(minutes) {
return minutes * this.pxPerMinute;
},
_onDragStart(event, block) {
const target = event.target;
target.dataset.dy = '0';
target.style.zIndex = '500';
// Enable cross-row overflow while dragging
const dayRows = document.getElementById('dayRows');
if (dayRows) dayRows.classList.add('dragging');
},
_onDragMove(event, block) {
const target = event.target;
// ── X axis: update time ──────────────────────────────────────
const deltaMin = event.dx / this.pxPerMinute;
const duration = this.parseTime(block.end) - this.parseTime(block.start);
const newStartMin = this.snapMinutes(this.parseTime(block.start) + deltaMin);
const clampedStart = Math.max(this._startMin, Math.min(this._endMin - duration, newStartMin));
block.start = this.formatTime(clampedStart);
block.end = this.formatTime(clampedStart + duration);
const totalRange = this._endMin - this._startMin;
target.style.left = ((clampedStart - this._startMin) / totalRange * 100) + '%';
// ── Y axis: visual shift + row highlight ─────────────────────
const dy = (parseFloat(target.dataset.dy) || 0) + event.dy;
target.dataset.dy = dy;
target.style.transform = `translateY(${dy}px)`;
this._highlightTargetRow(target);
},
_onDragEnd(event, block) {
const target = event.target;
// Detect which day-timeline the block's vertical center is over
const rect = target.getBoundingClientRect();
const centerY = rect.top + rect.height / 2;
for (const row of document.querySelectorAll('.day-timeline')) {
const rr = row.getBoundingClientRect();
if (centerY >= rr.top && centerY <= rr.bottom) {
block.date = row.dataset.date;
break;
}
}
// Cleanup
document.querySelectorAll('.day-timeline.drag-target')
.forEach(r => r.classList.remove('drag-target'));
const dayRows = document.getElementById('dayRows');
if (dayRows) dayRows.classList.remove('dragging');
target.style.transform = '';
target.style.zIndex = '';
delete target.dataset.dy;
App.renderCanvas();
},
_highlightTargetRow(el) {
const rect = el.getBoundingClientRect();
const centerY = rect.top + rect.height / 2;
document.querySelectorAll('.day-timeline').forEach(row => {
const rr = row.getBoundingClientRect();
const isTarget = centerY >= rr.top && centerY <= rr.bottom;
row.classList.toggle('drag-target', isTarget);
});
},
_onResizeMove(event, block) {
const target = event.target;
const newWidthPx = event.rect.width;
const totalRange = this._endMin - this._startMin;
const containerW = target.parentElement.clientWidth;
const minutesPerPx = totalRange / containerW;
const newDuration = this.snapMinutes(Math.round(newWidthPx * minutesPerPx));
const clampedDuration = Math.max(this.MIN_BLOCK_MIN, newDuration);
const startMin = this.parseTime(block.start);
const endMin = Math.min(this._endMin, startMin + clampedDuration);
block.end = this.formatTime(endMin);
const widthPct = (endMin - startMin) / totalRange * 100;
target.style.width = widthPct + '%';
},
_onResizeEnd(event, block) {
App.renderCanvas();
},
};