// Timeline view: wraps vis-timeline (groups = boards, range items = bookings). import { Timeline, DataSet } from 'vis-timeline/standalone'; import type { TimelineOptions } from 'vis-timeline/standalone'; import { escapeHtml } from './util'; import { computeCollisions } from './collisions'; import { colorForStatus } from './appearance'; import type { Appearance } from './appearance'; import type { Board, Booking } from './types'; export interface RenderFlags { withBrand: boolean; withCompany: boolean; withCollisions: boolean; } export interface TimelineElements { timelineEl: HTMLElement; chartWrap: HTMLElement; tooltip: HTMLElement; empty: HTMLElement; colResizer: HTMLElement; } // Clicking a bar opens the corresponding Planfix task. const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/'; export const zoomSteps: ReadonlyArray<{ days: number; label: string }> = [ { days: 91, label: '3 месяца' }, { days: 183, label: '6 месяцев' }, { days: 274, label: '9 месяцев' }, { days: 365, label: '12 месяцев' }, { days: 457, label: '15 месяцев' }, { days: 548, label: '18 месяцев' }, { days: 639, label: '21 месяц' }, { days: 731, label: '24 месяца' }, { days: 822, label: '27 месяцев' }, { days: 913, label: '30 месяцев' }, ]; const MONTH_BG_ID = '__month_hover_bg'; const MIN_LABEL_W = 140; const MAX_LABEL_W = 760; export interface TimelineView { render(boards: Board[], bookings: Booking[], fit: boolean, flags: RenderFlags): void; /** Re-run the last render (e.g. after colours changed). */ refresh(): void; /** Re-apply row height / centring from appearance and redraw. */ applyLayout(): void; setScale(days: number): void; } export function createTimelineView(el: TimelineElements, appearance: Appearance): TimelineView { const groupsDS = new DataSet(); const itemsDS = new DataSet(); let lastBoards: Board[] = []; let lastBookings: Booking[] = []; let lastFlags: RenderFlags = { withBrand: true, withCompany: false, withCollisions: true }; const options: TimelineOptions = { editable: false, selectable: false, stack: true, zoomable: false, moveable: true, verticalScroll: true, zoomMin: zoomSteps[0]!.days * 86400000, zoomMax: zoomSteps[zoomSteps.length - 1]!.days * 86400000, groupHeightMode: 'auto', margin: { item: { horizontal: 2, vertical: 0 } }, orientation: { axis: 'top' }, tooltip: { followMouse: false, delay: 0 }, locale: 'ru', xss: { disabled: true }, }; const timeline = new Timeline(el.timelineEl, itemsDS, groupsDS, options); // ---- dynamic height / scroll fix ---- let lastSetHeightPx: number | null = null; function fitTimelineHeight(): void { const available = el.chartWrap.clientHeight; if (!available) return; const labelSet = el.timelineEl.querySelector('.vis-labelset'); const axisEl = el.timelineEl.querySelector('.vis-panel.vis-top'); const groupCount = groupsDS.length; const axisHeight = axisEl ? axisEl.offsetHeight : 0; const contentHeight = labelSet ? axisHeight + labelSet.scrollHeight : 0; if (!labelSet && groupCount > 0) return; const target = groupCount === 0 ? available : Math.min(contentHeight || available, available); const px = Math.max(1, Math.round(target)); if (px === lastSetHeightPx) return; lastSetHeightPx = px; timeline.setOptions({ height: px + 'px' }); } let heightFitQueued = false; function scheduleHeightFit(): void { if (heightFitQueued) return; heightFitQueued = true; requestAnimationFrame(() => { heightFitQueued = false; fitTimelineHeight(); }); } timeline.on('changed', scheduleHeightFit); window.addEventListener('resize', scheduleHeightFit); scheduleHeightFit(); // ---- clickable bars -> Planfix task ---- timeline.on('click', (props: any) => { if (!props.item) return; const item = itemsDS.get(props.item) as any; if (item && item.taskId) { window.open(PLANFIX_TASK_URL + encodeURIComponent(item.taskId), '_blank', 'noopener'); } }); // ---- draggable label-column width ---- function currentLabelW(): number { const v = getComputedStyle(document.documentElement).getPropertyValue('--label-w'); return parseInt(v, 10) || 340; } function positionResizer(): void { const leftPanel = el.timelineEl.querySelector('.vis-panel.vis-left'); if (!leftPanel || el.timelineEl.style.display === 'none') { el.colResizer.style.display = 'none'; return; } const wrapRect = el.chartWrap.getBoundingClientRect(); const panelRect = leftPanel.getBoundingClientRect(); el.colResizer.style.left = panelRect.right - wrapRect.left + 'px'; el.colResizer.style.display = 'block'; } timeline.on('changed', positionResizer); window.addEventListener('resize', positionResizer); let colDragging = false; let colDragStartX = 0; let colDragStartW = 0; let colDragLastX = 0; let colDragRafPending = false; function applyColDrag(): void { colDragRafPending = false; let w = colDragStartW + (colDragLastX - colDragStartX); w = Math.max(MIN_LABEL_W, Math.min(MAX_LABEL_W, w)); document.documentElement.style.setProperty('--label-w', w + 'px'); timeline.redraw(); positionResizer(); } el.colResizer.addEventListener('mousedown', (e) => { colDragging = true; colDragStartX = e.clientX; colDragStartW = currentLabelW(); el.colResizer.classList.add('dragging'); document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; e.preventDefault(); }); window.addEventListener('mousemove', (e) => { if (!colDragging) return; colDragLastX = e.clientX; if (colDragRafPending) return; colDragRafPending = true; requestAnimationFrame(applyColDrag); }); window.addEventListener('mouseup', () => { if (!colDragging) return; colDragging = false; el.colResizer.classList.remove('dragging'); document.body.style.userSelect = ''; document.body.style.cursor = ''; scheduleHeightFit(); }); // ---- tooltip ---- let hoveredItemId: string | number | null = null; timeline.on('itemover', (props: any) => { hoveredItemId = props.item; const item = itemsDS.get(props.item) as any; if (item) { el.tooltip.textContent = item.tooltipText; el.tooltip.style.display = 'block'; } }); timeline.on('itemout', () => { hoveredItemId = null; el.tooltip.style.display = 'none'; }); document.addEventListener('mousemove', (e) => { if (hoveredItemId !== null) { el.tooltip.style.left = e.clientX + 12 + 'px'; el.tooltip.style.top = e.clientY + 12 + 'px'; } }); // ---- row hover highlight ---- let hoveredRowIdx = -1; function setRowHover(idx: number): void { if (idx === hoveredRowIdx) return; const labels = document.querySelectorAll('#timeline .vis-label'); const rows = document.querySelectorAll('#timeline .vis-group'); if (hoveredRowIdx >= 0) { labels[hoveredRowIdx]?.classList.remove('row-hover'); rows[hoveredRowIdx]?.classList.remove('row-hover'); } if (idx >= 0) { labels[idx]?.classList.add('row-hover'); rows[idx]?.classList.add('row-hover'); } hoveredRowIdx = idx; } // ---- hovered-month column highlight ---- let lastMonthKey: string | null = null; timeline.on('mouseMove', (props: any) => { if (props.time) { const t: Date = props.time; const y = t.getUTCFullYear(); const m = t.getUTCMonth(); const key = y + '-' + m; if (key !== lastMonthKey) { lastMonthKey = key; const monthStart = new Date(Date.UTC(y, m, 1)); const monthEnd = new Date(Date.UTC(y, m + 1, 1)); itemsDS.update([ { id: MONTH_BG_ID, type: 'background', start: monthStart, end: monthEnd, className: 'month-hover-bg' }, ]); } } if (props.group !== null && props.group !== undefined) { const idx = lastBoards.findIndex((b) => b.board_id === props.group); setRowHover(idx); } else { setRowHover(-1); } }); el.timelineEl.addEventListener('mouseleave', () => { lastMonthKey = null; itemsDS.remove(MONTH_BG_ID); setRowHover(-1); }); function render(boards: Board[], bookings: Booking[], fit: boolean, flags: RenderFlags): void { lastBoards = boards; lastBookings = bookings; lastFlags = flags; if (boards.length === 0) { groupsDS.clear(); itemsDS.clear(); el.timelineEl.style.display = 'none'; el.empty.style.display = 'block'; return; } el.timelineEl.style.display = ''; el.empty.style.display = 'none'; const collisionPartners = flags.withCollisions ? computeCollisions(bookings) : new Map(); const groups = boards.map((b, idx) => ({ id: b.board_id, content: escapeHtml(b.board_address) + ' (' + escapeHtml(b.board_id) + ')', order: idx, className: idx % 2 === 0 ? 'row-even' : 'row-odd', })); const items = bookings.map((bk, idx) => { const end = new Date(bk.end_date + 'T00:00:00Z'); end.setUTCDate(end.getUTCDate() + 1); const brand = bk.brand || ''; const company = bk.company_name || ''; const barParts: string[] = []; if (flags.withBrand && brand) barParts.push(escapeHtml(brand)); if (flags.withCompany && company) barParts.push(escapeHtml(company)); const barContent = barParts.join(' | '); const tooltipLines: string[] = []; if (bk.task_id) tooltipLines.push(bk.task_id); if (brand) tooltipLines.push(brand); if (company) tooltipLines.push(company); tooltipLines.push(bk.start_date + ' — ' + bk.end_date); if (bk.task_status) tooltipLines.push(bk.task_status); const partnerIdxs = collisionPartners.get(idx); const isCollision = !!(partnerIdxs && partnerIdxs.length); if (isCollision) { tooltipLines.push('⚠ Коллизия с:'); for (const pIdx of partnerIdxs!) { const p = bookings[pIdx]!; const pLabel = p.brand || p.company_name || 'Без названия'; tooltipLines.push(' ' + pLabel + ' ' + p.start_date + ' — ' + p.end_date); } } // Colour: collision colour overrides the per-status colour. const color = isCollision ? appearance.collisionColor : colorForStatus(appearance, bk.task_status); return { id: bk.board_id + '__' + idx, group: bk.board_id, start: bk.start_date, end: end.toISOString().slice(0, 10), content: `
${barContent}
`, type: 'range', tooltipText: tooltipLines.join('\n'), taskId: bk.task_id, }; }); groupsDS.clear(); groupsDS.add(groups); itemsDS.clear(); itemsDS.add(items); lastMonthKey = null; if (fit) timeline.fit({ animation: false }); scheduleHeightFit(); } function refresh(): void { if (lastBoards.length) render(lastBoards, lastBookings, false, lastFlags); } function applyLayout(): void { timeline.setOptions({ margin: { item: { horizontal: 2, vertical: 0 } } }); timeline.redraw(); scheduleHeightFit(); } function setScale(days: number): void { const range = timeline.getWindow(); const center = (range.start.getTime() + range.end.getTime()) / 2; const half = (days * 86400000) / 2; timeline.setWindow(new Date(center - half), new Date(center + half)); } return { render, refresh, applyLayout, setScale }; }