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>
543 lines
22 KiB
TypeScript
543 lines
22 KiB
TypeScript
// 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; },
|
||
};
|
||
}
|