Compare commits

..

5 Commits

Author SHA1 Message Date
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
aaverbitskiy
17b70b1f44 Колонка Город/Адрес/Код + оформление шкалы времени
1) Первая колонка графика: в углу — кнопки «Показывать» Город/Адрес/Код
   (стиль пилюль, активная — фиолетовая). Строка собирается из включённых
   сущностей, разделённых кружком 7px; код без скобок; адрес тёмный, город/код
   приглушённые. «Адрес» (или последний активный) нельзя снять — строка не пустеет.
   Доступно всем ролям, выбор в localStorage. Лёгкое обновление подписей
   (view.refreshLabels — без пересборки баров).

2) Временная шкала: единые 3-буквенные месяцы (format.minorLabels/majorLabels),
   год индиго и жирнее (.vis-text.vis-major), подсветка текущего месяца
   (фон-элемент cur-month-bg). Красная линия «сегодня» без изменений.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 20:56:33 +00:00
aaverbitskiy
3939dbf8f5 Плашка масштаба графика — сворачиваемая с анимацией
По умолчанию плашка свёрнута (65×35) — белая плашка с фиолетовым пилюлем текущего
масштаба («12М» и т.п.). При наведении разворачивается (145×80) в полный контрол
(лейбл + слайдер + баббл) с плавной анимацией размеров (overflow:hidden + фикс.
размеры) и кросс-фейдом содержимого. Сворачивается обратно через 1с после ухода
курсора (JS-таймер в main.ts, класс .expanded; повторное наведение отменяет). В
развёрнутом виде содержимое смещено на 4px вниз для вертикальной центровки.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:34:51 +00:00
aaverbitskiy
43bb72de61 Дропдауны: рамка поиска не режется + ширина ≤350px с горизонтальным скроллом
- Поле .dp-search било по специфичности правилом .hgroup-fields input[type=text]
  {width:100%} (0,2,1) — поле всегда было 100% (=padding-box со скроллом), из-за
  чего рамка резалась краем и появлялась фантомная горизонтальная полоса. Селектор
  усилен до .dropdown-panel input.dp-search (0,2,1, позже в источнике), ширина —
  fill-available (заполняет доступную ширину минус вертикальный скролл).
- Панель: max-width 350px + overflow:auto — контент шире 350px даёт горизонтальный
  скролл (убраны overflow-x:hidden и scrollbar-gutter из прошлой правки).

Задеплоено на прод без тега.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 08:50:20 +00:00
aaverbitskiy
691ec36b9e Дропдауны фильтров: убрана горизонтальная прокрутка в раскрывающейся панели
Панель подстраивает ширину под контент (width: max-content, min 220, max 96vw),
overflow-x: hidden убирает фантомную горизонтальную полосу от вертикального
скролла (метки-флексы тянулись на полную ширину под скроллбар), scrollbar-gutter:
stable резервирует место под вертикальный скролл. Задеплоено на прод без тега.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 08:29:20 +00:00
6 changed files with 243 additions and 24 deletions

View File

@ -164,13 +164,22 @@
<div id="pane-timeline">
<div id="chart-wrap">
<div id="timeline"></div>
<div id="col-info">
<span class="ci-label">Показывать</span>
<button type="button" class="ci-toggle" id="ci-city" data-part="city">Город</button>
<button type="button" class="ci-toggle is-on" id="ci-address" data-part="address">Адрес</button>
<button type="button" class="ci-toggle is-on" id="ci-code" data-part="code">Код</button>
</div>
<div class="field zoom-field" id="zoom-field">
<span id="zoom-collapsed" class="zoom-collapsed">12М</span>
<div class="zoom-full">
<label for="zoom-slider">Масштаб графика</label>
<div class="zoom-row">
<span id="zoom-label" class="zoom-bubble">12М</span>
<input type="range" id="zoom-slider" min="0" max="4" step="1" value="1" />
</div>
</div>
</div>
<div id="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div>
<div id="empty" class="empty-state" style="display:none">
<svg class="empty-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>

View File

