mapdash/frontend/src/timeline.ts

315 lines
11 KiB
TypeScript
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.

// 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 };
}