mapdash/frontend/src/timeline.ts
aaverbitskiy f0c53e2ce9 Выгрузка карты из памяти + фиксы столбца адресов
1) Карта выгружается (map.destroy()) через 2с после ухода в режим «График»
   — освобождает тайлы/canvas/GPU (~350–450 МБ); отменяется при возврате.
   Метод MapView.destroy() (сброс map/objectManager/initPromise/pending);
   в onModeChange сброс mapLoadedKey, иначе refreshMap короткозамыкал render и
   свежий ObjectManager оставался пустым.

2) MIN_LABEL_W 140→200 — три пилюли Город/Адрес/Код не обрезаются на узком
   столбце; container-query прячет подпись «Показывать» при ширине < 255px.

3) Правый/левый отступ 7px у содержимого строки (#timeline .vis-label .vis-inner,
   специфичность выше собственного padding vis-timeline) — адрес не липнет к
   границе столбца.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 05:33:30 +00:00

543 lines
22 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).
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 = '<span class="ci-dot" aria-hidden="true"></span>';
const parts: string[] = [];
if (flags.showCity && b.board_city) parts.push(`<span class="ci-city">${escapeHtml(b.board_city)}</span>`);
if (flags.showAddress && b.board_address) parts.push(`<span class="ci-addr">${escapeHtml(b.board_address)}</span>`);
if (flags.showCode && b.board_id) parts.push(`<span class="ci-code">${escapeHtml(b.board_id)}</span>`);
if (!parts.length) parts.push(`<span class="ci-addr">${escapeHtml(b.board_address)}</span>`);
return `<span class="ci-cell">${parts.join(dot)}</span>`;
}
// 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<any>();
const itemsDS = new DataSet<any>();
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<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();
// 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<HTMLElement>('.vis-item.vis-range'));
for (const it of items) it.classList.remove('bar-of', 'bar-of-left');
const center = el.timelineEl.querySelector<HTMLElement>('.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<number, { left: number; right: number }[]>();
const overflow: { it: HTMLElement; row: number; left: number; right: number; textW: number }[] = [];
for (const it of items) {
const inner = it.querySelector<HTMLElement>('.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<HTMLElement>('.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<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);
// 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<HTMLElement>('.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<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 ----
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<number, number[]>();
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 = `<div class="tt-row"><span class="tt-ico">📅</span><span>${fmtDate(bk.start_date)} ${fmtDate(bk.end_date)}${dur ? ' · ' + esc(dur) : ''}</span></div>`;
let tooltipHtml: string;
if (!flags.managerView) {
// Anonymous tier: date range only — no title, task №, status or Planfix hint.
tooltipHtml = `<div class="tt" style="--tt-accent:${color}">${datesRow}</div>`;
} else {
const title = brand || company || 'Без названия';
const managers = (bk.manager || []).filter(Boolean).join(', ');
const tt: string[] = [];
tt.push('<div class="tt-head">');
tt.push(`<span class="tt-title">${esc(title)}</span>`);
if (bk.task_id) tt.push(`<span class="tt-id">№${esc(bk.task_id)}</span>`);
tt.push('</div>');
if (brand && company) tt.push(`<div class="tt-sub">${esc(company)}</div>`);
tt.push('<div class="tt-sep"></div>');
tt.push(datesRow);
if (bk.task_status) tt.push(`<div class="tt-row"><span class="tt-dot" style="background:${statusColor}"></span><span>${esc(statusLabel(bk.task_status))}</span></div>`);
if (managers) tt.push(`<div class="tt-row"><span class="tt-ico">👤</span><span>${esc(managers)}</span></div>`);
if (isCollision) {
tt.push('<div class="tt-warn"><div class="tt-warn-head">⚠ Коллизия</div>');
for (const pIdx of partnerIdxs!) {
const p = bookings[pIdx]!;
const pLabel = p.brand || p.company_name || 'Без названия';
tt.push(`<div class="tt-warn-row">${esc(pLabel)} <span class="tt-warn-dates">${fmtDate(p.start_date)} ${fmtDate(p.end_date)}</span></div>`);
}
tt.push('</div>');
}
tt.push('<div class="tt-hint">↗ Клик — открыть в ПланФикс</div>');
tooltipHtml = `<div class="tt" style="--tt-accent:${color}">${tt.join('')}</div>`;
}
return {
id: bk.board_id + '__' + idx,
group: bk.board_id,
start: bk.start_date,
end: end.toISOString().slice(0, 10),
content: `<div class="bar-inner" style="background-color:${color};"><span class="bar-text">${barContent}</span></div>`,
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; },
};
}