// 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, statusLabel } from './appearance'; import type { Appearance } from './appearance'; import type { Board, Booking } from './types'; export interface RenderFlags { withBrand: boolean; withCompany: boolean; withCollisions: boolean; // Manager tier: show the rich tooltip (title, task №, status, manager, Planfix // hint). Anonymous tier gets a minimal tooltip with only the date range. managerView: boolean; // Which info elements the first column shows (toggled in the column header). showCity: boolean; showAddress: boolean; showCode: boolean; } export interface TimelineElements { timelineEl: HTMLElement; chartWrap: HTMLElement; tooltip: HTMLElement; empty: HTMLElement; colResizer: HTMLElement; } // First-column label: city · address · code (only the parts enabled in the // header), separated by a small dot. Falls back to the address so a row is never // empty (the header also forbids turning every part off). function groupContent(b: Board, flags: RenderFlags): string { const dot = ''; const parts: string[] = []; if (flags.showCity && b.board_city) parts.push(`${escapeHtml(b.board_city)}`); if (flags.showAddress && b.board_address) parts.push(`${escapeHtml(b.board_address)}`); if (flags.showCode && b.board_id) parts.push(`${escapeHtml(b.board_id)}`); if (!parts.length) parts.push(`${escapeHtml(b.board_address)}`); return `${parts.join(dot)}`; } // 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: 183, label: '6М' }, { days: 365, label: '12М' }, { days: 548, label: '18М' }, { days: 731, label: '24М' }, { days: 913, label: '30М' }, ]; const MONTH_BG_ID = '__month_hover_bg'; const CUR_MONTH_BG_ID = '__cur_month_bg'; const MIN_LABEL_W = 200; // fits the three column-info pills (~188px) without clipping // Uniform 3-letter month labels (moment's ru `MMM` mixes full/abbreviated with // dots — "март", "май", "нояб.", "дек."). scale is 'month' at every zoom step. const MONTHS_SHORT = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']; function axisMinorLabel(date: unknown, scale: string): string { const d = new Date((date as { valueOf(): number }).valueOf()); if (scale === 'month') return MONTHS_SHORT[d.getMonth()]!; if (scale === 'year') return String(d.getFullYear()); return String(d.getDate()); } function axisMajorLabel(date: unknown): string { return String(new Date((date as { valueOf(): number }).valueOf()).getFullYear()); } const MAX_LABEL_W = 760; // "2026-06-01" -> "01.06.2026" function fmtDate(iso: string): string { const p = iso.split('-'); return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso; } function pluralRu(n: number, one: string, few: string, many: string): string { const m10 = n % 10; const m100 = n % 100; if (m10 === 1 && m100 !== 11) return one; if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return few; return many; } // Inclusive span between two ISO dates as a human label ("14 дней" / "7 мес"). function durationLabel(startIso: string, endIso: string): string { const s = new Date(startIso + 'T00:00:00Z').getTime(); const e = new Date(endIso + 'T00:00:00Z').getTime(); if (Number.isNaN(s) || Number.isNaN(e) || e < s) return ''; const days = Math.round((e - s) / 86400000) + 1; if (days < 31) return `${days} ${pluralRu(days, 'день', 'дня', 'дней')}`; return `${Math.round(days / 30.44)} мес`; } 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; /** Update only the first-column labels from new info-column flags (light — * doesn't rebuild bars). */ refreshLabels(flags: RenderFlags): void; /** Re-apply row height / centring from appearance and redraw. */ applyLayout(): void; setScale(days: number): void; /** Called with a board_id when its row label is clicked. */ setOnBoardClick(fn: (boardId: string) => void): 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, managerView: true, showCity: false, showAddress: true, showCode: true }; let onBoardClick: ((boardId: string) => void) | null = null; 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', format: { minorLabels: axisMinorLabel, majorLabels: axisMajorLabel }, 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(); // When a bar's label is wider than the bar itself, move it just outside the bar // (dark text on the track) instead of clipping it inside. Default side is the // right of the bar; if the label would collide with the next bar on the same // row, put it to the left instead. Re-run on every redraw (zoom/pan changes bar // widths). Batched: reset all, measure all, then apply — at most one reflow. let barTextRaf = 0; const BAR_LABEL_GAP = 6; function layoutBarText(): void { const items = Array.from(el.timelineEl.querySelectorAll('.vis-item.vis-range')); for (const it of items) it.classList.remove('bar-of', 'bar-of-left'); const center = el.timelineEl.querySelector('.vis-panel.vis-center'); const bound = center ? center.getBoundingClientRect() : { left: 0, right: window.innerWidth }; // Per row (bars share a vertical band), collect every bar's horizontal span so // we can find each overflowing label's nearest neighbour on either side. const rows = new Map(); const overflow: { it: HTMLElement; row: number; left: number; right: number; textW: number }[] = []; for (const it of items) { const inner = it.querySelector('.bar-inner'); if (!inner) continue; const r = inner.getBoundingClientRect(); if (r.width === 0) continue; const row = Math.round(r.top); (rows.get(row) ?? rows.set(row, []).get(row)!).push({ left: r.left, right: r.right }); if (inner.scrollWidth > inner.clientWidth + 1) { const txt = it.querySelector('.bar-text'); overflow.push({ it, row, left: r.left, right: r.right, textW: txt ? txt.scrollWidth : 0 }); } } const left: HTMLElement[] = []; const right: HTMLElement[] = []; for (const c of overflow) { const bars = rows.get(c.row)!; let nextLeft = bound.right; let prevRight = bound.left; for (const b of bars) { if (b.left > c.right + 0.5 && b.left < nextLeft) nextLeft = b.left; if (b.right < c.left - 0.5 && b.right > prevRight) prevRight = b.right; } const need = c.textW + BAR_LABEL_GAP; const gapRight = nextLeft - c.right; const gapLeft = c.left - prevRight; // Prefer right; fall back to left when the right label would overlap the // next bar; if neither side fits, take whichever has more room. if (need <= gapRight) right.push(c.it); else if (need <= gapLeft) left.push(c.it); else if (gapRight >= gapLeft) right.push(c.it); else left.push(c.it); } for (const it of right) it.classList.add('bar-of'); for (const it of left) it.classList.add('bar-of-left'); } function scheduleBarText(): void { // Debounce (not throttle): a zoom/redraw fires several 'changed' events in a // burst and vis recreates item DOM between them. Wait for the burst to settle // so we measure final bar widths and our classes aren't wiped by a later pass. clearTimeout(barTextRaf); barTextRaf = window.setTimeout(layoutBarText, 110); } // 'changed' = data/redraw; 'rangechanged' = zoom/pan settled (bars resized). timeline.on('changed', scheduleBarText); timeline.on('rangechanged', scheduleBarText); // ---- clicks: bar -> Planfix task; row label -> board detail panel ---- timeline.on('click', (props: any) => { if (props.item) { const item = itemsDS.get(props.item) as any; if (item && item.taskId) { window.open(PLANFIX_TASK_URL + encodeURIComponent(item.taskId), '_blank', 'noopener'); } return; } if (props.what === 'group-label' && props.group != null && onBoardClick) { onBoardClick(String(props.group)); } }); // ---- 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); // Keep the floating scale control just below the time axis (5px gap) so it // never overlaps the axis; the axis height varies, so measure it each redraw. function positionZoomField(): void { const axis = el.timelineEl.querySelector('.vis-panel.vis-top'); if (!axis) return; const axisBottom = axis.getBoundingClientRect().bottom - el.chartWrap.getBoundingClientRect().top; const zf = document.getElementById('zoom-field'); if (zf) zf.style.top = axisBottom + 5 + 'px'; // The column-info header fills the empty top-left corner (above the labels). const ci = document.getElementById('col-info'); if (ci) ci.style.height = axisBottom + 'px'; } timeline.on('changed', positionZoomField); window.addEventListener('resize', positionZoomField); positionZoomField(); 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.innerHTML = item.tooltipHtml; el.tooltip.style.display = 'block'; } }); timeline.on('itemout', () => { hoveredItemId = null; el.tooltip.style.display = 'none'; }); document.addEventListener('mousemove', (e) => { if (hoveredItemId === null) return; const pad = 12; const tw = el.tooltip.offsetWidth; const th = el.tooltip.offsetHeight; let left = e.clientX + pad; let top = e.clientY + pad; // Flip above the cursor when the tooltip would spill past the bottom edge. if (top + th > window.innerHeight - 8) top = e.clientY - pad - th; // Flip to the left when it would spill past the right edge. if (left + tw > window.innerWidth - 8) left = e.clientX - pad - tw; if (top < 8) top = 8; if (left < 8) left = 8; el.tooltip.style.left = left + 'px'; el.tooltip.style.top = top + '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 = 'flex'; 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: groupContent(b, flags), 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 partnerIdxs = collisionPartners.get(idx); const isCollision = !!(partnerIdxs && partnerIdxs.length); // Colour: collision colour overrides the per-status colour. const statusColor = colorForStatus(appearance, bk.task_status); const color = isCollision ? appearance.collisionColor : statusColor; // ---- hover tooltip (structured HTML; values escaped) ---- const esc = escapeHtml; const dur = durationLabel(bk.start_date, bk.end_date); const datesRow = `
📅${fmtDate(bk.start_date)} – ${fmtDate(bk.end_date)}${dur ? ' · ' + esc(dur) : ''}
`; let tooltipHtml: string; if (!flags.managerView) { // Anonymous tier: date range only — no title, task №, status or Planfix hint. tooltipHtml = `
${datesRow}
`; } else { const title = brand || company || 'Без названия'; const managers = (bk.manager || []).filter(Boolean).join(', '); const tt: string[] = []; tt.push('
'); tt.push(`${esc(title)}`); if (bk.task_id) tt.push(`№${esc(bk.task_id)}`); tt.push('
'); if (brand && company) tt.push(`
${esc(company)}
`); tt.push('
'); tt.push(datesRow); if (bk.task_status) tt.push(`
${esc(statusLabel(bk.task_status))}
`); if (managers) tt.push(`
👤${esc(managers)}
`); if (isCollision) { tt.push('
⚠ Коллизия
'); for (const pIdx of partnerIdxs!) { const p = bookings[pIdx]!; const pLabel = p.brand || p.company_name || 'Без названия'; tt.push(`
${esc(pLabel)} ${fmtDate(p.start_date)} – ${fmtDate(p.end_date)}
`); } tt.push('
'); } tt.push('
↗ Клик — открыть в ПланФикс
'); tooltipHtml = `
${tt.join('')}
`; } return { id: bk.board_id + '__' + idx, group: bk.board_id, start: bk.start_date, end: end.toISOString().slice(0, 10), content: `
${barContent}
`, type: 'range', tooltipHtml, taskId: bk.task_id, }; }); groupsDS.clear(); groupsDS.add(groups); itemsDS.clear(); itemsDS.add(items); // Persistent tint on the current-month column (aligned to the axis months, // which the hover highlight also keys off UTC month boundaries). const now = new Date(); itemsDS.add({ id: CUR_MONTH_BG_ID, type: 'background', start: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)), end: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)), className: 'cur-month-bg', } as never); lastMonthKey = null; if (fit) timeline.fit({ animation: false }); scheduleHeightFit(); scheduleBarText(); } function refresh(): void { if (lastBoards.length) render(lastBoards, lastBookings, false, lastFlags); } // Light: only rewrite the group (row-label) contents — the bars/collisions // stay put, so toggling a column doesn't rebuild the whole timeline. function refreshLabels(f: RenderFlags): void { lastFlags = f; if (!lastBoards.length) return; groupsDS.update(lastBoards.map((b) => ({ id: b.board_id, content: groupContent(b, f) }))); } 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, refreshLabels, applyLayout, setScale, setOnBoardClick(fn: (boardId: string) => void): void { onBoardClick = fn; }, }; }