Compare commits

..

No commits in common. "main" and "v0.2.2" have entirely different histories.
main ... v0.2.2

14 changed files with 182 additions and 1333 deletions

1
.gitignore vendored
View File

@ -6,5 +6,4 @@ __pycache__/
static/assets/
static/index.html
static/next/
photos/
.env

View File

@ -9,7 +9,6 @@ services:
- .env
volumes:
- ./static:/app/static:ro
- ./photos:/app/photos:ro
networks:
edge:
external: true

View File

@ -3,7 +3,6 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%2326c04c'/%3E%3C/svg%3E" />
<title>Адресная программа</title>
<script>
// Theme is set before first paint (no flash). Dark theme is temporarily
@ -18,20 +17,6 @@
</head>
<body>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=111613184', 'ym');
ym(111613184, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/111613184" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
<div id="load-bar" aria-hidden="true"></div>
<header>
@ -40,17 +25,16 @@
<div class="brand">
<div class="brand-name">Green Media</div>
<div class="brand-sub">Адресная программа</div>
<div id="status" class="stat-tile stat-accent"><span class="st-l">Поверхности</span><span class="st-v"></span></div>
<div id="status"></div>
</div>
</div>
<!-- Group 1b: view mode switch -->
<div class="hgroup hgroup-modes">
<div class="mode-switch">
<div class="mode-ind" aria-hidden="true"></div>
<button type="button" class="mode-btn active" id="mode-timeline"><svg class="mode-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="4" y1="7" x2="16" y2="7"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="17" x2="11" y2="17"/></svg>График</button>
<button type="button" class="mode-btn" id="mode-map"><svg class="mode-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 21s-7-6.3-7-11a7 7 0 0 1 14 0c0 4.7-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/></svg>Карта</button>
<button type="button" class="mode-btn" id="mode-split"><svg class="mode-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="7" height="16" rx="1"/><rect x="14" y="4" width="7" height="16" rx="1"/></svg>Карта и график</button>
<button type="button" class="mode-btn active" id="mode-timeline">График</button>
<button type="button" class="mode-btn" id="mode-map">Карта</button>
<button type="button" class="mode-btn" id="mode-split">Карта и график</button>
</div>
<button type="button" class="theme-toggle" id="theme-toggle" title="Переключить тему" aria-label="Переключить тему"></button>
</div>
@ -97,18 +81,13 @@
<div class="dropdown-panel" id="manager-panel"></div>
</div>
</div>
<div class="field field-dates">
<label>Период</label>
<div class="daterange" id="daterange">
<div class="dr-field" id="dr-field" tabindex="0" role="button" aria-label="Выбрать период">
<svg class="dr-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/></svg>
<span class="dr-text" id="dr-text">Период</span>
<button type="button" class="dr-clear" id="dr-clear" aria-label="Очистить" style="display:none">×</button>
</div>
<div class="dr-cal" id="dr-cal" hidden></div>
<input type="date" id="date-start" class="dr-native" />
<input type="date" id="date-end" class="dr-native" />
</div>
<div class="field">
<label for="date-start">Дата начала (от)</label>
<input type="date" id="date-start" />
</div>
<div class="field">
<label for="date-end">Дата окончания (до)</label>
<input type="date" id="date-end" />
</div>
<div class="field manager-only">
<label>Статус</label>
@ -126,8 +105,14 @@
</div>
</div>
<!-- Group 3: display toggles (the scale control now floats over the timeline) -->
<!-- Group 3: scale + appearance + checkboxes (compact stack) -->
<div class="hgroup hgroup-controls">
<div class="field zoom-field">
<label for="zoom-slider">Масштаб: <span id="zoom-label">12 месяцев</span></label>
<div class="zoom-row">
<input type="range" id="zoom-slider" min="0" max="9" step="1" value="3" />
</div>
</div>
<div class="checks manager-only">
<div class="checks-row">
<div class="field checkbox-field">
@ -164,22 +149,6 @@
<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>
@ -193,10 +162,10 @@
<div id="pane-map">
<div id="map"></div>
<div id="map-legend">
<div class="stat-tile"><span class="st-l"><span class="lg-dot lg-free"></span>свободно</span><span class="st-v st-free" id="lg-free">0</span></div>
<div class="stat-tile"><span class="st-l"><span class="lg-dot lg-busy"></span>занято</span><span class="st-v st-busy" id="lg-busy">0</span></div>
<div class="stat-tile"><span class="st-l"><span class="lg-dot lg-total"></span>всего</span><span class="st-v" id="map-counter">0</span></div>
<button type="button" class="stat-tile st-nc" id="map-nocoords-btn" style="display:none"><span class="st-l"><span class="lg-dot"></span>без координат</span><span class="st-v">0</span></button>
<span class="lg-item"><span class="lg-dot lg-free"></span><span id="lg-free">свободна</span></span>
<span class="lg-item"><span class="lg-dot lg-busy"></span><span id="lg-busy">занята</span></span>
<span class="lg-item" id="map-counter"></span>
<button type="button" class="lg-nocoords" id="map-nocoords-btn" style="display:none"></button>
</div>
<div id="map-nocoords-panel" style="display:none"></div>
<div id="map-empty" class="empty-state" style="display:none">

View File

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

View File

@ -25,7 +25,7 @@ export const DEFAULT_STATUS_COLORS: Record<string, string> = {
'РК. Новая': '#4f46e5',
'РК. Предварительное бронирование': '#4f46e5',
'РК. Макет РИМ согласован': '#4f46e5',
'РК. Размещено': '#26c04c',
'РК. Размещено': '#4f46e5',
'РК. Ожидает монтаж': '#4f46e5',
'РК. Ожидает демонтаж': '#4f46e5',
'РК. Ожидает перемонтаж': '#4f46e5',
@ -98,53 +98,21 @@ export function applyCssVars(a: Appearance): void {
// ---- settings menu UI ----------------------------------------------------
function numberRow(labelText: string, value: number, min: number, max: number, onInput: (v: number) => void): HTMLElement {
// A stepper: /+ buttons around an editable number field (type a value directly).
const row = document.createElement('div');
const row = document.createElement('label');
row.className = 'decor-row';
const span = document.createElement('span');
span.textContent = labelText;
const stepper = document.createElement('div');
stepper.className = 'decor-stepper';
const input = document.createElement('input');
input.type = 'number';
input.className = 'ds-input';
input.min = String(min);
input.max = String(max);
input.value = String(value);
const dec = document.createElement('button');
dec.type = 'button';
dec.className = 'ds-btn';
dec.textContent = '';
dec.setAttribute('aria-label', 'Уменьшить');
const inc = document.createElement('button');
inc.type = 'button';
inc.className = 'ds-btn';
inc.textContent = '+';
inc.setAttribute('aria-label', 'Увеличить');
const clamp = (v: number): number => Math.max(min, Math.min(max, v));
// While typing: apply the clamped value live but leave the field as-is so
// multi-digit input isn't fought (e.g. typing "30" when min is 8).
input.addEventListener('input', () => {
const v = parseInt(input.value, 10);
if (!Number.isNaN(v)) onInput(clamp(v));
if (!Number.isNaN(v)) onInput(Math.max(min, Math.min(max, v)));
});
// On blur/Enter and on the buttons: normalise the field to the clamped value.
const commit = (v: number): void => {
const c = clamp(Number.isNaN(v) ? value : v);
input.value = String(c);
onInput(c);
};
input.addEventListener('change', () => commit(parseInt(input.value, 10)));
dec.addEventListener('click', () => commit((parseInt(input.value, 10) || min) - 1));
inc.addEventListener('click', () => commit((parseInt(input.value, 10) || min) + 1));
stepper.appendChild(dec);
stepper.appendChild(input);
stepper.appendChild(inc);
row.appendChild(span);
row.appendChild(stepper);
row.appendChild(input);
return row;
}

View File

@ -1,215 +0,0 @@
// Custom date-range picker. The two native <input type=date> (#date-start /
// #date-end) stay in the DOM (hidden) as the source of truth — all filter code
// keeps reading/writing them. The calendar selects into a temporary state; the
// filter is applied (and the popup closed) only when the user presses «Применить».
//
// Two endpoint pills «С» / «По» sit above the grid: one is active, and clicking a
// day writes into the active endpoint, then focus auto-advances to the other. Each
// pill has a × to clear just that endpoint, so an OPEN range is possible — «с X»
// (no end) or «по Y» (no start). «Применить» is enabled once at least one endpoint
// is set; «Сбросить» clears both.
const MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
const WEEKDAYS = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'];
function parseISO(iso: string): Date | null {
return iso ? new Date(iso + 'T00:00:00') : null;
}
function toISO(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function fmt(iso: string): string {
const p = iso.split('-');
// Short 2-digit year so the full range fits the compact "Период" field.
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0].slice(2)}` : iso;
}
export interface DateRange {
/** Refresh the field text (+ open calendar) from the hidden inputs. */
sync(): void;
}
export function createDateRange(): DateRange {
const startInput = document.getElementById('date-start') as HTMLInputElement;
const endInput = document.getElementById('date-end') as HTMLInputElement;
const field = document.getElementById('dr-field') as HTMLElement;
const text = document.getElementById('dr-text') as HTMLElement;
const clearBtn = document.getElementById('dr-clear') as HTMLElement;
const cal = document.getElementById('dr-cal') as HTMLElement;
let viewY = new Date().getFullYear();
let viewM = new Date().getMonth();
// In-progress selection (committed to the inputs on «Применить»). Either end may
// stay empty → an open range. `activeEnd` is the endpoint the next day-click fills.
let selStart = '';
let selEnd = '';
let activeEnd: 'start' | 'end' = 'start';
function updateText(): void {
const s = startInput.value;
const e = endInput.value;
if (s && e) text.textContent = `${fmt(s)}${fmt(e)}`;
else if (s) text.textContent = `с ${fmt(s)}`;
else if (e) text.textContent = `по ${fmt(e)}`;
else text.textContent = 'Период';
clearBtn.style.display = s || e ? '' : 'none';
field.classList.toggle('has-value', !!(s || e));
}
function render(): void {
const s = parseISO(selStart);
const e = parseISO(selEnd);
const endPill = (which: 'start' | 'end', label: string, val: string): string =>
`<button type="button" class="dr-end${activeEnd === which ? ' active' : ''}${val ? ' set' : ''}" data-end="${which}">` +
`<span class="dr-end-lbl">${label}</span>` +
`<span class="dr-end-val">${val ? fmt(val) : '—'}</span>` +
(val ? `<span class="dr-end-x" data-clear="${which}" aria-label="Очистить">×</span>` : '') +
`</button>`;
const parts: string[] = [
`<div class="dr-ends">${endPill('start', 'С', selStart)}${endPill('end', 'По', selEnd)}</div>`,
`<div class="dr-cal-h"><button type="button" class="dr-nav" data-nav="-1" aria-label="Предыдущий месяц"></button>` +
`<span class="dr-cal-title">${MONTHS[viewM]} ${viewY}</span>` +
`<button type="button" class="dr-nav" data-nav="1" aria-label="Следующий месяц"></button></div>`,
'<div class="dr-grid">',
];
for (const w of WEEKDAYS) parts.push(`<span class="dr-wd">${w}</span>`);
const lead = (new Date(viewY, viewM, 1).getDay() + 6) % 7; // Monday-first
const days = new Date(viewY, viewM + 1, 0).getDate();
for (let i = 0; i < lead; i++) parts.push('<span class="dr-blank"></span>');
for (let d = 1; d <= days; d++) {
const cur = new Date(viewY, viewM, d);
const iso = toISO(cur);
let cls = 'dr-day';
if (iso === selStart) cls += ' s';
if (iso === selEnd) cls += ' e';
if (s && e && cur > s && cur < e) cls += ' rng';
parts.push(`<button type="button" class="${cls}" data-iso="${iso}"><span class="dr-num">${d}</span></button>`);
}
parts.push('</div>');
const ready = !!(selStart || selEnd); // at least one endpoint → applyable (open range OK)
parts.push(
'<div class="dr-foot">' +
`<button type="button" class="dr-reset" data-act="reset">Сбросить</button>` +
`<button type="button" class="dr-apply" data-act="apply"${ready ? '' : ' disabled'}>Применить</button>` +
'</div>'
);
cal.innerHTML = parts.join('');
}
// Write the completed selection to the hidden inputs → triggers the filter reload.
function applyRange(): void {
startInput.value = selStart;
endInput.value = selEnd;
startInput.dispatchEvent(new Event('change', { bubbles: true }));
endInput.dispatchEvent(new Event('change', { bubbles: true }));
updateText();
}
function pick(iso: string): void {
if (activeEnd === 'start') {
selStart = iso;
// Крест начала за концом — конец больше не валиден, очищаем его.
if (selEnd && selStart > selEnd) selEnd = '';
if (!selEnd) activeEnd = 'end'; // авто-переход к незаданному концу
} else {
selEnd = iso;
if (selStart && selEnd < selStart) selStart = '';
if (!selStart) activeEnd = 'start';
}
render();
}
function open(): void {
selStart = startInput.value;
selEnd = endInput.value;
// Start filling whichever endpoint is empty (start first).
activeEnd = !selStart ? 'start' : !selEnd ? 'end' : 'start';
const base = parseISO(selStart) || parseISO(selEnd) || new Date();
viewY = base.getFullYear();
viewM = base.getMonth();
render();
cal.hidden = false;
}
function close(): void {
cal.hidden = true;
}
field.addEventListener('click', (ev) => {
if ((ev.target as HTMLElement).closest('.dr-clear')) return;
if (cal.hidden) open();
else close();
});
field.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); if (cal.hidden) open(); else close(); }
});
clearBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
selStart = '';
selEnd = '';
startInput.value = '';
endInput.value = '';
startInput.dispatchEvent(new Event('change', { bubbles: true }));
endInput.dispatchEvent(new Event('change', { bubbles: true }));
updateText();
close();
});
cal.addEventListener('click', (ev) => {
// Keep the click inside the calendar so re-rendering a clicked day doesn't
// detach the target and trip the outside-click close below.
ev.stopPropagation();
const target = ev.target as HTMLElement;
const nav = target.closest<HTMLElement>('.dr-nav');
if (nav) {
viewM += Number(nav.dataset.nav);
if (viewM < 0) { viewM = 11; viewY--; } else if (viewM > 11) { viewM = 0; viewY++; }
render();
return;
}
const act = target.closest<HTMLElement>('[data-act]');
if (act) {
if (act.dataset.act === 'apply') {
if (selStart || selEnd) { applyRange(); close(); }
} else if (act.dataset.act === 'reset') {
selStart = '';
selEnd = '';
activeEnd = 'start';
render();
}
return;
}
// Clear one endpoint via its × (→ open range on that side). Checked before the
// pill activate below, since the × lives inside the pill button.
const clr = target.closest<HTMLElement>('[data-clear]');
if (clr) {
const which = clr.dataset.clear as 'start' | 'end';
if (which === 'start') selStart = ''; else selEnd = '';
activeEnd = which; // focus the emptied end for the next pick
render();
return;
}
const endBtn = target.closest<HTMLElement>('[data-end]');
if (endBtn) {
activeEnd = endBtn.dataset.end as 'start' | 'end';
render();
return;
}
const day = target.closest<HTMLElement>('.dr-day');
if (day && day.dataset.iso) pick(day.dataset.iso);
});
document.addEventListener('click', (ev) => {
if (!(ev.target as HTMLElement).closest('#daterange')) close();
});
updateText();
return {
sync(): void {
updateText();
if (!cal.hidden) {
selStart = startInput.value;
selEnd = endInput.value;
activeEnd = !selStart ? 'start' : !selEnd ? 'end' : 'start';
render();
}
},
};
}

View File

@ -42,30 +42,6 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
function renderPanel(): void {
panel.innerHTML = '';
// Show all available options, plus any currently-selected value that is no
// longer among them (so a selection made incompatible by other filters
// stays visible and can still be unchecked).
const extra = Array.from(selected).filter((v) => !allValues.includes(v));
const display = allValues.concat(extra);
const optionEls: { v: string; el: HTMLElement }[] = [];
// Search box — filters the option list in place (only for longer lists).
if (display.length > 7) {
const search = document.createElement('input');
search.type = 'text';
search.className = 'dp-search';
search.placeholder = 'Поиск…';
search.addEventListener('click', (e) => e.stopPropagation());
search.addEventListener('input', () => {
const q = search.value.trim().toLowerCase();
for (const o of optionEls) {
o.el.style.display = !q || formatLabel(o.v).toLowerCase().includes(q) ? '' : 'none';
}
});
panel.appendChild(search);
}
const actions = document.createElement('div');
actions.className = 'dp-actions';
const selAll = document.createElement('a');
@ -88,6 +64,11 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
actions.appendChild(selNone);
panel.appendChild(actions);
// Show all available options, plus any currently-selected value that is no
// longer among them (so a selection made incompatible by other filters
// stays visible and can still be unchecked).
const extra = Array.from(selected).filter((v) => !allValues.includes(v));
const display = allValues.concat(extra);
for (const v of display) {
const label = document.createElement('label');
const cb = document.createElement('input');
@ -102,7 +83,6 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
});
label.appendChild(cb);
label.appendChild(document.createTextNode(formatLabel(v)));
optionEls.push({ v, el: label });
panel.appendChild(label);
}
}
@ -113,14 +93,6 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
if (d !== root) d.classList.remove('open');
});
root.classList.toggle('open');
if (root.classList.contains('open')) {
const s = panel.querySelector<HTMLInputElement>('.dp-search');
if (s) {
s.value = '';
s.dispatchEvent(new Event('input')); // reset any prior filtering
s.focus();
}
}
});
document.addEventListener('click', (e) => {
if (!root.contains(e.target as Node)) root.classList.remove('open');

View File

@ -14,11 +14,11 @@ import 'vis-timeline/styles/vis-timeline-graph2d.min.css';
import { api } from './api';
import type { Board, Booking, Filters } from './types';
import { createDropdown } from './dropdown';
import { createDateRange } from './datepicker';
import { createTimelineView, zoomSteps, type RenderFlags } from './timeline';
import { loadAppearance, applyCssVars, buildAppearanceMenu, statusLabel } from './appearance';
import { loadAppearance, applyCssVars, buildAppearanceMenu, colorForStatus, statusLabel } from './appearance';
import { createMapView } from './map';
import { createViewModes, type ViewMode } from './viewmode';
import { escapeHtml } from './util';
import { initAuth, isManager, isAuthenticated, login, logout } from './auth';
const el = {
@ -35,8 +35,6 @@ 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,
@ -116,7 +114,6 @@ const brandDropdown = createDropdown('brand');
const dimensionDropdown = createDropdown('dimension');
const managerDropdown = createDropdown('manager');
const statusDropdown = createDropdown('status', statusLabel);
const dateRange = createDateRange();
const view = createTimelineView(
{
timelineEl: el.timelineEl,
@ -136,32 +133,17 @@ 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, ...cols };
return { withBrand: false, withCompany: false, withCollisions: false, managerView: false };
}
return {
withBrand: el.showBrand.checked,
withCompany: el.showCompany.checked,
withCollisions: el.showCollisions.checked,
managerView: true,
...cols,
};
}
@ -208,7 +190,7 @@ function positionDecorMenu(): void {
if (chips && chips.style.display !== 'none' && chips.getClientRects().length) {
refBottom = Math.max(refBottom, chips.getBoundingClientRect().bottom);
}
const top = refBottom + 20;
const top = refBottom + 25;
el.decorMenu.style.top = top + 'px';
el.decorMenu.style.maxHeight = Math.max(120, window.innerHeight - top - 30) + 'px';
}
@ -249,32 +231,6 @@ const fmtD = (iso: string): string => {
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso;
};
// ---- toast notifications (transient action feedback) ----
let toastHost: HTMLElement | null = null;
function showToast(message: string): void {
if (!toastHost) {
toastHost = document.createElement('div');
toastHost.className = 'toasts';
document.body.appendChild(toastHost);
}
const t = document.createElement('div');
t.className = 'toast';
const ico = document.createElement('span');
ico.className = 'toast-ico';
ico.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const txt = document.createElement('span');
txt.textContent = message;
t.appendChild(ico);
t.appendChild(txt);
toastHost.appendChild(t);
void t.offsetWidth; // reflow so the enter transition runs (rAF is paused in bg tabs)
t.classList.add('show');
window.setTimeout(() => {
t.classList.remove('show');
window.setTimeout(() => t.remove(), 250);
}, 2500);
}
// ---- shareable filter links: current filters <-> URL query ----
function filtersToUrl(): void {
const f = currentFilters();
@ -333,8 +289,11 @@ function renderChips(): void {
share.addEventListener('click', () => {
navigator.clipboard
?.writeText(location.href)
.then(() => showToast('Ссылка скопирована'))
.catch(() => showToast('Не удалось скопировать'));
.then(() => {
share.textContent = 'Ссылка скопирована';
window.setTimeout(() => { share.textContent = 'Скопировать ссылку'; }, 1600);
})
.catch(() => { /* clipboard unavailable */ });
});
el.filterChips.appendChild(share);
const divider = document.createElement('span');
@ -366,7 +325,7 @@ el.emptyResetTimeline.addEventListener('click', resetFilters);
el.emptyResetMap.addEventListener('click', resetFilters);
async function loadData(fit: boolean): Promise<void> {
el.status.innerHTML = '<span class="st-l">Поверхности</span><span class="st-v">…</span>';
el.status.textContent = 'Загрузка…';
loadStart();
try {
const f = currentFilters();
@ -380,10 +339,9 @@ async function loadData(fit: boolean): Promise<void> {
lastBoards = boards;
lastBookings = bookings;
view.render(boards, bookings, fit, flags());
el.status.innerHTML = `<span class="st-l">Поверхности</span><span class="st-v">${boards.length}</span>`;
el.status.textContent = `Поверхности: ${boards.length}`;
rebuildDecorMenu(); // status list may have changed
renderChips();
dateRange.sync(); // keep the date-range field in step with reset / chip-remove / URL
filtersToUrl();
// Keep the map in sync when it is visible (facets filter it; dates do not).
if (mapVisible()) void refreshMap();
@ -412,7 +370,7 @@ async function refreshMap(): Promise<void> {
if (key === mapLoadedKey) return;
mapLoadedKey = key;
const surfaces = await api.map(currentFilters());
mapView.render(surfaces, isManager());
mapView.render(surfaces);
} catch (e) {
console.error('map load failed', e);
} finally {
@ -420,25 +378,9 @@ 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
// 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
}
if (mode !== 'timeline') await refreshMap(); // map visible (map or split)
}
const viewModes = createViewModes(
@ -480,64 +422,65 @@ el.search.addEventListener('input', scheduleReload);
el.dateStart.addEventListener('change', scheduleReload);
el.dateEnd.addEventListener('change', scheduleReload);
// Fill the track up to the thumb and ride the value bubble above the thumb.
function updateZoomUi(): void {
const max = parseInt(el.zoomSlider.max, 10) || 1;
const frac = max ? parseInt(el.zoomSlider.value, 10) / max : 0;
el.zoomSlider.style.setProperty('--zoom-fill', frac * 100 + '%');
const thumb = 16;
const w = el.zoomSlider.offsetWidth || 120;
el.zoomLabel.style.left = thumb / 2 + frac * (w - thumb) + 'px';
}
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]);
// ---- board detail panel (opened from a timeline row label) ----
const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/';
const boardPanel = document.getElementById('board-panel') as HTMLElement;
const boardBackdrop = document.getElementById('board-backdrop') as HTMLElement;
const boardPanelTitle = document.getElementById('board-panel-title') as HTMLElement;
const boardPanelBody = document.getElementById('board-panel-body') as HTMLElement;
function closeBoardPanel(): void {
boardPanel.hidden = true;
boardBackdrop.hidden = true;
}
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());
});
function openBoardPanel(boardId: string): void {
// Detail panel exposes client/brand/manager + Planfix deep-links — managers only.
if (!isManager()) return;
const board = lastBoards.find((b) => b.board_id === boardId);
const bookings = lastBookings
.filter((b) => b.board_id === boardId)
.sort((a, b) => a.start_date.localeCompare(b.start_date));
boardPanelTitle.textContent = boardId;
const parts: string[] = [];
if (board) {
const line1 = [board.board_city, board.board_dimension].filter(Boolean).map(escapeHtml).join(' · ');
if (line1) parts.push(`<div class="bp-meta">${line1}</div>`);
if (board.board_address) parts.push(`<div class="bp-meta">${escapeHtml(board.board_address)}</div>`);
}
parts.push(`<div class="bp-label">Брони (${bookings.length})</div>`);
if (!bookings.length) {
parts.push('<div class="bp-empty">Броней нет</div>');
} else {
for (const b of bookings) {
const title = escapeHtml(b.brand || b.company_name || 'Без названия');
const sub = b.brand && b.company_name ? `<div class="bp-b-sub">${escapeHtml(b.company_name)}</div>` : '';
const color = colorForStatus(appearance, b.task_status);
const mgr = (b.manager || []).filter(Boolean).join(', ');
parts.push(
'<div class="bp-booking">' +
`<div class="bp-b-head"><span class="bp-b-title">${title}</span>` +
`<a class="bp-b-link" href="${PLANFIX_TASK_URL}${encodeURIComponent(b.task_id)}" target="_blank" rel="noopener">№${escapeHtml(b.task_id)} ↗</a></div>` +
sub +
`<div class="bp-b-dates">${fmtD(b.start_date)} ${fmtD(b.end_date)}</div>` +
`<div class="bp-b-row"><span class="bp-dot" style="background:${color}"></span>${escapeHtml(b.task_status || '')}</div>` +
(mgr ? `<div class="bp-b-row">👤 ${escapeHtml(mgr)}</div>` : '') +
'</div>',
);
}
}
boardPanelBody.innerHTML = parts.join('');
boardPanel.hidden = false;
boardBackdrop.hidden = false;
}
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.
function goToSurfaceOnMap(boardId: string): void {
if (viewModes.getMode() === 'timeline') viewModes.setMode('split');
mapView.focusBoard(boardId);
}
view.setOnBoardClick(goToSurfaceOnMap);
(document.getElementById('board-panel-close') as HTMLElement).addEventListener('click', closeBoardPanel);
boardBackdrop.addEventListener('click', closeBoardPanel);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeBoardPanel(); });
view.setOnBoardClick(openBoardPanel);
async function init(): Promise<void> {
cityDropdown.setValues([], 'Все города');
@ -556,12 +499,10 @@ async function init(): Promise<void> {
rebuildDecorMenu();
view.applyLayout();
await loadData(true);
const defaultZoomIdx = 1; // 12М
const defaultZoomIdx = 3;
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).
void onModeChange(viewModes.getMode());
}

View File

@ -59,15 +59,9 @@ export interface MapElements {
}
export interface MapView {
render(surfaces: MapSurface[], managerView: boolean): void;
render(surfaces: MapSurface[]): void;
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;
}
// "2026-06-01" -> "01.06.2026"
@ -76,38 +70,10 @@ function fmtD(iso: string): string {
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso;
}
// Photo gallery block for the balloon: main image + thumbnail strip (up to
// MAX_THUMBS, then a "+N" chip). Clicks are handled by delegation (setupBalloonPhotos):
// a thumb swaps the main image, the main image / "+N" opens the lightbox.
const MAX_THUMBS = 4;
function photoGallery(s: MapSurface): string {
const ph = s.photos || [];
if (!ph.length) return '';
const data = escapeHtml(ph.join('|'));
const n = ph.length;
let h = `<div class="bl-photos" data-photos="${data}">`;
h += `<div class="bl-photo" data-idx="0" data-lb="main">`;
h += `<img src="${escapeHtml(ph[0]!)}" loading="lazy" alt="" />`;
if (n > 1) h += `<span class="bl-count">1 / ${n}</span>`;
h += `<span class="bl-exp" aria-hidden="true">⛶</span></div>`;
if (n > 1) {
h += '<div class="bl-thumbs">';
const shown = Math.min(n, MAX_THUMBS);
for (let i = 0; i < shown; i++) {
h += `<button type="button" class="bl-thumb${i === 0 ? ' bl-thumb-act' : ''}" data-th="${i}"><img src="${escapeHtml(ph[i]!)}" loading="lazy" alt="" /></button>`;
}
if (n > MAX_THUMBS) h += `<button type="button" class="bl-more" data-lb="more">+${n - MAX_THUMBS}</button>`;
h += '</div>';
}
return h + '</div>';
}
// Balloon (click) — structured card matching the timeline tooltip style.
function balloonBody(s: MapSurface): string {
const busyColor = s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE;
const rows: string[] = ['<div class="bl">'];
const gallery = photoGallery(s);
if (gallery) rows.push(gallery);
const meta = [s.dimension, s.board_type].filter(Boolean).map(escapeHtml).join(' · ');
if (meta) rows.push(`<div class="bl-sub">${meta}</div>`);
rows.push(`<div class="bl-sub">${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}</div>`);
@ -128,209 +94,6 @@ function balloonBody(s: MapSurface): string {
return rows.join('');
}
// ---- photo lightbox (fullscreen overlay above the map) --------------------
interface Lightbox { overlay: HTMLElement; img: HTMLImageElement; cnt: HTMLElement; prev: HTMLElement; next: HTMLElement; photos: string[]; idx: number; }
let lb: Lightbox | null = null;
function lbShow(i: number): void {
if (!lb || !lb.photos.length) return;
const n = lb.photos.length;
lb.idx = (i % n + n) % n;
lb.img.src = lb.photos[lb.idx]!;
lb.cnt.textContent = n > 1 ? `${lb.idx + 1} / ${n}` : '';
lb.prev.style.display = lb.next.style.display = n > 1 ? '' : 'none';
}
function ensureLightbox(): Lightbox {
if (lb) return lb;
const overlay = document.createElement('div');
overlay.className = 'lb-overlay';
overlay.innerHTML =
'<button type="button" class="lb-close" aria-label="Закрыть">✕</button>' +
'<button type="button" class="lb-prev" aria-label="Предыдущее"></button>' +
'<img class="lb-img" alt="" />' +
'<button type="button" class="lb-next" aria-label="Следующее"></button>' +
'<div class="lb-count"></div>';
document.body.appendChild(overlay);
lb = {
overlay,
img: overlay.querySelector('.lb-img') as HTMLImageElement,
cnt: overlay.querySelector('.lb-count') as HTMLElement,
prev: overlay.querySelector('.lb-prev') as HTMLElement,
next: overlay.querySelector('.lb-next') as HTMLElement,
photos: [],
idx: 0,
};
const close = (): void => overlay.classList.remove('open');
overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target === lb!.img) close(); });
overlay.querySelector('.lb-close')!.addEventListener('click', close);
lb.prev.addEventListener('click', (e) => { e.stopPropagation(); lbShow(lb!.idx - 1); });
lb.next.addEventListener('click', (e) => { e.stopPropagation(); lbShow(lb!.idx + 1); });
document.addEventListener('keydown', (e) => {
if (!overlay.classList.contains('open')) return;
if (e.key === 'Escape') close();
else if (e.key === 'ArrowLeft') lbShow(lb!.idx - 1);
else if (e.key === 'ArrowRight') lbShow(lb!.idx + 1);
});
return lb;
}
function openLightbox(photos: string[], idx: number): void {
const l = ensureLightbox();
l.photos = photos;
l.overlay.classList.add('open');
lbShow(idx);
}
// Delegated handling of balloon photo clicks (attached once). Thumbnails swap
// the main image; the main image and the "+N" chip open the lightbox.
let balloonPhotosBound = false;
function setupBalloonPhotos(): void {
if (balloonPhotosBound) return;
balloonPhotosBound = true;
document.addEventListener('click', (e) => {
const t = e.target as HTMLElement;
const wrap = t.closest('.bl-photos') as HTMLElement | null;
if (!wrap) return;
const photos = (wrap.dataset.photos || '').split('|').filter(Boolean);
if (!photos.length) return;
const main = wrap.querySelector('.bl-photo') as HTMLElement;
const thumb = t.closest('.bl-thumb') as HTMLElement | null;
if (thumb) {
const i = Number(thumb.dataset.th || 0);
(main.querySelector('img') as HTMLImageElement).src = photos[i]!;
main.dataset.idx = String(i);
const cnt = main.querySelector('.bl-count');
if (cnt) cnt.textContent = `${i + 1} / ${photos.length}`;
wrap.querySelectorAll('.bl-thumb').forEach((el) => el.classList.toggle('bl-thumb-act', el === thumb));
return;
}
if (t.closest('.bl-photo')) { openLightbox(photos, Number(main.dataset.idx || 0)); return; }
if (t.closest('.bl-more')) { openLightbox(photos, MAX_THUMBS); }
});
}
// ---- custom surface panel (replaces the Yandex balloon) -------------------
// A floating card: fits its content (no forced scroll), draggable by the header,
// and resizable from the bottom-right corner (CSS resize).
interface Panel { el: HTMLElement; title: HTMLElement; tabs: HTMLElement; body: HTMLElement; surfaces: MapSurface[]; }
interface PanelPlace { x: number; y: number; bounds: DOMRect; }
let mpanel: Panel | null = null;
// Timestamp of the last panel open. Yandex fires the map's own 'click' for the
// very same click that opened a cluster panel, so the map-click close handler
// ignores clicks landing within a short window of an open.
let panelOpenedAt = 0;
// Open the panel next to the clicked cluster, offset toward the free side: which
// half of the map the cluster sits in decides the direction (e.g. bottom-left
// cluster -> panel opens up and to the right), then clamp inside the map.
function positionPanel(el: HTMLElement, place: PanelPlace): void {
const gap = 14;
const m = 8;
const b = place.bounds;
// Vertical space actually available inside the browser window (bounded by the
// map area, which starts below the header). Cap the panel to it so it can never
// grow past the window edge — it scrolls inside instead.
const availTop = Math.max(b.top, m);
const availBottom = Math.min(b.bottom, window.innerHeight - m);
el.style.maxHeight = Math.max(160, availBottom - availTop) + 'px';
const pw = el.offsetWidth;
const ph = el.offsetHeight;
const midX = (b.left + b.right) / 2;
const midY = (b.top + b.bottom) / 2;
let left = place.x < midX ? place.x + gap : place.x - gap - pw;
let top = place.y < midY ? place.y + gap : place.y - gap - ph;
left = Math.max(b.left + m, Math.min(left, b.right - pw - m));
top = Math.max(availTop, Math.min(top, availBottom - ph));
el.style.left = left + 'px';
el.style.top = top + 'px';
}
function renderPanel(idx: number): void {
if (!mpanel) return;
const s = mpanel.surfaces[idx];
if (!s) return;
mpanel.title.textContent = s.board_id;
if (mpanel.surfaces.length > 1) {
mpanel.tabs.style.display = '';
mpanel.tabs.innerHTML = mpanel.surfaces
.map((x, i) => `<button type="button" class="mpanel-tab${i === idx ? ' mpanel-tab-act' : ''}" data-i="${i}">${escapeHtml(x.board_id)}</button>`)
.join('');
} else {
mpanel.tabs.style.display = 'none';
mpanel.tabs.innerHTML = '';
}
mpanel.body.innerHTML = balloonBody(s);
}
function ensureMapPanel(): Panel {
if (mpanel) return mpanel;
const el = document.createElement('div');
el.className = 'mpanel';
el.style.display = 'none';
el.innerHTML =
'<div class="mpanel-head"><span class="mpanel-title"></span><button type="button" class="mpanel-close" aria-label="Закрыть">✕</button></div>' +
'<div class="mpanel-tabs"></div><div class="mpanel-body"></div>';
document.body.appendChild(el);
mpanel = {
el,
title: el.querySelector('.mpanel-title') as HTMLElement,
tabs: el.querySelector('.mpanel-tabs') as HTMLElement,
body: el.querySelector('.mpanel-body') as HTMLElement,
surfaces: [],
};
el.querySelector('.mpanel-close')!.addEventListener('click', closeMapPanel);
mpanel.tabs.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest('.mpanel-tab') as HTMLElement | null;
if (t) renderPanel(Number(t.dataset.i || 0));
});
// Drag by the header.
const head = el.querySelector('.mpanel-head') as HTMLElement;
head.addEventListener('mousedown', (e) => {
if ((e.target as HTMLElement).closest('.mpanel-close')) return;
const r = el.getBoundingClientRect();
const ox = e.clientX - r.left;
const oy = e.clientY - r.top;
const move = (ev: MouseEvent): void => {
el.style.left = Math.max(0, ev.clientX - ox) + 'px';
el.style.top = Math.max(0, ev.clientY - oy) + 'px';
};
const up = (): void => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
e.preventDefault();
});
return mpanel;
}
function showMapPanel(surfaces: MapSurface[], idx: number, place: PanelPlace): void {
const p = ensureMapPanel();
p.surfaces = surfaces;
// Every click is a fresh panel: reset size, widen when there are many surfaces
// (tabs flow into more columns → shorter panel), then reposition next to the
// just-clicked cluster. A new click never reuses the previous position.
p.el.style.height = '';
p.el.style.maxHeight = '';
const n = surfaces.length;
const cols = n <= 8 ? 2 : n <= 24 ? 3 : 4;
p.el.style.width = cols > 2 ? Math.min(320 + (cols - 2) * 150, Math.round(window.innerWidth * 0.92)) + 'px' : '';
p.el.style.display = '';
renderPanel(idx);
positionPanel(p.el, place); // measures the rendered size, then offsets to the free side
// Restart the pop-in animation so each click reads as a new panel appearing.
p.el.style.animation = 'none';
void p.el.offsetHeight;
p.el.style.animation = '';
panelOpenedAt = Date.now();
}
function closeMapPanel(): void {
if (mpanel) mpanel.el.style.display = 'none';
}
// Hover tooltip for a single marker: code, size, address (+ brand/company if booked).
function singleTip(s: MapSurface): string {
const parts: string[] = [];
@ -371,12 +134,6 @@ function clusterTip(list: MapSurface[]): string {
}
export function createMapView(els: MapElements, apiKey: string): MapView {
setupBalloonPhotos();
// A "place" for the panel: the click point (cluster/marker screen position) and
// the current map bounds, so the panel can open next to it toward the free side.
function clusterPlace(x: number, y: number): PanelPlace {
return { x, y, bounds: els.container.getBoundingClientRect() };
}
let ymaps: any = null;
let map: any = null;
let objectManager: any = null;
@ -446,26 +203,8 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
tip.style.display = 'none';
}
// Whether the current viewer is a manager. The "no coordinates" badge is a
// manager-only tool, so it stays hidden for anonymous visitors.
let managerView = false;
// A pending "fly to this surface" request (e.g. from a timeline label click)
// that is applied once the map has rendered the surfaces.
let pendingFocus: string | null = null;
function applyFocus(): void {
if (!map || !pendingFocus || drawn.length === 0) return; // wait for a render
const bid = pendingFocus;
pendingFocus = null;
const s = drawn.find((x) => x.board_id === bid);
if (s && s.lat != null && s.lon != null) {
map.setCenter([s.lat, s.lon], 16, { duration: 400, checkZoomRange: true });
}
}
function draw(surfaces: MapSurface[]): void {
if (!objectManager) return;
closeMapPanel();
const withCoords = surfaces.filter((s) => s.lat != null && s.lon != null);
const noCoordsList = surfaces.filter((s) => s.lat == null || s.lon == null);
drawn = withCoords;
@ -475,60 +214,51 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
type: 'Feature',
id: i,
geometry: { type: 'Point', coordinates: [s.lat as number, s.lon as number] },
// Clicks open our own resizable, content-fitting panel — not a Yandex balloon.
properties: { occupied: s.occupied_now },
properties: {
balloonContentHeader: escapeHtml(s.board_id),
balloonContentBody: balloonBody(s),
occupied: s.occupied_now,
},
// Same colours as the donut cluster (islands#circleDotIcon is colourable).
options: { preset: 'islands#circleDotIcon', iconColor: s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE, hasBalloon: false },
options: { preset: 'islands#circleDotIcon', iconColor: s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE },
}));
hideTip();
objectManager.removeAll();
objectManager.add({ type: 'FeatureCollection', features });
// Live legend counts (react to the current filter) — values only; the labels
// are static in the tile markup.
if (legendFree) legendFree.textContent = String(free);
if (legendBusy) legendBusy.textContent = String(busy);
// Live legend counts (react to the current filter).
if (legendFree) legendFree.textContent = `свободно: ${free}`;
if (legendBusy) legendBusy.textContent = `занято: ${busy}`;
// Total counts the whole inventory, including surfaces without coordinates
// (which can't be drawn but are still surfaces — listed under "без координат").
els.counter.textContent = String(surfaces.length);
els.counter.textContent = `всего: ${surfaces.length}`;
// Actionable "no coordinates" tile + list — manager-only.
// Actionable "no coordinates" badge + list.
if (noCoordsBtn) {
noCoordsBtn.style.display = managerView && noCoordsList.length ? '' : 'none';
const ncVal = noCoordsBtn.querySelector<HTMLElement>('.st-v');
if (ncVal) ncVal.textContent = String(noCoordsList.length);
noCoordsBtn.style.display = noCoordsList.length ? '' : 'none';
noCoordsBtn.textContent = `без координат: ${noCoordsList.length}`;
}
if (noCoordsPanel) {
noCoordsPanel.innerHTML = renderNoCoords(noCoordsList);
if (!managerView || !noCoordsList.length) noCoordsPanel.style.display = 'none';
if (!noCoordsList.length) noCoordsPanel.style.display = 'none';
}
els.empty.style.display = withCoords.length ? 'none' : 'flex';
if (features.length) {
if (pendingFocus) {
// A pending "fly to this surface" (e.g. timeline label click) supersedes
// the default fit-to-all-points view.
applyFocus();
} else {
const points = features.map((f) => f.geometry.coordinates);
const bounds = ymaps.util.bounds.fromPoints(points);
const done = map.setBounds(bounds, { checkZoomRange: true, zoomMargin: 40 });
// Search-to-locate: when the filter narrows to a single surface, fly in
// and open its card so the user lands right on it.
if (withCoords.length === 1 && done && typeof done.then === 'function') {
done.then(() => {
const b = els.container.getBoundingClientRect();
showMapPanel([withCoords[0]!], 0, { x: (b.left + b.right) / 2, y: (b.top + b.bottom) / 2, bounds: b });
});
}
const points = features.map((f) => f.geometry.coordinates);
const bounds = ymaps.util.bounds.fromPoints(points);
const done = map.setBounds(bounds, { checkZoomRange: true, zoomMargin: 40 });
// Search-to-locate: when the filter narrows to a single surface, fly in
// and open its balloon so the user lands right on the card.
if (features.length === 1 && done && typeof done.then === 'function') {
done.then(() => objectManager.objects.balloon.open(features[0].id));
}
}
}
return {
render(surfaces: MapSurface[], mv: boolean): void {
managerView = mv;
render(surfaces: MapSurface[]): void {
if (!objectManager) {
pending = surfaces;
return;
@ -574,13 +304,8 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
objectManager = new ymaps.ObjectManager({
clusterize: true,
gridSize: 112,
// Even a lone surface becomes a cluster of 1 (donut showing "1"), so all
// map graphics share the donut style and every click flows through the
// one cluster handler -> our panel.
minClusterSize: 1,
// No Yandex balloons — clicks open our own panel. Clusters must not zoom.
// Click opens the balloon at any zoom (instead of zooming into the cluster).
clusterDisableClickZoom: true,
clusterHasBalloon: false,
clusterIconLayout: DonutClusterLayout,
// Hit area for hover/click — a circle centred on the geo point matching
// the 60px donut (radius 30). Without a shape the icon is inert.
@ -598,30 +323,14 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
const cluster = objectManager.clusters.getById(e.get('objectId'));
const objs = cluster ? cluster.properties.geoObjects : [];
const list = objs.map((o: any) => drawn[o.id as number]).filter(Boolean);
// A cluster of one keeps the rich single-surface tooltip.
if (list.length === 1) showTip(singleTip(list[0]!));
else if (list.length) showTip(clusterTip(list));
if (list.length) showTip(clusterTip(list));
});
objectManager.clusters.events.add(['mouseleave'], hideTip);
// Click opens our own panel (and hides the hover tip so it doesn't stick).
objectManager.objects.events.add(['click'], (e: any) => {
hideTip();
const s = drawn[e.get('objectId') as number];
if (s) showMapPanel([s], 0, clusterPlace(mx, my));
});
objectManager.clusters.events.add(['click'], (e: any) => {
hideTip();
const cluster = objectManager.clusters.getById(e.get('objectId'));
const objs = cluster ? cluster.properties.geoObjects : [];
const list = objs.map((o: any) => drawn[o.id as number]).filter(Boolean);
if (list.length) showMapPanel(list, 0, clusterPlace(mx, my));
});
// Clicking the empty map (anywhere but a cluster) closes an open panel.
// Yandex also fires this map 'click' for the same click that opened a
// cluster panel, so skip closes within a short window of an open.
map.events.add('click', () => {
if (Date.now() - panelOpenedAt > 250) closeMapPanel();
});
// Hide the hover tooltip when a balloon opens — otherwise mouseleave may
// not fire (the balloon covers the marker) and the tip stays stuck,
// following the cursor across the whole map.
objectManager.objects.events.add(['click'], hideTip);
objectManager.clusters.events.add(['click'], hideTip);
if (pending) {
draw(pending);
@ -633,26 +342,5 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
invalidateSize(): void {
if (map) map.container.fitToViewport();
},
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,7 +35,6 @@
--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);
@ -61,7 +60,6 @@
--row-odd: #0f1420;
--row-hover: #33361f;
--month-hover: rgba(91, 141, 239, .12);
--cur-month: rgba(91, 141, 239, .09);
--tip-bg: #0b0f18;
}
@ -109,12 +107,12 @@ header {
.hgroup-fields {
flex-shrink: 4; min-width: 0;
display: grid;
grid-template-columns: repeat(4, 195px);
grid-template-columns: repeat(4, minmax(130px, 1fr));
gap: 12px 14px;
align-items: start;
align-content: start;
}
/* Compact grid: equal 195px columns; fields flow left-to-right and wrap. */
/* Compact grid: 4 dropdowns on row 1; the two dates + search fall to row 2. */
.hgroup-fields .field { min-width: 0; }
.hgroup-fields .dropdown,
.hgroup-fields .dropdown-btn,
@ -123,61 +121,6 @@ header {
@media (max-width: 760px) { .hgroup-fields { grid-template-columns: repeat(2, minmax(120px, 1fr)); } }
/* Fields group: top-align so the date input sits UNDER Город/Бренд. */
.hgroup-fields { align-items: flex-start; }
/* ---- date-range field + calendar popup ---- */
.field-dates .dr-field { max-width: 195px; } /* "Период" matches the other fields (short 2-digit-year date fits) */
.dr-native { display: none; }
.daterange { position: relative; }
.dr-field {
display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box;
height: 36px; padding: 0 10px; cursor: pointer; font-size: 13px; color: var(--text-muted);
border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg);
transition: border-color var(--transition);
}
.dr-field:hover { border-color: var(--border-strong); }
.dr-field.has-value { color: var(--text); }
.dr-ico { width: 16px; height: 16px; flex: 0 0 auto; color: var(--accent); }
.dr-text { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
.dr-clear { border: none; background: none; color: var(--text-muted); cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px; flex: 0 0 auto; }
.dr-clear:hover { color: var(--text); }
.dr-cal {
position: absolute; top: calc(100% + 4px); left: 0; z-index: 40; width: 252px;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-md); padding: 10px; font-variant-numeric: tabular-nums;
}
.dr-cal[hidden] { display: none; }
/* Endpoint pills «С» / «По» above the calendar styled like the active-filter
chips (accent-weak fill, accent text, pill shape); the active one (next click
target) gets an accent border; × clears just that endpoint. */
.dr-ends { display: flex; gap: 8px; margin-bottom: 10px; }
.dr-end { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 5px; padding: 4px 5px 4px 10px; border: 1px solid transparent; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: 12px; font-weight: 500; cursor: pointer; text-align: left; }
.dr-end:hover { border-color: var(--accent-ring); }
.dr-end.active { border-color: var(--accent); }
.dr-end-lbl { flex: 0 0 auto; opacity: .65; }
.dr-end-val { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.dr-end:not(.set) .dr-end-val { opacity: .55; }
.dr-end-x { flex: 0 0 auto; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; color: inherit; font-size: 15px; line-height: 1; }
.dr-end-x:hover { background: rgba(79, 70, 229, .18); }
.dr-cal-h { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.dr-cal-title { font-size: 13px; font-weight: 600; color: var(--text); }
.dr-nav { border: none; background: none; color: var(--text-muted); cursor: pointer; font-size: 18px; line-height: 1; width: 26px; height: 26px; border-radius: var(--radius-sm); }
.dr-nav:hover { background: var(--surface); color: var(--text); }
.dr-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
.dr-wd { text-align: center; font-size: 11px; color: var(--text-muted); padding-bottom: 4px; }
.dr-day { position: relative; border: none; background: none; padding: 2px 0; cursor: pointer; display: flex; align-items: center; justify-content: center; height: 30px; }
.dr-day::before { content: ""; position: absolute; inset: 0; z-index: 0; }
/* In-range days: a separate light circle, same 27px diameter as the endpoints. */
.dr-day.rng::before { inset: auto; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 27px; height: 27px; border-radius: 50%; background: #eef0fe; }
.dr-num { position: relative; z-index: 1; display: inline-flex; align-items: center; justify-content: center; width: 27px; height: 27px; border-radius: 50%; font-size: 12px; color: var(--text); }
.dr-day:hover .dr-num { background: var(--surface-2); }
.dr-day.s .dr-num, .dr-day.e .dr-num { background: var(--accent); color: #fff; font-weight: 600; }
.dr-day.s:hover .dr-num, .dr-day.e:hover .dr-num { background: var(--accent); }
.dr-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
.dr-reset { border: none; background: none; color: var(--text-muted); cursor: pointer; font-size: 13px; padding: 7px 10px; border-radius: var(--radius-sm); }
.dr-reset:hover { background: var(--surface); color: var(--text); }
.dr-apply { border: none; background: var(--accent); color: #fff; cursor: pointer; font-size: 13px; font-weight: 600; padding: 7px 16px; border-radius: var(--radius-sm); transition: background var(--transition); }
.dr-apply:hover { background: var(--accent-strong, #4338ca); }
.dr-apply:disabled { opacity: .45; cursor: default; }
.fcol { display: flex; flex-direction: column; gap: 8px; }
input[type=date],
@ -218,10 +161,7 @@ input::placeholder { color: var(--text-muted); }
top, the three checkboxes stacked below. */
.hgroup-controls { flex-direction: column; align-items: flex-start; gap: 8px; }
.zoom-row { display: flex; align-items: center; gap: 8px; }
/* Toggles: aligned to the filter *controls* (not the labels) Бренд + Компания
share the top row, then Коллизии and Все поверхности, evenly distributed
between the top of the first control box and the bottom of Поиск. */
.checks { display: flex; flex-direction: column; align-items: flex-start; justify-content: space-between; flex: 1; align-self: stretch; padding: 19px 0 0; }
.checks { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
.checks .checkbox-field { align-self: flex-start; }
.checks-row { display: flex; flex-direction: row; align-items: center; gap: 18px; }
@ -239,7 +179,12 @@ input::placeholder { color: var(--text-muted); }
}
/* Counters — compact inset inside the brand block. */
.brand #status { margin-top: auto; }
.brand #status {
margin-top: auto; padding: 8px 12px;
background: var(--surface-2); border: none;
border-radius: var(--radius-sm); font-size: 12px; color: var(--text-muted);
line-height: 1.5; white-space: nowrap; font-variant-numeric: tabular-nums;
}
/* Scale group: stack the "Оформление" button under the zoom slider. */
.checkbox-field { flex-direction: row; align-items: center; gap: 6px; align-self: center; }
@ -248,104 +193,11 @@ input::placeholder { color: var(--text-muted); }
font-size: 13px; color: var(--text); text-transform: none; letter-spacing: normal;
font-weight: 400; cursor: pointer; white-space: nowrap;
}
/* Toggle switch: the checkbox keeps its semantics/JS, only the look changes.
Scoped to .checkbox-field so the dropdown option checkboxes stay normal. */
.checkbox-field input[type=checkbox] {
appearance: none; -webkit-appearance: none; margin: 0; flex: 0 0 auto;
width: 34px; height: 20px; border-radius: 20px; background: #cbd0d8;
position: relative; cursor: pointer; transition: background var(--transition);
}
.checkbox-field input[type=checkbox]::after {
content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px;
border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .25);
transition: left var(--transition);
}
.checkbox-field input[type=checkbox]:checked { background: var(--accent); }
.checkbox-field input[type=checkbox]:checked::after { left: 16px; }
.checkbox-field input[type=checkbox]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.checkbox-field input[type=checkbox] { cursor: pointer; accent-color: var(--accent); }
.zoom-field { gap: 4px; }
.zoom-field input[type=range] { width: 100px; cursor: pointer; accent-color: var(--accent); }
.zoom-field label { white-space: nowrap; }
.zoom-field .zoom-row { position: relative; width: 120px; padding-bottom: 24px; display: block; }
/* 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;
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;
background: linear-gradient(to right, var(--accent) var(--zoom-fill, 0%), var(--border-strong) var(--zoom-fill, 0%));
}
.zoom-field input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
background: #fff; border: 2px solid var(--accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .3); cursor: pointer;
}
.zoom-field input[type=range]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%; background: #fff;
border: 2px solid var(--accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .3); cursor: pointer;
}
.zoom-field input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
/* Value bubble that rides below the slider thumb, pointing up (JS sets `left`). */
.zoom-bubble {
position: absolute; bottom: 0; top: auto; left: 0; transform: translateX(-50%);
background: var(--accent); color: #fff; font-size: 11px; font-weight: 600;
padding: 2px 8px; border-radius: 6px; white-space: nowrap; pointer-events: none;
font-variant-numeric: tabular-nums;
}
.zoom-bubble::after {
content: ""; position: absolute; top: -4px; bottom: auto; left: 50%; transform: translateX(-50%);
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; }
@ -363,12 +215,7 @@ 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;
/* 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;
min-width: 220px; max-height: 280px; overflow-y: auto; z-index: 30;
}
.dropdown.open .dropdown-panel { display: block; }
.dropdown-panel label {
@ -386,23 +233,6 @@ 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.
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;
}
.dropdown-panel .dp-search::placeholder { color: var(--text-muted); }
.dropdown-panel .dp-search:focus { border-color: var(--border-strong); background: var(--bg); }
/* Оформление button + context menu */
/* Pinned to the header's bottom-right corner. */
@ -426,9 +256,9 @@ input::placeholder { color: var(--text-muted); }
}
.decor-btn:hover::after { opacity: 1; }
.decor-menu {
/* Fixed to the viewport: JS sets `top` (20px below the header/chips) and
`max-height` (ending 30px above the window bottom) on open. */
display: none; position: fixed; right: 58px;
/* Fixed to the viewport: JS sets `top` (25px below the header) and `max-height`
(ending 30px above the window bottom) on open, so it never overflows. */
display: none; position: fixed; right: 28px;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-md); padding: var(--size-3); z-index: 50;
width: max-content; max-width: min(420px, 92vw); overflow-y: auto;
@ -449,24 +279,6 @@ input::placeholder { color: var(--text-muted); }
width: 84px; padding: 5px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm);
font-size: 13px; background: var(--bg); color: var(--text);
}
/* Stepper: /+ buttons around an editable number field. */
.decor-stepper { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
.decor-stepper .ds-btn {
display: flex; align-items: center; justify-content: center; padding: 0;
width: 20px; height: 28px; border: none; background: var(--surface);
color: var(--accent); font-size: 15px; line-height: 1; cursor: pointer;
transition: background var(--transition);
}
.decor-stepper .ds-btn:hover { background: var(--surface-2); }
.decor-stepper input.ds-input {
width: 38px; height: 28px; text-align: center; padding: 0 2px;
border: none; border-left: 1px solid var(--border); border-right: 1px solid var(--border);
border-radius: 0; font-size: 13px; background: var(--bg); color: var(--text);
font-variant-numeric: tabular-nums; -moz-appearance: textfield;
}
.decor-stepper .ds-input::-webkit-outer-spin-button,
.decor-stepper .ds-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.decor-stepper .ds-input:focus { outline: none; }
.decor-row input[type=color] {
width: 42px; height: 28px; padding: 0; border: 1px solid var(--border);
border-radius: var(--radius-sm); background: var(--bg); cursor: pointer;
@ -490,42 +302,18 @@ input::placeholder { color: var(--text-muted); }
/* ---- view modes: segmented control ---- */
.mode-switch {
display: inline-flex; flex-direction: column; gap: 5px;
background: var(--surface-2); border: none; position: relative;
background: var(--surface-2); border: none;
border-radius: var(--radius); padding: 6px;
height: 100%; /* fill the header row so the bottom lines up with the date fields */
width: 160px; /* fixed so the bold active label doesn't resize the block */
}
/* Sliding active-mode indicator (JS sets top/height to the active button). */
.mode-ind {
position: absolute; left: 6px; right: 6px; top: 6px; height: 0; z-index: 0;
background: var(--bg); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm);
transition: top .2s var(--ease-3), height .2s var(--ease-3); pointer-events: none;
}
.mode-btn {
position: relative; z-index: 1;
border: none; background: transparent; color: var(--text-muted); cursor: pointer;
padding: 6px 12px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%;
border-radius: var(--radius-sm); transition: color var(--transition);
flex: 1; display: flex; align-items: center; gap: 8px; /* icon + label */
padding: 6px 13px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%;
border-radius: var(--radius-sm); transition: background var(--transition), color var(--transition);
flex: 1; display: flex; align-items: center; /* even heights, vertically centred */
}
.mode-ico { width: 16px; height: 16px; flex: 0 0 auto; }
.mode-btn:hover { color: var(--text); }
.mode-btn.active { color: var(--accent); font-weight: 600; }
/* ---- toast notifications (bottom-centre, transient) ---- */
.toasts {
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); z-index: 200;
display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none;
}
.toast {
display: flex; align-items: center; gap: 9px;
background: var(--tip-bg); color: var(--tip-text); font-size: 13px;
padding: 9px 15px; border-radius: 9px; box-shadow: var(--shadow-md);
opacity: 0; transform: translateY(8px);
transition: opacity .22s var(--ease-3), transform .22s var(--ease-3);
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast-ico { color: #37b24d; display: inline-flex; }
.mode-btn.active { background: var(--bg); color: var(--accent); font-weight: 600; box-shadow: var(--shadow-sm); }
/* Theme toggle */
.theme-toggle {
@ -590,34 +378,26 @@ html.role-pending .manager-only { display: none !important; }
/* Donut cluster icon (built in map.ts) — centre the 60px svg on the geo point. */
.mapdash-cluster { width: 60px; height: 60px; margin: -30px 0 0 -30px; line-height: 0; cursor: pointer; }
/* ---- stat tiles (header counter + map legend) ---- */
.stat-tile {
display: inline-flex; flex-direction: column; align-items: flex-start; gap: 1px;
background: var(--surface); border-radius: 9px; padding: 5px 11px;
font-variant-numeric: tabular-nums; border: none;
}
.st-l { font-size: 11px; color: var(--text-muted); display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.st-v { font-size: 18px; font-weight: 600; line-height: 1.15; color: var(--text); }
.stat-accent { background: #eef0fe; }
.stat-accent .st-v { color: var(--accent); }
.st-v.st-free { color: #2f9e44; }
.st-v.st-busy { color: #d64545; }
#map-legend {
position: absolute; left: 10px; bottom: 44px; z-index: 5;
display: flex; flex-direction: row; flex-wrap: wrap; gap: 6px;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
padding: var(--size-2) var(--size-3); font-size: 12px; color: var(--text);
display: flex; flex-direction: column; align-items: flex-start; gap: var(--size-1);
box-shadow: var(--shadow-md); font-variant-numeric: tabular-nums;
}
#map-legend .stat-tile { box-shadow: var(--shadow-md); }
#map-legend .lg-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
#map-legend .lg-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; font-size: 12px; }
#map-legend #map-counter { padding-left: 18px; } /* align "всего" with the dotted rows */
#map-legend .lg-nocoords { margin-top: 2px; font-size: 12px; }
#map-legend .lg-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
#map-legend .lg-free { background: #37b24d; }
#map-legend .lg-busy { background: #e24b4a; }
#map-legend .lg-total { background: var(--border-strong); }
/* Actionable "no coordinates" tile — dashed accent, clickable. */
.st-nc { cursor: pointer; background: #fbf6e9; border: 1px dashed #d7c08a; padding: 4px 10px; text-align: left;
transition: border-color var(--transition); }
.st-nc .lg-dot { background: #e8a317; }
.st-nc .st-v { color: #b07d16; }
.st-nc:hover { border-color: #b07d16; }
/* Actionable "no coordinates" badge inside the legend. */
.lg-nocoords {
border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface);
color: var(--text-muted); font-size: 12px; padding: 2px 8px; cursor: pointer; white-space: nowrap;
transition: border-color var(--transition), color var(--transition);
}
.lg-nocoords:hover { border-color: var(--border-strong); color: var(--text); }
/* Panel listing surfaces missing coordinates. */
#map-nocoords-panel {
position: absolute; left: 10px; bottom: 150px; z-index: 6;
@ -649,52 +429,6 @@ html.role-pending .manager-only { display: none !important; }
.bl-dates { color: var(--text-muted); }
.bl-link { color: var(--accent); text-decoration: none; font-weight: 600; }
.bl-link:hover { text-decoration: underline; }
/* Balloon photo gallery: main image + thumbnail strip; opens the lightbox. */
.bl-photos { margin: 2px 0 9px; }
.bl-photo { position: relative; height: 168px; border-radius: 8px; overflow: hidden; background: var(--surface-2); cursor: zoom-in; }
.bl-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
.bl-count { position: absolute; top: 8px; right: 8px; background: rgba(17,20,26,.72); color: #fff; font-size: 11px; padding: 2px 8px; border-radius: 20px; }
.bl-exp { position: absolute; bottom: 8px; right: 8px; background: rgba(17,20,26,.72); color: #fff; width: 26px; height: 26px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; }
.bl-thumbs { display: flex; gap: 6px; margin-top: 6px; }
.bl-thumb { width: 62px; height: 42px; padding: 0; border-radius: 6px; overflow: hidden; cursor: pointer; border: 2px solid transparent; background: var(--surface-2); }
.bl-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.bl-thumb-act { border-color: var(--accent); }
.bl-more { width: 62px; height: 42px; border-radius: 6px; border: none; background: var(--surface-2); color: var(--text-muted); font-size: 13px; cursor: pointer; }
/* Fullscreen photo lightbox above the map. */
.lb-overlay { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; }
.lb-overlay.open { display: flex; }
.lb-img { max-width: 92vw; max-height: 88vh; object-fit: contain; border-radius: 4px; }
.lb-close, .lb-prev, .lb-next { position: absolute; background: rgba(255,255,255,.12); color: #fff; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; border-radius: 8px; }
.lb-close:hover, .lb-prev:hover, .lb-next:hover { background: rgba(255,255,255,.24); }
.lb-close { top: 18px; right: 20px; width: 40px; height: 40px; font-size: 20px; }
.lb-prev, .lb-next { top: 50%; transform: translateY(-50%); width: 46px; height: 66px; font-size: 30px; }
.lb-prev { left: 18px; }
.lb-next { right: 18px; }
.lb-count { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 13px; background: rgba(0,0,0,.4); padding: 3px 12px; border-radius: 20px; }
/* Custom surface panel (replaces the Yandex balloon): fits content, draggable
by the header, resizable from the bottom-right corner. */
.mpanel {
position: fixed; z-index: 60; width: 320px; height: auto;
min-width: 240px; min-height: 130px; max-width: 92vw; max-height: 84vh;
overflow: auto; resize: both;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-md);
font-size: 13px; color: var(--text);
animation: pop-in .13s var(--ease-3);
}
.mpanel-head {
position: sticky; top: 0; z-index: 1; background: var(--bg);
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 9px 12px; border-bottom: 1px solid var(--border); cursor: move;
border-radius: var(--radius) var(--radius) 0 0;
}
.mpanel-title { font-weight: 700; font-size: 15px; }
.mpanel-close { border: none; background: none; color: var(--text-muted); font-size: 16px; line-height: 1; cursor: pointer; padding: 0 2px; }
.mpanel-close:hover { color: var(--text); }
.mpanel-tabs { display: flex; flex-wrap: wrap; gap: 6px; padding: 9px 12px 0; }
.mpanel-tab { border: 1px solid var(--border); background: var(--surface); color: var(--accent); border-radius: var(--radius-sm); padding: 3px 9px; font-size: 12px; cursor: pointer; }
.mpanel-tab-act { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); }
.mpanel-body { padding: 10px 12px 14px; }
#map-empty.empty-state { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 5; }
/* Custom hover tooltip for map markers and clusters. */
.map-tip {
@ -735,7 +469,6 @@ html.role-pending .manager-only { display: none !important; }
.vis-item.vis-range .vis-item-overflow { display: flex; align-items: center; height: 100%; width: 100%; overflow: hidden; }
.vis-item.vis-range .vis-item-content { padding: 0; width: 100%; }
.bar-inner {
position: relative;
height: var(--bar-h);
width: 100%;
box-sizing: border-box;
@ -750,20 +483,6 @@ html.role-pending .manager-only { display: none !important; }
overflow: hidden;
}
.vis-item.vis-range:hover .bar-inner { filter: brightness(0.92); }
/* Label wider than the bar: JS adds .bar-of (label past the bar's right edge) or
.bar-of-left (past the left edge, when the right side would hit the next bar),
as dark text on the track instead of clipping inside the bar. */
.vis-item.vis-range.bar-of .vis-item-overflow,
.vis-item.vis-range.bar-of-left .vis-item-overflow { overflow: visible; }
.vis-item.vis-range.bar-of .bar-inner,
.vis-item.vis-range.bar-of-left .bar-inner { overflow: visible; }
.vis-item.vis-range.bar-of .bar-text,
.vis-item.vis-range.bar-of-left .bar-text {
position: absolute; top: 50%; transform: translateY(-50%);
color: var(--text); white-space: nowrap; pointer-events: none;
}
.vis-item.vis-range.bar-of .bar-text { left: 100%; margin-left: 5px; }
.vis-item.vis-range.bar-of-left .bar-text { right: 100%; margin-right: 5px; text-align: right; }
.vis-foreground .vis-group { min-height: var(--row-h); }
.vis-labelset .vis-label { font-size: var(--label-font); display: flex; align-items: center; min-height: var(--row-h); }
@ -773,14 +492,10 @@ 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 */
/* #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 {
.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;
}
@ -789,7 +504,6 @@ 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,
@ -797,18 +511,15 @@ html.role-pending .manager-only { display: none !important; }
.vis-current-time { background-color: var(--today); }
/* Column resize handle */
/* Matches #split-resizer: a 6px solid bar that turns accent on hover/drag, so
both dividers look identical. */
#col-resizer {
position: absolute; top: 0; bottom: 0; width: 6px; margin-left: -3px;
background: var(--border); cursor: col-resize; z-index: 25; display: none;
transition: background var(--transition);
position: absolute; top: 0; bottom: 0; width: 9px; margin-left: -4px;
cursor: col-resize; z-index: 25; display: none;
}
#col-resizer:hover, #col-resizer.dragging { background: var(--accent); }
#col-resizer::after { content: ""; position: absolute; top: 0; bottom: 0; left: 4px; width: 1px; background: var(--border); }
#col-resizer:hover::after, #col-resizer.dragging::after { background: var(--accent); width: 2px; left: 3px; }
/* 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,10 +14,6 @@ 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 {
@ -28,46 +24,24 @@ 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/';
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М' },
{ 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 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 MIN_LABEL_W = 140;
const MAX_LABEL_W = 760;
// "2026-06-01" -> "01.06.2026"
@ -98,9 +72,6 @@ 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;
@ -113,7 +84,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, showCity: false, showAddress: true, showCode: true };
let lastFlags: RenderFlags = { withBrand: true, withCompany: false, withCollisions: true, managerView: true };
let onBoardClick: ((boardId: string) => void) | null = null;
const options: TimelineOptions = {
@ -130,7 +101,6 @@ 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 },
};
@ -168,71 +138,6 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
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) {
@ -266,22 +171,6 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
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;
@ -416,7 +305,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
const groups = boards.map((b, idx) => ({
id: b.board_id,
content: groupContent(b, flags),
content: escapeHtml(b.board_address) + ' (' + escapeHtml(b.board_id) + ')',
order: idx,
className: idx % 2 === 0 ? 'row-even' : 'row-odd',
}));
@ -478,7 +367,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
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>`,
content: `<div class="bar-inner" style="background-color:${color};">${barContent}</div>`,
type: 'range',
tooltipHtml,
taskId: bk.task_id,
@ -489,35 +378,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 });
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();
@ -534,7 +404,6 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
return {
render,
refresh,
refreshLabels,
applyLayout,
setScale,
setOnBoardClick(fn: (boardId: string) => void): void { onBoardClick = fn; },

View File

@ -66,8 +66,6 @@ export interface MapSurface {
lon: number | null;
occupied_now: boolean;
bookings: MapBooking[];
/** URLs of this surface's photos (empty when none). Shown to everyone. */
photos: string[];
}
/** task_status value that marks an archived booking (painted slate #708090). */

View File

@ -37,23 +37,13 @@ export function createViewModes(els: ViewModeElements, cb: ViewModeCallbacks): V
els.view.style.setProperty('--split-left', savedSplit + '%');
}
function positionIndicator(): void {
const active = mode === 'timeline' ? els.btnTimeline : mode === 'map' ? els.btnMap : els.btnSplit;
const ind = els.btnTimeline.parentElement?.querySelector<HTMLElement>('.mode-ind');
if (ind && active.offsetHeight) {
ind.style.top = active.offsetTop + 'px';
ind.style.height = active.offsetHeight + 'px';
}
}
function apply(): void {
els.view.classList.remove('mode-timeline', 'mode-map', 'mode-split');
els.view.classList.add('mode-' + mode);
els.btnTimeline.classList.toggle('active', mode === 'timeline');
els.btnMap.classList.toggle('active', mode === 'map');
els.btnSplit.classList.toggle('active', mode === 'split');
positionIndicator();
}
window.addEventListener('resize', positionIndicator);
function setMode(next: ViewMode): void {
mode = next;
@ -105,7 +95,6 @@ export function createViewModes(els: ViewModeElements, cb: ViewModeCallbacks): V
const saved = localStorage.getItem(LS_MODE) as ViewMode | null;
mode = saved === 'map' || saved === 'split' ? saved : 'timeline';
apply();
requestAnimationFrame(positionIndicator); // header is laid out by the next frame
return {
getMode: () => mode,

39
main.py
View File

@ -1,7 +1,5 @@
import asyncio
import os
import time
from urllib.parse import quote
import httpx
from fastapi import Depends, FastAPI, Header, Query
from fastapi.responses import FileResponse
@ -307,39 +305,6 @@ async def bookings(role: str = Depends(get_role), city: str = "", dimension: str
return rows
# Surface photos live on disk as photos/<board_key>/<file>.{jpg,jpeg,png}. The
# directory listing is cached briefly so /api/map doesn't stat the tree on every
# request; new photos appear within PHOTO_TTL seconds.
PHOTOS_DIR = "photos"
PHOTO_EXT = (".jpg", ".jpeg", ".png")
PHOTO_TTL = 60.0
_photo_index: dict[str, list[str]] = {}
_photo_index_ts = 0.0
def photo_index() -> dict[str, list[str]]:
global _photo_index, _photo_index_ts
now = time.monotonic()
if _photo_index_ts and now - _photo_index_ts < PHOTO_TTL:
return _photo_index
idx: dict[str, list[str]] = {}
try:
for key in os.listdir(PHOTOS_DIR):
sub = os.path.join(PHOTOS_DIR, key)
if not os.path.isdir(sub):
continue
files = sorted(
f for f in os.listdir(sub)
if f.lower().endswith(PHOTO_EXT) and os.path.isfile(os.path.join(sub, f))
)
if files:
idx[key] = [f"/photos/{quote(key)}/{quote(f)}" for f in files]
except FileNotFoundError:
pass
_photo_index, _photo_index_ts = idx, now
return _photo_index
@app.get("/api/map")
async def map_data(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = ""):
if role != "manager":
@ -396,7 +361,6 @@ async def map_data(role: str = Depends(get_role), city: str = "", dimension: str
by_key.setdefault(b["board_key"], []).append(b)
anon = role != "manager"
photos = photo_index()
out = []
for s in surfaces:
active = by_key.get(s["board_key"], [])
@ -406,11 +370,8 @@ async def map_data(role: str = Depends(get_role), city: str = "", dimension: str
# Truncated tier keeps the free/occupied marker colour but hides the
# balloon's booking details (company/brand/status).
"bookings": [] if anon else active,
# Surface photos are public inventory — shown to everyone.
"photos": photos.get(str(s["board_key"]), []),
})
return out
app.mount("/photos", StaticFiles(directory=PHOTOS_DIR, check_dir=False), name="photos")
app.mount("/", StaticFiles(directory="static", html=True), name="static")