Compare commits

..

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

14 changed files with 172 additions and 1223 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@ -3,7 +3,6 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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> <title>Адресная программа</title>
<script> <script>
// Theme is set before first paint (no flash). Dark theme is temporarily // Theme is set before first paint (no flash). Dark theme is temporarily
@ -40,17 +39,16 @@
<div class="brand"> <div class="brand">
<div class="brand-name">Green Media</div> <div class="brand-name">Green Media</div>
<div class="brand-sub">Адресная программа</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>
</div> </div>
<!-- Group 1b: view mode switch --> <!-- Group 1b: view mode switch -->
<div class="hgroup hgroup-modes"> <div class="hgroup hgroup-modes">
<div class="mode-switch"> <div class="mode-switch">
<div class="mode-ind" aria-hidden="true"></div> <button type="button" class="mode-btn active" id="mode-timeline">График</button>
<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">Карта</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">Карта и график</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>
</div> </div>
<button type="button" class="theme-toggle" id="theme-toggle" title="Переключить тему" aria-label="Переключить тему"></button> <button type="button" class="theme-toggle" id="theme-toggle" title="Переключить тему" aria-label="Переключить тему"></button>
</div> </div>
@ -97,18 +95,13 @@
<div class="dropdown-panel" id="manager-panel"></div> <div class="dropdown-panel" id="manager-panel"></div>
</div> </div>
</div> </div>
<div class="field field-dates"> <div class="field">
<label>Период</label> <label for="date-start">Дата начала (от)</label>
<div class="daterange" id="daterange"> <input type="date" id="date-start" />
<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>
<div class="field">
<label for="date-end">Дата окончания (до)</label>
<input type="date" id="date-end" />
</div> </div>
<div class="field manager-only"> <div class="field manager-only">
<label>Статус</label> <label>Статус</label>
@ -126,8 +119,14 @@
</div> </div>
</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="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 manager-only">
<div class="checks-row"> <div class="checks-row">
<div class="field checkbox-field"> <div class="field checkbox-field">
@ -164,22 +163,6 @@
<div id="pane-timeline"> <div id="pane-timeline">
<div id="chart-wrap"> <div id="chart-wrap">
<div id="timeline"></div> <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="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div>
<div id="empty" class="empty-state" style="display:none"> <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> <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 +176,10 @@
<div id="pane-map"> <div id="pane-map">
<div id="map"></div> <div id="map"></div>
<div id="map-legend"> <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> <span class="lg-item"><span class="lg-dot lg-free"></span><span id="lg-free">свободна</span></span>
<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> <span class="lg-item"><span class="lg-dot lg-busy"></span><span id="lg-busy">занята</span></span>
<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> <span class="lg-item" id="map-counter"></span>
<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> <button type="button" class="lg-nocoords" id="map-nocoords-btn" style="display:none"></button>
</div> </div>
<div id="map-nocoords-panel" style="display:none"></div> <div id="map-nocoords-panel" style="display:none"></div>
<div id="map-empty" class="empty-state" style="display:none"> <div id="map-empty" class="empty-state" style="display:none">

View File

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

View File