@ -1,7 +1,7 @@
{
"name": "mapdash-frontend",
"private": true,
"version": "0.2.16",
"version": "1.0.2",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -35,6 +35,8 @@ const el = {
showAllSurfaces: document.getElementById('show-all-surfaces') as HTMLInputElement,
zoomSlider: document.getElementById('zoom-slider') as HTMLInputElement,
zoomLabel: document.getElementById('zoom-label') as HTMLElement,
zoomCollapsed: document.getElementById('zoom-collapsed') as HTMLElement,
zoomField: document.getElementById('zoom-field') as HTMLElement,
chartWrap: document.getElementById('chart-wrap') as HTMLElement,
colResizer: document.getElementById('col-resizer') as HTMLElement,
decorBtn: document.getElementById('decor-btn') as HTMLButtonElement,
@ -134,17 +136,32 @@ const mapView = createMapView(
let lastBoards: Board[] = [];
let lastBookings: Booking[] = [];
// Which info elements the first column shows — toggled in the column header,
// persisted per browser. Available to every role (address/code already shown to
// anonymous; city is not sensitive). A row is never empty (see the toggle handler).
const CI_LS = 'mapdash.infoCols';
type InfoCols = { city: boolean; address: boolean; code: boolean };
const infoCols: InfoCols = ((): InfoCols => {
try {
const s = JSON.parse(localStorage.getItem(CI_LS) || 'null');
if (s && typeof s.city === 'boolean' && typeof s.address === 'boolean' && typeof s.code === 'boolean') return s;
} catch { /* fall through to default */ }
return { city: false, address: true, code: true };
})();
function flags(): RenderFlags {
const cols = { showCity: infoCols.city, showAddress: infoCols.address, showCode: infoCols.code };
// Anonymous tier: no labels (data is blanked anyway) and no collision
// highlighting, so every bar renders in the single neutral status colour.
if (!isManager()) {
return { withBrand: false, withCompany: false, withCollisions: false, managerView: false };
return { withBrand: false, withCompany: false, withCollisions: false, managerView: false, ...cols };
}
return {
withBrand: el.showBrand.checked,
withCompany: el.showCompany.checked,
withCollisions: el.showCollisions.checked,
managerView: true,
...cols,
};
}
@ -403,10 +420,25 @@ async function refreshMap(): Promise<void> {
}
}
let mapDestroyTimer = 0;
async function onModeChange(mode: ViewMode): Promise<void> {
if (mode !== 'map') view.applyLayout(); // timeline visible (timeline or split)
if (mode === 'timeline') mapView.closePanel(); // map hidden — drop its floating card
if (mode !== 'timeline') await refreshMap(); // map visible (map or split)
if (mode === 'timeline') {
mapView.closePanel(); // map hidden — drop its floating card
// Free the Yandex map (tiles/canvas/GPU — the bulk of the tab's memory) a
// couple of seconds after it's closed; cancelled if a map view returns, so
// quick График⇄Карта toggles don't thrash a re-init.
window.clearTimeout(mapDestroyTimer);
mapDestroyTimer = window.setTimeout(() => {
mapView.destroy();
// The fresh map/ObjectManager will be empty, so drop the "already loaded"
// key — the next open re-fetches + re-renders instead of short-circuiting.
mapLoadedKey = '';
}, 2000);
} else {
window.clearTimeout(mapDestroyTimer); // map visible again — keep it
await refreshMap(); // map visible (map or split); ensureInit rebuilds if destroyed
}
}
const viewModes = createViewModes(
@ -460,11 +492,44 @@ function updateZoomUi(): void {
el.zoomSlider.addEventListener('input', () => {
const step = zoomSteps[parseInt(el.zoomSlider.value, 10)]!;
el.zoomLabel.textContent = step.label;
el.zoomCollapsed.textContent = step.label;
view.setScale(step.days);
updateZoomUi();
});
window.addEventListener('resize', updateZoomUi);
// The scale plate is collapsed to a small value pill by default; expand on hover
// and collapse again 1s after the pointer leaves (so it doesn't snap shut while
// you're reaching back toward it).
let zoomCollapseTimer = 0;
el.zoomField.addEventListener('mouseenter', () => {
window.clearTimeout(zoomCollapseTimer);
el.zoomField.classList.add('expanded');
updateZoomUi(); // realign the bubble now that the slider has a real width
});
el.zoomField.addEventListener('mouseleave', () => {
window.clearTimeout(zoomCollapseTimer);
zoomCollapseTimer = window.setTimeout(() => el.zoomField.classList.remove('expanded'), 1000);
});
// Column-info header: toggle city / address / code shown in the first column.
const ciButtons = Array.from(document.querySelectorAll<HTMLElement>('.ci-toggle'));
function syncInfoButtons(): void {
for (const btn of ciButtons) btn.classList.toggle('is-on', infoCols[btn.dataset.part as keyof InfoCols]);
}
for (const btn of ciButtons) {
btn.addEventListener('click', () => {
const part = btn.dataset.part as keyof InfoCols;
// Never leave the row empty: can't turn off the last remaining part.
if (infoCols[part] && !(['city', 'address', 'code'] as (keyof InfoCols)[]).some((k) => k !== part && infoCols[k])) return;
infoCols[part] = !infoCols[part];
localStorage.setItem(CI_LS, JSON.stringify(infoCols));
syncInfoButtons();
view.refreshLabels(flags());
});
}
syncInfoButtons();
// Clicking a surface in the timeline's left column flies the map to it: switch to
// the combined "Карта и график" view if only the timeline is open, then focus the
// surface's cluster. Works for anonymous and manager users alike.
@ -494,6 +559,7 @@ async function init(): Promise<void> {
const defaultZoomIdx = 1; // 12М
el.zoomSlider.value = String(defaultZoomIdx);
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label;
el.zoomCollapsed.textContent = zoomSteps[defaultZoomIdx]!.label;
view.setScale(zoomSteps[defaultZoomIdx]!.days);
updateZoomUi();
// Apply the persisted view mode (inits the map if it starts visible).

View File

@ -63,6 +63,9 @@ export interface MapView {
ensureInit(): Promise<void>;
invalidateSize(): void;
closePanel(): void;
/** Destroy the Yandex map and free its tiles/canvas/GPU memory. The next
* ensureInit() re-creates it from scratch. */
destroy(): void;
/** Pan + zoom the map to the surface's location (its cluster). No-op if it has no coords. */
focusBoard(boardId: string): void;
}
@ -633,6 +636,20 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
closePanel(): void {
closeMapPanel();
},
destroy(): void {
closeMapPanel();
// map.destroy() tears down Yandex's DOM inside #map and releases its tile
// cache / canvas / GPU textures. Our legend/counter overlays are siblings
// of #map, so they survive. Reset state so ensureInit() rebuilds cleanly.
if (map) {
try { map.destroy(); } catch { /* already torn down */ }
}
map = null;
objectManager = null;
initPromise = null;
pendingFocus = null;
pending = null;
},
focusBoard(boardId: string): void {
pendingFocus = boardId;
applyFocus(); // applies now if already rendered, else waits for the next draw

View File

@ -35,6 +35,7 @@
--row-odd: #ffffff;
--row-hover: #fff3cd;
--month-hover: rgba(79, 70, 229, .08);
--cur-month: rgba(79, 70, 229, .07);
--tip-bg: var(--gray-9);
--tip-text: #ffffff;
--tip-sub: var(--gray-4);
@ -60,6 +61,7 @@
--row-odd: #0f1420;
--row-hover: #33361f;
--month-hover: rgba(91, 141, 239, .12);
--cur-month: rgba(91, 141, 239, .09);
--tip-bg: #0b0f18;
}
@ -262,16 +264,36 @@ input::placeholder { color: var(--text-muted); }
.checkbox-field input[type=checkbox]:checked::after { left: 16px; }
.checkbox-field input[type=checkbox]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.zoom-field { gap: 4px; }
.zoom-field label { white-space: nowrap; }
.zoom-field .zoom-row { position: relative; width: 120px; padding-bottom: 24px; display: block; }
/* The scale control floats over the top-right of the timeline, just below the
time axis (JS sets `top` = axis bottom + 5px; this is only a fallback). */
/* Scale control floats over the top-right of the timeline (JS sets `top` = axis
bottom + 5px). Collapsed by default: a small plate showing only the value pill.
JS toggles `.expanded` (on hover; collapse is delayed ~1s after mouseleave).
Fixed sizes + overflow:hidden let width/height animate; the two faces cross-fade. */
#chart-wrap #zoom-field {
position: absolute; top: 52px; right: 16px; z-index: 20;
background: rgba(255, 255, 255, .92); border-radius: 8px;
padding: 3px 12px 4px; box-shadow: var(--shadow-md);
width: 65px; height: 35px; overflow: hidden;
background: rgba(255, 255, 255, .95); border-radius: 8px; box-shadow: var(--shadow-md);
transition: width .24s var(--ease-3), height .24s var(--ease-3);
}
#chart-wrap #zoom-field.expanded { width: 145px; height: 80px; }
/* Collapsed face: the accent value pill, centred in the small plate. */
.zoom-collapsed {
position: absolute; inset: 0; margin: auto; width: fit-content; height: fit-content;
display: inline-flex; align-items: center; justify-content: center;
background: var(--accent); color: #fff; font-size: 11px; font-weight: 600;
padding: 3px 9px; border-radius: 6px; white-space: nowrap; cursor: pointer;
font-variant-numeric: tabular-nums;
opacity: 1; transition: opacity .14s ease .06s;
}
#chart-wrap #zoom-field.expanded .zoom-collapsed { opacity: 0; transition-delay: 0s; pointer-events: none; }
/* Expanded face: label + slider + bubble. */
.zoom-full {
position: absolute; top: 4px; left: 0; padding: 3px 12px 4px;
display: flex; flex-direction: column; gap: 4px;
opacity: 0; pointer-events: none; transition: opacity .14s ease;
}
#chart-wrap #zoom-field.expanded .zoom-full { opacity: 1; pointer-events: auto; transition-delay: .08s; }
.zoom-field input[type=range] {
-webkit-appearance: none; appearance: none; width: 100%; height: 6px; margin: 0;
border-radius: 4px; cursor: pointer; outline: none;
@ -298,6 +320,33 @@ input::placeholder { color: var(--text-muted); }
border: 4px solid transparent; border-bottom-color: var(--accent); border-top: 0;
}
/* Column-info header: fills the empty top-left corner (above the row labels).
Toggle which of city / address / code each first-column row shows. */
#chart-wrap #col-info {
position: absolute; top: 0; left: 0; width: var(--label-w); z-index: 21;
display: flex; align-items: center; gap: 6px;
padding: 0 12px; box-sizing: border-box; overflow: hidden;
background: var(--bg); border-bottom: 1px solid var(--border);
container-type: inline-size;
}
.ci-label { flex: 0 0 auto; font-size: 11px; color: var(--text-muted); white-space: nowrap; }
/* Narrow column: drop the "Показывать" label so the three pills still fit. */
@container (max-width: 255px) { #chart-wrap #col-info .ci-label { display: none; } }
.ci-toggle {
flex: 0 0 auto; border: none; cursor: pointer; white-space: nowrap;
font-size: 12px; font-weight: 500; padding: 5px 11px; border-radius: 999px;
background: var(--accent-weak); color: var(--accent);
transition: background var(--transition), color var(--transition);
}
.ci-toggle.is-on { background: var(--accent); color: #fff; }
.ci-toggle:not(.is-on):hover { background: var(--accent-ring); }
/* Row label parts, separated by a 7px dot; address is primary, city/code muted. */
.ci-cell { display: inline-flex; align-items: center; flex-wrap: wrap; }
.ci-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #c1c4ef; margin: 0 9px; flex: 0 0 auto; }
.ci-city { color: var(--text-muted); }
.ci-addr { color: var(--text); }
.ci-code { color: var(--text-muted); font-variant-numeric: tabular-nums; }
/* checkbox dropdown filter */
.dropdown { position: relative; }
.dropdown-btn {
@ -314,7 +363,12 @@ input::placeholder { color: var(--text-muted); }
display: none; position: absolute; top: calc(100% + 4px); left: 0;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-md); padding: 6px 0;
min-width: 220px; max-height: 280px; overflow-y: auto; z-index: 30;
/* Fit the widest option instead of a fixed width, capped at 350px wider
content then scrolls horizontally. The .dp-search fills via fill-available
(not width:100%, whose % included the scrollbar area clipped border +
phantom horizontal scrollbar). */
width: max-content; min-width: 220px; max-width: 350px;
max-height: 280px; overflow: auto; z-index: 30;
}
.dropdown.open .dropdown-panel { display: block; }
.dropdown-panel label {
@ -332,9 +386,18 @@ input::placeholder { color: var(--text-muted); }
}
.dropdown-panel .dp-actions a { font-size: 11px; color: var(--accent); cursor: pointer; text-decoration: none; font-weight: 600; }
.dropdown-panel .dp-actions a:hover { text-decoration: underline; }
/* In-dropdown search that filters the option list — a clean, evenly-bordered box. */
.dropdown-panel .dp-search {
display: block; width: calc(100% - 16px); box-sizing: border-box; margin: 3px 8px 6px;
/* In-dropdown search that filters the option list a clean, evenly-bordered box.
Selector carries `input` so it out-specifies `.hgroup-fields input[type=text]
{ width: 100% }` (0,2,1), which would otherwise force width:100% and clip the
border / spawn a phantom horizontal scrollbar. */
.dropdown-panel input.dp-search {
display: block; box-sizing: border-box; margin: 3px 8px 6px;
/* Fill the available width MINUS the vertical scrollbar (so the border is never
clipped and no phantom horizontal scrollbar appears); calc(100%-16px) is the
fallback for engines without fill-available. */
width: calc(100% - 16px);
width: -moz-available;
width: -webkit-fill-available;
padding: 7px 10px; font-size: 13px; color: var(--text); background: var(--surface);
border: 1px solid var(--border); border-radius: var(--radius-sm); outline: none;
}
@ -710,10 +773,14 @@ html.role-pending .manager-only { display: none !important; }
/* Row hover */
.vis-label.row-hover, .vis-group.row-hover { background-color: var(--row-hover) !important; }
/* Fixed/resizable label column */
.vis-label .vis-inner {
/* #timeline lifts specificity above vis-timeline's own `.vis-labelset .vis-label
.vis-inner { padding: 5px }`, so the right padding actually applies. */
#timeline .vis-label .vis-inner {
display: inline-block;
box-sizing: border-box;
min-width: var(--label-w);
max-width: var(--label-w);
padding-left: 7px; padding-right: 7px; /* keep the address off the column borders */
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle;
}
@ -722,6 +789,7 @@ html.role-pending .manager-only { display: none !important; }
.vis-panel.vis-center, .vis-panel.vis-left, .vis-panel.vis-right,
.vis-panel.vis-top, .vis-panel.vis-bottom { border-color: var(--border); }
.vis-time-axis .vis-text { color: var(--text-muted); }
.vis-time-axis .vis-text.vis-major { color: var(--accent); font-weight: 500; }
.vis-time-axis .vis-grid.vis-minor { border-color: var(--grid-line); }
.vis-time-axis .vis-grid.vis-major { border-color: var(--border-strong); }
.vis-labelset .vis-label,
@ -740,6 +808,7 @@ html.role-pending .manager-only { display: none !important; }
/* Hovered-month column highlight */
.vis-item.vis-background.month-hover-bg { background-color: var(--month-hover); }
.vis-item.vis-background.cur-month-bg { background-color: var(--cur-month); }
/* Rich hover tooltip for timeline bars (structured HTML, built in timeline.ts).
Shares the dark look of the map tooltip (.map-tip) for a consistent feel. */

View File

@ -14,6 +14,10 @@ export interface RenderFlags {
// 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 {
@ -24,6 +28,19 @@ export interface TimelineElements {
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/';
@ -36,7 +53,21 @@ export const zoomSteps: ReadonlyArray<{ days: number; label: string }> = [
];
const MONTH_BG_ID = '__month_hover_bg';
const MIN_LABEL_W = 140;
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"
@ -67,6 +98,9 @@ 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;
@ -79,7 +113,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
const itemsDS = new DataSet<any>();
let lastBoards: Board[] = [];
let lastBookings: Booking[] = [];
let lastFlags: RenderFlags = { withBrand: true, withCompany: false, withCollisions: true, managerView: true };
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 = {
@ -96,6 +130,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
orientation: { axis: 'top' },
tooltip: { followMouse: false, delay: 0 },
locale: 'ru',
format: { minorLabels: axisMinorLabel, majorLabels: axisMajorLabel },
xss: { disabled: true },
};
@ -234,10 +269,14 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
// 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 zf = document.getElementById('zoom-field');
const axis = el.timelineEl.querySelector<HTMLElement>('.vis-panel.vis-top');
if (!zf || !axis) return;
zf.style.top = axis.getBoundingClientRect().bottom - el.chartWrap.getBoundingClientRect().top + 5 + 'px';
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);
@ -377,7 +416,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
const groups = boards.map((b, idx) => ({
id: b.board_id,
content: escapeHtml(b.board_address) + ' (' + escapeHtml(b.board_id) + ')',
content: groupContent(b, flags),
order: idx,
className: idx % 2 === 0 ? 'row-even' : 'row-odd',
}));
@ -450,6 +489,16 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
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 });
@ -461,6 +510,14 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
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();
@ -477,6 +534,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
return {
render,
refresh,
refreshLabels,
applyLayout,
setScale,
setOnBoardClick(fn: (boardId: string) => void): void { onBoardClick = fn; },