Files
scenar-creator/app/static/js/canvas.js
2026-02-20 19:21:55 +01:00

433 lines
17 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 — adaptive based on available width
const widthPx = (ce - cs) * this.pxPerMinute;
const inner = document.createElement('div');
inner.className = 'block-inner';
if (widthPx >= 28) {
const nameEl = document.createElement('span');
nameEl.className = 'block-title';
nameEl.textContent = block.title + (block.notes ? ' *' : '');
inner.appendChild(nameEl);
if (widthPx >= 72) {
const timeLabel = `${block.start}${block.end}`;
const timeEl = document.createElement('span');
timeEl.className = 'block-time';
timeEl.textContent = timeLabel + (isOvernight ? ' →' : '');
inner.appendChild(timeEl);
}
if (widthPx >= 90 && block.responsible) {
const respEl = document.createElement('span');
respEl.className = 'block-responsible';
respEl.textContent = block.responsible;
inner.appendChild(respEl);
}
}
// Tooltip always available for narrow blocks
el.title = `${block.title} (${block.start}${block.end})` +
(block.responsible ? ` · ${block.responsible}` : '');
el.appendChild(inner);
// Resize handle — explicit element so cursor is always correct
const resizeHandle = document.createElement('div');
resizeHandle.className = 'block-resize-handle';
el.appendChild(resizeHandle);
// Click to edit
el.addEventListener('click', (e) => {
e.stopPropagation();
App.openBlockModal(block.id);
});
// Native pointer drag — skip if clicking the resize handle (interact.js owns that)
el.addEventListener('pointerdown', (e) => {
if (e.target.closest('.block-resize-handle')) return;
e.stopPropagation();
this._startPointerDrag(e, el, block);
});
// interact.js resize only (right edge)
if (window.interact) {
interact(el)
.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;
},
// Native pointer drag — ghost on document.body (no overflow/clipping issues)
// Day detection uses pre-captured bounding rects, NOT elementFromPoint (unreliable with ghost)
_startPointerDrag(e, el, block) {
const startX = e.clientX;
const startY = e.clientY;
const elRect = el.getBoundingClientRect();
const startMin = this.parseTime(block.start);
const duration = this.parseTime(block.end) - startMin;
const snapGrid = this._minutesPx(this.GRID_MINUTES);
// ── Capture day row positions BEFORE any DOM change ──────────
const dayTimelines = Array.from(document.querySelectorAll('.day-timeline'));
const dayRows = dayTimelines.map(t => {
const r = t.getBoundingClientRect();
return { date: t.dataset.date, el: t, top: r.top, bottom: r.bottom };
});
// Release implicit pointer capture so pointermove fires freely on document
try { el.releasePointerCapture(e.pointerId); } catch (_) {}
// Prevent text selection + set grabbing cursor during drag
document.body.style.userSelect = 'none';
document.body.style.cursor = 'grabbing';
// ── Ghost element ─────────────────────────────────────────────
const ghost = document.createElement('div');
ghost.className = 'block-el drag-ghost';
ghost.innerHTML = el.innerHTML;
Object.assign(ghost.style, {
position: 'fixed',
left: elRect.left + 'px',
top: elRect.top + 'px',
width: elRect.width + 'px',
height: elRect.height + 'px',
background: el.style.background,
color: el.style.color,
zIndex: '9999',
opacity: '0.88',
pointerEvents: 'none',
cursor: 'grabbing',
boxShadow: '0 6px 20px rgba(0,0,0,0.3)',
borderRadius: '4px',
transition: 'none',
margin: '0',
});
document.body.appendChild(ghost);
el.style.opacity = '0.2';
// ── Helpers ───────────────────────────────────────────────────
const findRow = (clientY) => {
for (const d of dayRows) {
if (clientY >= d.top && clientY <= d.bottom) return d;
}
// Clamp to nearest row when pointer is between rows or outside canvas
if (!dayRows.length) return null;
if (clientY < dayRows[0].top) return dayRows[0];
return dayRows[dayRows.length - 1];
};
const clearHighlights = () =>
dayTimelines.forEach(r => r.classList.remove('drag-target'));
// ── Drag move ─────────────────────────────────────────────────
const onMove = (ev) => {
ghost.style.left = (elRect.left + ev.clientX - startX) + 'px';
ghost.style.top = (elRect.top + ev.clientY - startY) + 'px';
clearHighlights();
const row = findRow(ev.clientY);
if (row) row.el.classList.add('drag-target');
};
// ── Drag end ──────────────────────────────────────────────────
const onUp = (ev) => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
ghost.remove();
el.style.opacity = '';
clearHighlights();
document.body.style.userSelect = '';
document.body.style.cursor = '';
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
// Ignore micro-movements (treat as click, let click handler open modal)
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
// ── Update time (X axis) ──────────────────────────────────
const snappedDx = Math.round(dx / snapGrid) * snapGrid;
const deltaMin = snappedDx / this.pxPerMinute;
const newStart = this.snapMinutes(startMin + deltaMin);
const clamped = Math.max(this._startMin, Math.min(this._endMin - duration, newStart));
block.start = this.formatTime(clamped);
block.end = this.formatTime(clamped + duration);
// ── Update day (Y axis) — bounding rects, NO elementFromPoint ──
const row = findRow(ev.clientY);
if (row) block.date = row.date;
App.renderCanvas();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
},
_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();
},
};