@ -25,7 +25,7 @@ export const DEFAULT_STATUS_COLORS: Record<string, string> = {
'РК. Новая': '#4f46e5', 'РК. Новая': '#4f46e5',
'РК. Предварительное бронирование': '#4f46e5', 'РК. Предварительное бронирование': '#4f46e5',
'РК. Макет РИМ согласован': '#4f46e5', 'РК. Макет РИМ согласован': '#4f46e5',
'РК. Размещено': '#26c04c', 'РК. Размещено': '#4f46e5',
'РК. Ожидает монтаж': '#4f46e5', 'РК. Ожидает монтаж': '#4f46e5',
'РК. Ожидает демонтаж': '#4f46e5', 'РК. Ожидает демонтаж': '#4f46e5',
'РК. Ожидает перемонтаж': '#4f46e5', 'РК. Ожидает перемонтаж': '#4f46e5',
@ -98,53 +98,21 @@ export function applyCssVars(a: Appearance): void {
// ---- settings menu UI ---------------------------------------------------- // ---- settings menu UI ----------------------------------------------------
function numberRow(labelText: string, value: number, min: number, max: number, onInput: (v: number) => void): HTMLElement { 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('label');
const row = document.createElement('div');
row.className = 'decor-row'; row.className = 'decor-row';
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = labelText; span.textContent = labelText;
const stepper = document.createElement('div');
stepper.className = 'decor-stepper';
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'number'; input.type = 'number';
input.className = 'ds-input';
input.min = String(min); input.min = String(min);
input.max = String(max); input.max = String(max);
input.value = String(value); 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', () => { input.addEventListener('input', () => {
const v = parseInt(input.value, 10); 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(span);
row.appendChild(stepper); row.appendChild(input);
return row; 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 { function renderPanel(): void {
panel.innerHTML = ''; 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'); const actions = document.createElement('div');
actions.className = 'dp-actions'; actions.className = 'dp-actions';
const selAll = document.createElement('a'); const selAll = document.createElement('a');
@ -88,6 +64,11 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
actions.appendChild(selNone); actions.appendChild(selNone);
panel.appendChild(actions); 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) { for (const v of display) {
const label = document.createElement('label'); const label = document.createElement('label');
const cb = document.createElement('input'); const cb = document.createElement('input');
@ -102,7 +83,6 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
}); });
label.appendChild(cb); label.appendChild(cb);
label.appendChild(document.createTextNode(formatLabel(v))); label.appendChild(document.createTextNode(formatLabel(v)));
optionEls.push({ v, el: label });
panel.appendChild(label); panel.appendChild(label);
} }
} }
@ -113,14 +93,6 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
if (d !== root) d.classList.remove('open'); if (d !== root) d.classList.remove('open');
}); });
root.classList.toggle('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) => { document.addEventListener('click', (e) => {
if (!root.contains(e.target as Node)) root.classList.remove('open'); 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 { api } from './api';
import type { Board, Booking, Filters } from './types'; import type { Board, Booking, Filters } from './types';
import { createDropdown } from './dropdown'; import { createDropdown } from './dropdown';
import { createDateRange } from './datepicker';
import { createTimelineView, zoomSteps, type RenderFlags } from './timeline'; 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 { createMapView } from './map';
import { createViewModes, type ViewMode } from './viewmode'; import { createViewModes, type ViewMode } from './viewmode';
import { escapeHtml } from './util';
import { initAuth, isManager, isAuthenticated, login, logout } from './auth'; import { initAuth, isManager, isAuthenticated, login, logout } from './auth';
const el = { const el = {
@ -35,8 +35,6 @@ const el = {
showAllSurfaces: document.getElementById('show-all-surfaces') as HTMLInputElement, showAllSurfaces: document.getElementById('show-all-surfaces') as HTMLInputElement,
zoomSlider: document.getElementById('zoom-slider') as HTMLInputElement, zoomSlider: document.getElementById('zoom-slider') as HTMLInputElement,
zoomLabel: document.getElementById('zoom-label') as HTMLElement, 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, chartWrap: document.getElementById('chart-wrap') as HTMLElement,
colResizer: document.getElementById('col-resizer') as HTMLElement, colResizer: document.getElementById('col-resizer') as HTMLElement,
decorBtn: document.getElementById('decor-btn') as HTMLButtonElement, decorBtn: document.getElementById('decor-btn') as HTMLButtonElement,
@ -116,7 +114,6 @@ const brandDropdown = createDropdown('brand');
const dimensionDropdown = createDropdown('dimension'); const dimensionDropdown = createDropdown('dimension');
const managerDropdown = createDropdown('manager'); const managerDropdown = createDropdown('manager');
const statusDropdown = createDropdown('status', statusLabel); const statusDropdown = createDropdown('status', statusLabel);
const dateRange = createDateRange();
const view = createTimelineView( const view = createTimelineView(
{ {
timelineEl: el.timelineEl, timelineEl: el.timelineEl,
@ -136,32 +133,17 @@ const mapView = createMapView(
let lastBoards: Board[] = []; let lastBoards: Board[] = [];
let lastBookings: Booking[] = []; 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 { 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 // Anonymous tier: no labels (data is blanked anyway) and no collision
// highlighting, so every bar renders in the single neutral status colour. // highlighting, so every bar renders in the single neutral status colour.
if (!isManager()) { if (!isManager()) {
return { withBrand: false, withCompany: false, withCollisions: false, managerView: false, ...cols }; return { withBrand: false, withCompany: false, withCollisions: false, managerView: false };
} }
return { return {
withBrand: el.showBrand.checked, withBrand: el.showBrand.checked,
withCompany: el.showCompany.checked, withCompany: el.showCompany.checked,
withCollisions: el.showCollisions.checked, withCollisions: el.showCollisions.checked,
managerView: true, managerView: true,
...cols,
}; };
} }
@ -249,32 +231,6 @@ const fmtD = (iso: string): string => {
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso; 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 ---- // ---- shareable filter links: current filters <-> URL query ----
function filtersToUrl(): void { function filtersToUrl(): void {
const f = currentFilters(); const f = currentFilters();
@ -333,8 +289,11 @@ function renderChips(): void {
share.addEventListener('click', () => { share.addEventListener('click', () => {
navigator.clipboard navigator.clipboard
?.writeText(location.href) ?.writeText(location.href)
.then(() => showToast('Ссылка скопирована')) .then(() => {
.catch(() => showToast('Не удалось скопировать')); share.textContent = 'Ссылка скопирована';
window.setTimeout(() => { share.textContent = 'Скопировать ссылку'; }, 1600);
})
.catch(() => { /* clipboard unavailable */ });
}); });
el.filterChips.appendChild(share); el.filterChips.appendChild(share);
const divider = document.createElement('span'); const divider = document.createElement('span');
@ -366,7 +325,7 @@ el.emptyResetTimeline.addEventListener('click', resetFilters);
el.emptyResetMap.addEventListener('click', resetFilters); el.emptyResetMap.addEventListener('click', resetFilters);
async function loadData(fit: boolean): Promise<void> { async function loadData(fit: boolean): Promise<void> {
el.status.innerHTML = '<span class="st-l">Поверхности</span><span class="st-v">…</span>'; el.status.textContent = 'Загрузка…';
loadStart(); loadStart();
try { try {
const f = currentFilters(); const f = currentFilters();
@ -380,10 +339,9 @@ async function loadData(fit: boolean): Promise<void> {
lastBoards = boards; lastBoards = boards;
lastBookings = bookings; lastBookings = bookings;
view.render(boards, bookings, fit, flags()); 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 rebuildDecorMenu(); // status list may have changed
renderChips(); renderChips();
dateRange.sync(); // keep the date-range field in step with reset / chip-remove / URL
filtersToUrl(); filtersToUrl();
// Keep the map in sync when it is visible (facets filter it; dates do not). // Keep the map in sync when it is visible (facets filter it; dates do not).
if (mapVisible()) void refreshMap(); if (mapVisible()) void refreshMap();
@ -420,25 +378,9 @@ async function refreshMap(): Promise<void> {
} }
} }
let mapDestroyTimer = 0;
async function onModeChange(mode: ViewMode): Promise<void> { async function onModeChange(mode: ViewMode): Promise<void> {
if (mode !== 'map') view.applyLayout(); // timeline visible (timeline or split) if (mode !== 'map') view.applyLayout(); // timeline visible (timeline or split)
if (mode === 'timeline') { if (mode !== 'timeline') await refreshMap(); // map visible (map or split)
mapView.closePanel(); // map hidden — drop its floating card
// Free the Yandex map (tiles/canvas/GPU — the bulk of the tab's memory) a
// couple of seconds after it's closed; cancelled if a map view returns, so
// quick График⇄Карта toggles don't thrash a re-init.
window.clearTimeout(mapDestroyTimer);
mapDestroyTimer = window.setTimeout(() => {
mapView.destroy();
// The fresh map/ObjectManager will be empty, so drop the "already loaded"
// key — the next open re-fetches + re-renders instead of short-circuiting.
mapLoadedKey = '';
}, 2000);
} else {
window.clearTimeout(mapDestroyTimer); // map visible again — keep it
await refreshMap(); // map visible (map or split); ensureInit rebuilds if destroyed
}
} }
const viewModes = createViewModes( const viewModes = createViewModes(
@ -480,64 +422,65 @@ el.search.addEventListener('input', scheduleReload);
el.dateStart.addEventListener('change', scheduleReload); el.dateStart.addEventListener('change', scheduleReload);
el.dateEnd.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', () => { el.zoomSlider.addEventListener('input', () => {
const step = zoomSteps[parseInt(el.zoomSlider.value, 10)]!; const step = zoomSteps[parseInt(el.zoomSlider.value, 10)]!;
el.zoomLabel.textContent = step.label; el.zoomLabel.textContent = step.label;
el.zoomCollapsed.textContent = step.label;
view.setScale(step.days); 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. // ---- board detail panel (opened from a timeline row label) ----
const ciButtons = Array.from(document.querySelectorAll<HTMLElement>('.ci-toggle')); const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/';
function syncInfoButtons(): void { const boardPanel = document.getElementById('board-panel') as HTMLElement;
for (const btn of ciButtons) btn.classList.toggle('is-on', infoCols[btn.dataset.part as keyof InfoCols]); 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) { function openBoardPanel(boardId: string): void {
btn.addEventListener('click', () => { // Detail panel exposes client/brand/manager + Planfix deep-links — managers only.
const part = btn.dataset.part as keyof InfoCols; if (!isManager()) return;
// Never leave the row empty: can't turn off the last remaining part. const board = lastBoards.find((b) => b.board_id === boardId);
if (infoCols[part] && !(['city', 'address', 'code'] as (keyof InfoCols)[]).some((k) => k !== part && infoCols[k])) return; const bookings = lastBookings
infoCols[part] = !infoCols[part]; .filter((b) => b.board_id === boardId)
localStorage.setItem(CI_LS, JSON.stringify(infoCols)); .sort((a, b) => a.start_date.localeCompare(b.start_date));
syncInfoButtons(); boardPanelTitle.textContent = boardId;
view.refreshLabels(flags()); 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(); (document.getElementById('board-panel-close') as HTMLElement).addEventListener('click', closeBoardPanel);
boardBackdrop.addEventListener('click', closeBoardPanel);
// Clicking a surface in the timeline's left column flies the map to it: switch to document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeBoardPanel(); });
// the combined "Карта и график" view if only the timeline is open, then focus the view.setOnBoardClick(openBoardPanel);
// 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);
async function init(): Promise<void> { async function init(): Promise<void> {
cityDropdown.setValues([], 'Все города'); cityDropdown.setValues([], 'Все города');
@ -556,12 +499,10 @@ async function init(): Promise<void> {
rebuildDecorMenu(); rebuildDecorMenu();
view.applyLayout(); view.applyLayout();
await loadData(true); await loadData(true);
const defaultZoomIdx = 1; // 12М const defaultZoomIdx = 3;
el.zoomSlider.value = String(defaultZoomIdx); el.zoomSlider.value = String(defaultZoomIdx);
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label; el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label;
el.zoomCollapsed.textContent = zoomSteps[defaultZoomIdx]!.label;
view.setScale(zoomSteps[defaultZoomIdx]!.days); view.setScale(zoomSteps[defaultZoomIdx]!.days);
updateZoomUi();
// Apply the persisted view mode (inits the map if it starts visible). // Apply the persisted view mode (inits the map if it starts visible).
void onModeChange(viewModes.getMode()); void onModeChange(viewModes.getMode());
} }

View File

@ -62,12 +62,6 @@ export interface MapView {
render(surfaces: MapSurface[], managerView: boolean): void; render(surfaces: MapSurface[], managerView: boolean): void;
ensureInit(): Promise<void>; ensureInit(): Promise<void>;
invalidateSize(): 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" // "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; 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. // Balloon (click) — structured card matching the timeline tooltip style.
function balloonBody(s: MapSurface): string { function balloonBody(s: MapSurface): string {
const busyColor = s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE; const busyColor = s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE;
const rows: string[] = ['<div class="bl">']; 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(' · '); const meta = [s.dimension, s.board_type].filter(Boolean).map(escapeHtml).join(' · ');
if (meta) rows.push(`<div class="bl-sub">${meta}</div>`); if (meta) rows.push(`<div class="bl-sub">${meta}</div>`);
rows.push(`<div class="bl-sub">${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}</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(''); 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). // Hover tooltip for a single marker: code, size, address (+ brand/company if booked).
function singleTip(s: MapSurface): string { function singleTip(s: MapSurface): string {
const parts: string[] = []; const parts: string[] = [];
@ -371,12 +134,6 @@ function clusterTip(list: MapSurface[]): string {
} }
export function createMapView(els: MapElements, apiKey: string): MapView { 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 ymaps: any = null;
let map: any = null; let map: any = null;
let objectManager: any = null; let objectManager: any = null;
@ -450,22 +207,8 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
// manager-only tool, so it stays hidden for anonymous visitors. // manager-only tool, so it stays hidden for anonymous visitors.
let managerView = false; 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 { function draw(surfaces: MapSurface[]): void {
if (!objectManager) return; if (!objectManager) return;
closeMapPanel();
const withCoords = surfaces.filter((s) => s.lat != null && s.lon != null); const withCoords = surfaces.filter((s) => s.lat != null && s.lon != null);
const noCoordsList = surfaces.filter((s) => s.lat == null || s.lon == null); const noCoordsList = surfaces.filter((s) => s.lat == null || s.lon == null);
drawn = withCoords; drawn = withCoords;
@ -475,28 +218,29 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
type: 'Feature', type: 'Feature',
id: i, id: i,
geometry: { type: 'Point', coordinates: [s.lat as number, s.lon as number] }, 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: {
properties: { occupied: s.occupied_now }, balloonContentHeader: escapeHtml(s.board_id),
balloonContentBody: balloonBody(s),
occupied: s.occupied_now,
},
// Same colours as the donut cluster (islands#circleDotIcon is colourable). // 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(); hideTip();
objectManager.removeAll(); objectManager.removeAll();
objectManager.add({ type: 'FeatureCollection', features }); objectManager.add({ type: 'FeatureCollection', features });
// Live legend counts (react to the current filter) — values only; the labels // Live legend counts (react to the current filter).
// are static in the tile markup. if (legendFree) legendFree.textContent = `свободно: ${free}`;
if (legendFree) legendFree.textContent = String(free); if (legendBusy) legendBusy.textContent = `занято: ${busy}`;
if (legendBusy) legendBusy.textContent = String(busy);
// Total counts the whole inventory, including surfaces without coordinates // Total counts the whole inventory, including surfaces without coordinates
// (which can't be drawn but are still surfaces — listed under "без координат"). // (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 — manager-only.
if (noCoordsBtn) { if (noCoordsBtn) {
noCoordsBtn.style.display = managerView && noCoordsList.length ? '' : 'none'; noCoordsBtn.style.display = managerView && noCoordsList.length ? '' : 'none';
const ncVal = noCoordsBtn.querySelector<HTMLElement>('.st-v'); noCoordsBtn.textContent = `без координат: ${noCoordsList.length}`;
if (ncVal) ncVal.textContent = String(noCoordsList.length);
} }
if (noCoordsPanel) { if (noCoordsPanel) {
noCoordsPanel.innerHTML = renderNoCoords(noCoordsList); noCoordsPanel.innerHTML = renderNoCoords(noCoordsList);
@ -506,22 +250,13 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
els.empty.style.display = withCoords.length ? 'none' : 'flex'; els.empty.style.display = withCoords.length ? 'none' : 'flex';
if (features.length) { 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 points = features.map((f) => f.geometry.coordinates);
const bounds = ymaps.util.bounds.fromPoints(points); const bounds = ymaps.util.bounds.fromPoints(points);
const done = map.setBounds(bounds, { checkZoomRange: true, zoomMargin: 40 }); const done = map.setBounds(bounds, { checkZoomRange: true, zoomMargin: 40 });
// Search-to-locate: when the filter narrows to a single surface, fly in // Search-to-locate: when the filter narrows to a single surface, fly in
// and open its card so the user lands right on it. // and open its balloon so the user lands right on the card.
if (withCoords.length === 1 && done && typeof done.then === 'function') { if (features.length === 1 && done && typeof done.then === 'function') {
done.then(() => { done.then(() => objectManager.objects.balloon.open(features[0].id));
const b = els.container.getBoundingClientRect();
showMapPanel([withCoords[0]!], 0, { x: (b.left + b.right) / 2, y: (b.top + b.bottom) / 2, bounds: b });
});
}
} }
} }
} }
@ -574,13 +309,8 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
objectManager = new ymaps.ObjectManager({ objectManager = new ymaps.ObjectManager({
clusterize: true, clusterize: true,
gridSize: 112, gridSize: 112,
// Even a lone surface becomes a cluster of 1 (donut showing "1"), so all // Click opens the balloon at any zoom (instead of zooming into the cluster).
// 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.
clusterDisableClickZoom: true, clusterDisableClickZoom: true,
clusterHasBalloon: false,
clusterIconLayout: DonutClusterLayout, clusterIconLayout: DonutClusterLayout,
// Hit area for hover/click — a circle centred on the geo point matching // 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. // the 60px donut (radius 30). Without a shape the icon is inert.
@ -598,30 +328,14 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
const cluster = objectManager.clusters.getById(e.get('objectId')); const cluster = objectManager.clusters.getById(e.get('objectId'));
const objs = cluster ? cluster.properties.geoObjects : []; const objs = cluster ? cluster.properties.geoObjects : [];
const list = objs.map((o: any) => drawn[o.id as number]).filter(Boolean); 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) showTip(clusterTip(list));
if (list.length === 1) showTip(singleTip(list[0]!));
else if (list.length) showTip(clusterTip(list));
}); });
objectManager.clusters.events.add(['mouseleave'], hideTip); objectManager.clusters.events.add(['mouseleave'], hideTip);
// Click opens our own panel (and hides the hover tip so it doesn't stick). // Hide the hover tooltip when a balloon opens — otherwise mouseleave may
objectManager.objects.events.add(['click'], (e: any) => { // not fire (the balloon covers the marker) and the tip stays stuck,
hideTip(); // following the cursor across the whole map.
const s = drawn[e.get('objectId') as number]; objectManager.objects.events.add(['click'], hideTip);
if (s) showMapPanel([s], 0, clusterPlace(mx, my)); objectManager.clusters.events.add(['click'], hideTip);
});
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();
});
if (pending) { if (pending) {
draw(pending); draw(pending);
@ -633,26 +347,5 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
invalidateSize(): void { invalidateSize(): void {
if (map) map.container.fitToViewport(); 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-odd: #ffffff;
--row-hover: #fff3cd; --row-hover: #fff3cd;
--month-hover: rgba(79, 70, 229, .08); --month-hover: rgba(79, 70, 229, .08);
--cur-month: rgba(79, 70, 229, .07);
--tip-bg: var(--gray-9); --tip-bg: var(--gray-9);
--tip-text: #ffffff; --tip-text: #ffffff;
--tip-sub: var(--gray-4); --tip-sub: var(--gray-4);
@ -61,7 +60,6 @@
--row-odd: #0f1420; --row-odd: #0f1420;
--row-hover: #33361f; --row-hover: #33361f;
--month-hover: rgba(91, 141, 239, .12); --month-hover: rgba(91, 141, 239, .12);
--cur-month: rgba(91, 141, 239, .09);
--tip-bg: #0b0f18; --tip-bg: #0b0f18;
} }
@ -109,12 +107,12 @@ header {
.hgroup-fields { .hgroup-fields {
flex-shrink: 4; min-width: 0; flex-shrink: 4; min-width: 0;
display: grid; display: grid;
grid-template-columns: repeat(4, 195px); grid-template-columns: repeat(4, minmax(130px, 1fr));
gap: 12px 14px; gap: 12px 14px;
align-items: start; align-items: start;
align-content: 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 .field { min-width: 0; }
.hgroup-fields .dropdown, .hgroup-fields .dropdown,
.hgroup-fields .dropdown-btn, .hgroup-fields .dropdown-btn,
@ -123,61 +121,6 @@ header {
@media (max-width: 760px) { .hgroup-fields { grid-template-columns: repeat(2, minmax(120px, 1fr)); } } @media (max-width: 760px) { .hgroup-fields { grid-template-columns: repeat(2, minmax(120px, 1fr)); } }
/* Fields group: top-align so the date input sits UNDER Город/Бренд. */ /* Fields group: top-align so the date input sits UNDER Город/Бренд. */
.hgroup-fields { align-items: flex-start; } .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; } .fcol { display: flex; flex-direction: column; gap: 8px; }
input[type=date], input[type=date],
@ -218,10 +161,7 @@ input::placeholder { color: var(--text-muted); }
top, the three checkboxes stacked below. */ top, the three checkboxes stacked below. */
.hgroup-controls { flex-direction: column; align-items: flex-start; gap: 8px; } .hgroup-controls { flex-direction: column; align-items: flex-start; gap: 8px; }
.zoom-row { display: flex; align-items: center; gap: 8px; } .zoom-row { display: flex; align-items: center; gap: 8px; }
/* Toggles: aligned to the filter *controls* (not the labels) Бренд + Компания .checks { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
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 .checkbox-field { align-self: flex-start; } .checks .checkbox-field { align-self: flex-start; }
.checks-row { display: flex; flex-direction: row; align-items: center; gap: 18px; } .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. */ /* 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. */ /* Scale group: stack the "Оформление" button under the zoom slider. */
.checkbox-field { flex-direction: row; align-items: center; gap: 6px; align-self: center; } .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-size: 13px; color: var(--text); text-transform: none; letter-spacing: normal;
font-weight: 400; cursor: pointer; white-space: nowrap; font-weight: 400; cursor: pointer; white-space: nowrap;
} }
/* Toggle switch: the checkbox keeps its semantics/JS, only the look changes. .checkbox-field input[type=checkbox] { cursor: pointer; accent-color: var(--accent); }
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; }
.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 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 */ /* checkbox dropdown filter */
.dropdown { position: relative; } .dropdown { position: relative; }
@ -363,12 +215,7 @@ input::placeholder { color: var(--text-muted); }
display: none; position: absolute; top: calc(100% + 4px); left: 0; display: none; position: absolute; top: calc(100% + 4px); left: 0;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-md); padding: 6px 0; box-shadow: var(--shadow-md); padding: 6px 0;
/* Fit the widest option instead of a fixed width, capped at 350px wider min-width: 220px; max-height: 280px; overflow-y: auto; z-index: 30;
content then scrolls horizontally. The .dp-search fills via fill-available
(not width:100%, whose % included the scrollbar area clipped border +
phantom horizontal scrollbar). */
width: max-content; min-width: 220px; max-width: 350px;
max-height: 280px; overflow: auto; z-index: 30;
} }
.dropdown.open .dropdown-panel { display: block; } .dropdown.open .dropdown-panel { display: block; }
.dropdown-panel label { .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 { font-size: 11px; color: var(--accent); cursor: pointer; text-decoration: none; font-weight: 600; }
.dropdown-panel .dp-actions a:hover { text-decoration: underline; } .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 */ /* Оформление button + context menu */
/* Pinned to the header's bottom-right corner. */ /* Pinned to the header's bottom-right corner. */
@ -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); width: 84px; padding: 5px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm);
font-size: 13px; background: var(--bg); color: var(--text); 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] { .decor-row input[type=color] {
width: 42px; height: 28px; padding: 0; border: 1px solid var(--border); width: 42px; height: 28px; padding: 0; border: 1px solid var(--border);
border-radius: var(--radius-sm); background: var(--bg); cursor: pointer; border-radius: var(--radius-sm); background: var(--bg); cursor: pointer;
@ -490,42 +302,18 @@ input::placeholder { color: var(--text-muted); }
/* ---- view modes: segmented control ---- */ /* ---- view modes: segmented control ---- */
.mode-switch { .mode-switch {
display: inline-flex; flex-direction: column; gap: 5px; 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; border-radius: var(--radius); padding: 6px;
height: 100%; /* fill the header row so the bottom lines up with the date fields */ 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 { .mode-btn {
position: relative; z-index: 1;
border: none; background: transparent; color: var(--text-muted); cursor: pointer; border: none; background: transparent; color: var(--text-muted); cursor: pointer;
padding: 6px 12px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%; padding: 6px 13px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%;
border-radius: var(--radius-sm); transition: color var(--transition); border-radius: var(--radius-sm); transition: background var(--transition), color var(--transition);
flex: 1; display: flex; align-items: center; gap: 8px; /* icon + label */ 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:hover { color: var(--text); }
.mode-btn.active { color: var(--accent); font-weight: 600; } .mode-btn.active { background: var(--bg); color: var(--accent); font-weight: 600; box-shadow: var(--shadow-sm); }
/* ---- 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; }
/* Theme toggle */ /* Theme toggle */
.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. */ /* 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; } .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 { #map-legend {
position: absolute; left: 10px; bottom: 44px; z-index: 5; 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-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; font-size: 12px; }
#map-legend .lg-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; } #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-free { background: #37b24d; }
#map-legend .lg-busy { background: #e24b4a; } #map-legend .lg-busy { background: #e24b4a; }
#map-legend .lg-total { background: var(--border-strong); } /* Actionable "no coordinates" badge inside the legend. */
/* Actionable "no coordinates" tile — dashed accent, clickable. */ .lg-nocoords {
.st-nc { cursor: pointer; background: #fbf6e9; border: 1px dashed #d7c08a; padding: 4px 10px; text-align: left; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface);
transition: border-color var(--transition); } color: var(--text-muted); font-size: 12px; padding: 2px 8px; cursor: pointer; white-space: nowrap;
.st-nc .lg-dot { background: #e8a317; } transition: border-color var(--transition), color var(--transition);
.st-nc .st-v { color: #b07d16; } }
.st-nc:hover { border-color: #b07d16; } .lg-nocoords:hover { border-color: var(--border-strong); color: var(--text); }
/* Panel listing surfaces missing coordinates. */ /* Panel listing surfaces missing coordinates. */
#map-nocoords-panel { #map-nocoords-panel {
position: absolute; left: 10px; bottom: 150px; z-index: 6; 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-dates { color: var(--text-muted); }
.bl-link { color: var(--accent); text-decoration: none; font-weight: 600; } .bl-link { color: var(--accent); text-decoration: none; font-weight: 600; }
.bl-link:hover { text-decoration: underline; } .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; } #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. */ /* Custom hover tooltip for map markers and clusters. */
.map-tip { .map-tip {
@ -773,14 +507,10 @@ html.role-pending .manager-only { display: none !important; }
/* Row hover */ /* Row hover */
.vis-label.row-hover, .vis-group.row-hover { background-color: var(--row-hover) !important; } .vis-label.row-hover, .vis-group.row-hover { background-color: var(--row-hover) !important; }
/* Fixed/resizable label column */ /* Fixed/resizable label column */
/* #timeline lifts specificity above vis-timeline's own `.vis-labelset .vis-label .vis-label .vis-inner {
.vis-inner { padding: 5px }`, so the right padding actually applies. */
#timeline .vis-label .vis-inner {
display: inline-block; display: inline-block;
box-sizing: border-box;
min-width: var(--label-w); min-width: var(--label-w);
max-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; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle;
} }
@ -789,7 +519,6 @@ html.role-pending .manager-only { display: none !important; }
.vis-panel.vis-center, .vis-panel.vis-left, .vis-panel.vis-right, .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-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 { 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-minor { border-color: var(--grid-line); }
.vis-time-axis .vis-grid.vis-major { border-color: var(--border-strong); } .vis-time-axis .vis-grid.vis-major { border-color: var(--border-strong); }
.vis-labelset .vis-label, .vis-labelset .vis-label,
@ -797,18 +526,15 @@ html.role-pending .manager-only { display: none !important; }
.vis-current-time { background-color: var(--today); } .vis-current-time { background-color: var(--today); }
/* Column resize handle */ /* Column resize handle */
/* Matches #split-resizer: a 6px solid bar that turns accent on hover/drag, so
both dividers look identical. */
#col-resizer { #col-resizer {
position: absolute; top: 0; bottom: 0; width: 6px; margin-left: -3px; position: absolute; top: 0; bottom: 0; width: 9px; margin-left: -4px;
background: var(--border); cursor: col-resize; z-index: 25; display: none; cursor: col-resize; z-index: 25; display: none;
transition: background var(--transition);
} }
#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 */ /* Hovered-month column highlight */
.vis-item.vis-background.month-hover-bg { background-color: var(--month-hover); } .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). /* 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. */ 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 // Manager tier: show the rich tooltip (title, task №, status, manager, Planfix
// hint). Anonymous tier gets a minimal tooltip with only the date range. // hint). Anonymous tier gets a minimal tooltip with only the date range.
managerView: boolean; managerView: boolean;
// Which info elements the first column shows (toggled in the column header).
showCity: boolean;
showAddress: boolean;
showCode: boolean;
} }
export interface TimelineElements { export interface TimelineElements {
@ -28,46 +24,24 @@ export interface TimelineElements {
colResizer: HTMLElement; colResizer: HTMLElement;
} }
// First-column label: city · address · code (only the parts enabled in the
// header), separated by a small dot. Falls back to the address so a row is never
// empty (the header also forbids turning every part off).
function groupContent(b: Board, flags: RenderFlags): string {
const dot = '<span class="ci-dot" aria-hidden="true"></span>';
const parts: string[] = [];
if (flags.showCity && b.board_city) parts.push(`<span class="ci-city">${escapeHtml(b.board_city)}</span>`);
if (flags.showAddress && b.board_address) parts.push(`<span class="ci-addr">${escapeHtml(b.board_address)}</span>`);
if (flags.showCode && b.board_id) parts.push(`<span class="ci-code">${escapeHtml(b.board_id)}</span>`);
if (!parts.length) parts.push(`<span class="ci-addr">${escapeHtml(b.board_address)}</span>`);
return `<span class="ci-cell">${parts.join(dot)}</span>`;
}
// Clicking a bar opens the corresponding Planfix task. // Clicking a bar opens the corresponding Planfix task.
const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/'; const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/';
export const zoomSteps: ReadonlyArray<{ days: number; label: string }> = [ export const zoomSteps: ReadonlyArray<{ days: number; label: string }> = [
{ days: 183, label: '6М' }, { days: 91, label: '3 месяца' },
{ days: 365, label: '12М' }, { days: 183, label: '6 месяцев' },
{ days: 548, label: '18М' }, { days: 274, label: '9 месяцев' },
{ days: 731, label: '24М' }, { days: 365, label: '12 месяцев' },
{ days: 913, label: '30М' }, { 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 MONTH_BG_ID = '__month_hover_bg';
const CUR_MONTH_BG_ID = '__cur_month_bg'; const MIN_LABEL_W = 140;
const MIN_LABEL_W = 200; // fits the three column-info pills (~188px) without clipping
// Uniform 3-letter month labels (moment's ru `MMM` mixes full/abbreviated with
// dots — "март", "май", "нояб.", "дек."). scale is 'month' at every zoom step.
const MONTHS_SHORT = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
function axisMinorLabel(date: unknown, scale: string): string {
const d = new Date((date as { valueOf(): number }).valueOf());
if (scale === 'month') return MONTHS_SHORT[d.getMonth()]!;
if (scale === 'year') return String(d.getFullYear());
return String(d.getDate());
}
function axisMajorLabel(date: unknown): string {
return String(new Date((date as { valueOf(): number }).valueOf()).getFullYear());
}
const MAX_LABEL_W = 760; const MAX_LABEL_W = 760;
// "2026-06-01" -> "01.06.2026" // "2026-06-01" -> "01.06.2026"
@ -98,9 +72,6 @@ export interface TimelineView {
render(boards: Board[], bookings: Booking[], fit: boolean, flags: RenderFlags): void; render(boards: Board[], bookings: Booking[], fit: boolean, flags: RenderFlags): void;
/** Re-run the last render (e.g. after colours changed). */ /** Re-run the last render (e.g. after colours changed). */
refresh(): void; 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. */ /** Re-apply row height / centring from appearance and redraw. */
applyLayout(): void; applyLayout(): void;
setScale(days: number): void; setScale(days: number): void;
@ -113,7 +84,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
const itemsDS = new DataSet<any>(); const itemsDS = new DataSet<any>();
let lastBoards: Board[] = []; let lastBoards: Board[] = [];
let lastBookings: Booking[] = []; 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; let onBoardClick: ((boardId: string) => void) | null = null;
const options: TimelineOptions = { const options: TimelineOptions = {
@ -130,7 +101,6 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
orientation: { axis: 'top' }, orientation: { axis: 'top' },
tooltip: { followMouse: false, delay: 0 }, tooltip: { followMouse: false, delay: 0 },
locale: 'ru', locale: 'ru',
format: { minorLabels: axisMinorLabel, majorLabels: axisMajorLabel },
xss: { disabled: true }, xss: { disabled: true },
}; };
@ -266,22 +236,6 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
timeline.on('changed', positionResizer); timeline.on('changed', positionResizer);
window.addEventListener('resize', 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 colDragging = false;
let colDragStartX = 0; let colDragStartX = 0;
let colDragStartW = 0; let colDragStartW = 0;
@ -416,7 +370,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
const groups = boards.map((b, idx) => ({ const groups = boards.map((b, idx) => ({
id: b.board_id, id: b.board_id,
content: groupContent(b, flags), content: escapeHtml(b.board_address) + ' (' + escapeHtml(b.board_id) + ')',
order: idx, order: idx,
className: idx % 2 === 0 ? 'row-even' : 'row-odd', className: idx % 2 === 0 ? 'row-even' : 'row-odd',
})); }));
@ -489,16 +443,6 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
groupsDS.add(groups); groupsDS.add(groups);
itemsDS.clear(); itemsDS.clear();
itemsDS.add(items); 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; lastMonthKey = null;
if (fit) timeline.fit({ animation: false }); if (fit) timeline.fit({ animation: false });
@ -510,14 +454,6 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
if (lastBoards.length) render(lastBoards, lastBookings, false, lastFlags); 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 { function applyLayout(): void {
timeline.setOptions({ margin: { item: { horizontal: 2, vertical: 0 } } }); timeline.setOptions({ margin: { item: { horizontal: 2, vertical: 0 } } });
timeline.redraw(); timeline.redraw();
@ -534,7 +470,6 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
return { return {
render, render,
refresh, refresh,
refreshLabels,
applyLayout, applyLayout,
setScale, setScale,
setOnBoardClick(fn: (boardId: string) => void): void { onBoardClick = fn; }, setOnBoardClick(fn: (boardId: string) => void): void { onBoardClick = fn; },

View File

@ -66,8 +66,6 @@ export interface MapSurface {
lon: number | null; lon: number | null;
occupied_now: boolean; occupied_now: boolean;
bookings: MapBooking[]; 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). */ /** 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 + '%'); 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 { function apply(): void {
els.view.classList.remove('mode-timeline', 'mode-map', 'mode-split'); els.view.classList.remove('mode-timeline', 'mode-map', 'mode-split');
els.view.classList.add('mode-' + mode); els.view.classList.add('mode-' + mode);
els.btnTimeline.classList.toggle('active', mode === 'timeline'); els.btnTimeline.classList.toggle('active', mode === 'timeline');
els.btnMap.classList.toggle('active', mode === 'map'); els.btnMap.classList.toggle('active', mode === 'map');
els.btnSplit.classList.toggle('active', mode === 'split'); els.btnSplit.classList.toggle('active', mode === 'split');
positionIndicator();
} }
window.addEventListener('resize', positionIndicator);
function setMode(next: ViewMode): void { function setMode(next: ViewMode): void {
mode = next; mode = next;
@ -105,7 +95,6 @@ export function createViewModes(els: ViewModeElements, cb: ViewModeCallbacks): V
const saved = localStorage.getItem(LS_MODE) as ViewMode | null; const saved = localStorage.getItem(LS_MODE) as ViewMode | null;
mode = saved === 'map' || saved === 'split' ? saved : 'timeline'; mode = saved === 'map' || saved === 'split' ? saved : 'timeline';
apply(); apply();
requestAnimationFrame(positionIndicator); // header is laid out by the next frame
return { return {
getMode: () => mode, getMode: () => mode,

39
main.py
View File

@ -1,7 +1,5 @@
import asyncio import asyncio
import os import os
import time
from urllib.parse import quote
import httpx import httpx
from fastapi import Depends, FastAPI, Header, Query from fastapi import Depends, FastAPI, Header, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@ -307,39 +305,6 @@ async def bookings(role: str = Depends(get_role), city: str = "", dimension: str
return rows 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") @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 = ""): async def map_data(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = ""):
if role != "manager": 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) by_key.setdefault(b["board_key"], []).append(b)
anon = role != "manager" anon = role != "manager"
photos = photo_index()
out = [] out = []
for s in surfaces: for s in surfaces:
active = by_key.get(s["board_key"], []) 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 # Truncated tier keeps the free/occupied marker colour but hides the
# balloon's booking details (company/brand/status). # balloon's booking details (company/brand/status).
"bookings": [] if anon else active, "bookings": [] if anon else active,
# Surface photos are public inventory — shown to everyone.
"photos": photos.get(str(s["board_key"]), []),
}) })
return out return out
app.mount("/photos", StaticFiles(directory=PHOTOS_DIR, check_dir=False), name="photos")
app.mount("/", StaticFiles(directory="static", html=True), name="static") app.mount("/", StaticFiles(directory="static", html=True), name="static")