315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
// Timeline view: wraps vis-timeline (groups = boards, range items = bookings).
|
||
// Ported 1:1 from the original static/index.html logic, split into a module.
|
||
import { Timeline, DataSet } from 'vis-timeline/standalone';
|
||
import type { TimelineOptions } from 'vis-timeline/standalone';
|
||
import { escapeHtml } from './util';
|
||
import { computeCollisions } from './collisions';
|
||
import { ARCHIVED_STATUS } from './types';
|
||
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;
|
||
}
|
||
|
||
// Discrete zoom steps for the header slider, one per 3-month increment from 3
|
||
// to 30 months. Index 3 (12 months) is the default on load.
|
||
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;
|
||
setScale(days: number): void;
|
||
}
|
||
|
||
export function createTimelineView(el: TimelineElements): TimelineView {
|
||
const groupsDS = new DataSet<any>();
|
||
const itemsDS = new DataSet<any>();
|
||
let lastBoards: Board[] = [];
|
||
|
||
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: 'fixed',
|
||
margin: { item: { horizontal: 2, vertical: 6 } },
|
||
orientation: { axis: 'top' },
|
||
tooltip: { followMouse: false, delay: 0 },
|
||
locale: 'ru',
|
||
};
|
||
|
||
const timeline = new Timeline(el.timelineEl, itemsDS, groupsDS, options);
|
||
|
||
// ---- dynamic height / scroll fix: push an explicit pixel height computed
|
||
// from the real rendered DOM, recomputed on every redraw and window resize.
|
||
let lastSetHeightPx: number | null = null;
|
||
function fitTimelineHeight(): void {
|
||
const available = el.chartWrap.clientHeight;
|
||
if (!available) return;
|
||
const labelSet = el.timelineEl.querySelector<HTMLElement>('.vis-labelset');
|
||
const axisEl = el.timelineEl.querySelector<HTMLElement>('.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();
|
||
|
||
// ---- draggable label-column width (drives --label-w; vis panel follows it).
|
||
function currentLabelW(): number {
|
||
const v = getComputedStyle(document.documentElement).getPropertyValue('--label-w');
|
||
return parseInt(v, 10) || 340;
|
||
}
|
||
function positionResizer(): void {
|
||
const leftPanel = el.timelineEl.querySelector<HTMLElement>('.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 following the cursor.
|
||
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 (DOM class toggling, no DataSet churn).
|
||
let hoveredRowIdx = -1;
|
||
function setRowHover(idx: number): void {
|
||
if (idx === hoveredRowIdx) return;
|
||
const labels = document.querySelectorAll<HTMLElement>('#timeline .vis-label');
|
||
const rows = document.querySelectorAll<HTMLElement>('#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 (single background item, month-snapped).
|
||
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;
|
||
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<number, number[]>();
|
||
|
||
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); // exclusive end so the last day shows fully
|
||
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(' | ');
|
||
|
||
// Tooltip: task_id / brand / company / start — end / task_status, then collisions.
|
||
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);
|
||
}
|
||
}
|
||
|
||
const item: any = {
|
||
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'),
|
||
};
|
||
// Colour priority: collision (purple) > archived (slate) > normal (blue).
|
||
const isArchived = bk.task_status === ARCHIVED_STATUS;
|
||
if (isCollision) item.className = 'collision';
|
||
else if (isArchived) item.className = 'archived';
|
||
return item;
|
||
});
|
||
|
||
groupsDS.clear();
|
||
groupsDS.add(groups);
|
||
itemsDS.clear();
|
||
itemsDS.add(items);
|
||
lastMonthKey = null;
|
||
|
||
if (fit) timeline.fit({ animation: false });
|
||
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, setScale };
|
||
}
|