Compare commits

...

13 Commits

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 05:33:30 +00:00
aaverbitskiy
17b70b1f44 Колонка Город/Адрес/Код + оформление шкалы времени
1) Первая колонка графика: в углу — кнопки «Показывать» Город/Адрес/Код
   (стиль пилюль, активная — фиолетовая). Строка собирается из включённых
   сущностей, разделённых кружком 7px; код без скобок; адрес тёмный, город/код
   приглушённые. «Адрес» (или последний активный) нельзя снять — строка не пустеет.
   Доступно всем ролям, выбор в localStorage. Лёгкое обновление подписей
   (view.refreshLabels — без пересборки баров).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 08:29:20 +00:00
aaverbitskiy
77a36a22d9 Датапикер: открытый диапазон через пилюли-концы «С» / «По»
Над календарём — две пилюли-конца в стиле чипсов фильтров (accent-weak
заливка, индиго-текст, скруглённые). Активная пилюля (куда пишется следующий
клик) с индиго-рамкой; клик по дню пишет в активный конец, фокус авто-переходит
ко второму. У каждой пилюли × — очистить именно этот конец, что даёт ОТКРЫТЫЙ
диапазон: «с X» (без конца) или «по Y» (без начала). «Применить» активна, если
задан хотя бы один конец. Нативные #date-start/#date-end по-прежнему источник
истины, любой из них может быть пустым; фильтр и URL уже читают from/to по
отдельности.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 08:14:45 +00:00
aaverbitskiy
5ac2142c70 Датапикер: дни диапазона — отдельные кружки одного размера (27px)
Промежуточные дни диапазона были сплошной заливкой-пилюлей; теперь каждый — отдельный светлый кружок 27px, вровень с акцентными кружками концов (тоже 27px). Убраны соединяющие полу-градиенты у концов.

Задеплоено на прод поверх v0.2.15 без нового тега.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 05:40:41 +00:00
aaverbitskiy
5902c1e7f0 Микрофикс: убран grid-column span 2 у .field-dates
После унификации полей до 195px «Период» больше не занимает 2 колонки — span 2 оставлял пустоту справа и криво переносил поля (у анонима «Поиск» уезжал на вторую строку). Теперь все поля — обычные колонки 195px, ряд заполняется слева направо.

Задеплоено прямо на прод поверх v0.2.15 без нового тега.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 05:25:06 +00:00
aaverbitskiy
147529c20f Унификация ширины полей-фильтров: все 195px, короткий год в «Период»
- .hgroup-fields: колонки 170→195px (все фильтры одинаковой ширины)
- «Период» (.dr-field) 333→195px, вровень с остальными
- Формат даты в поле «Период» — 2-значный год (05.08.26 — 20.08.26), чтобы диапазон целиком помещался в 195px; в чипсах год остался полным

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 05:13:35 +00:00
aaverbitskiy
5ba5a77f97 UI-доработки: выравнивание тогглов, датапикер с «Применить», степперы 80px, поле поиска, автозакрытие балуна карты
- Тогглы: Бренд+Компания в одну строку, блок выровнен ровно по полосе контролов (верх = верх полей, низ = низ «Поиска»), равномерные зазоры
- Датапикер: выбор диапазона больше не применяется автоматически по второму клику — только кнопкой «Применить» (+ «Сбросить» для незавершённого выбора)
- Степперы «Оформления»: ровно 80px по ширине
- Поле поиска в дропдаунах: равномерная рамка 1px по всем сторонам вместо разноцветных краёв
- Балун на карте: клик по пустой карте закрывает балун (guard от самозакрытия по «своему» клику, клик по другому кластеру открывает новый)
- Полоса фильтров: поля сужены на 10px, «Период» до 333px

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 20:39:12 +00:00
aaverbitskiy
e9eee60dab ui: toasts, icon segmented mode switch, steppers, date-range picker (v0.2.13)
Second modernization batch (reference: easystudy ui_elements):
- Toast/snackbar notifications (dark, green check) for actions — copy link.
- Mode switch: icons (horizontal bars / pin / columns) + a sliding active
  indicator; fixed width so the bold active label never resizes the block.
- Appearance number fields became steppers: an editable field with centred −/+
  buttons, ~20% narrower.
- Date filter: the two native date inputs are replaced by a custom range picker
  — one field "Период (даты)" + a calendar popup with month nav and range
  selection (circle endpoints, light band between). The native inputs stay hidden
  as the source of truth so all filter/URL/chip wiring is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 19:02:45 +00:00
aaverbitskiy
f5166b888a ui: toggles, stat tiles, dropdown search, bubble scale slider (v0.2.12)
Modernised several controls (reference: easystudy ui_elements catalogue):
- Display checkboxes (Бренд/Компания/Коллизии/Все поверхности) are now toggle
  switches; the option checkboxes inside filter dropdowns stay normal.
- Counters became stat tiles: header "Поверхности N" (accent) and the map
  legend (свободно/занято/всего + dashed "без координат").
- Filter dropdowns gained an in-panel search that filters the option list
  (shown for lists >7, auto-focused on open).
- Timeline scale control restyled as a filled slider with a value bubble, moved
  out of the header to float over the top-right of the chart just below the time
  axis (5px gap), bubble pointing up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 14:46:04 +00:00
aaverbitskiy
d7c1ce7b77 ui: fresh panel per cluster click, 5-step scale, unified column resizer (v0.2.11)
- Map panel: every marker/cluster click reopens a fresh panel repositioned next
  to the just-clicked cluster (with a pop-in), instead of leaving it in place and
  only swapping the content.
- Timeline scale slider: 5 fixed stops — 6/12/18/24/30 months; label is now
  "Масштаб графика: <6М|12М|18М|24М|30М>" (default 12М).
- Address-column resizer now matches the timeline/map splitter: a 6px solid bar
  that turns accent on hover/drag, so both dividers look identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 13:40:10 +00:00
10 changed files with 842 additions and 112 deletions

View File

@ -40,16 +40,17 @@
<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"></div> <div id="status" class="stat-tile stat-accent"><span class="st-l">Поверхности</span><span class="st-v"></span></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">
<button type="button" class="mode-btn active" id="mode-timeline">График</button> <div class="mode-ind" aria-hidden="true"></div>
<button type="button" class="mode-btn" id="mode-map">Карта</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-split">Карта и график</button> <button type="button" class="mode-btn" id="mode-map"><svg class="mode-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 21s-7-6.3-7-11a7 7 0 0 1 14 0c0 4.7-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/></svg>Карта</button>
<button type="button" class="mode-btn" id="mode-split"><svg class="mode-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="7" height="16" rx="1"/><rect x="14" y="4" width="7" height="16" rx="1"/></svg>Карта и график</button>
</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>
@ -96,13 +97,18 @@
<div class="dropdown-panel" id="manager-panel"></div> <div class="dropdown-panel" id="manager-panel"></div>
</div> </div>
</div> </div>
<div class="field"> <div class="field field-dates">
<label for="date-start">Дата начала (от)</label> <label>Период</label>
<input type="date" id="date-start" /> <div class="daterange" id="daterange">
</div> <div class="dr-field" id="dr-field" tabindex="0" role="button" aria-label="Выбрать период">
<div class="field"> <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>
<label for="date-end">Дата окончания (до)</label> <span class="dr-text" id="dr-text">Период</span>
<input type="date" id="date-end" /> <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>
<div class="field manager-only"> <div class="field manager-only">
<label>Статус</label> <label>Статус</label>
@ -120,14 +126,8 @@
</div> </div>
</div> </div>
<!-- Group 3: scale + appearance + checkboxes (compact stack) --> <!-- Group 3: display toggles (the scale control now floats over the timeline) -->
<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,6 +164,22 @@
<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>
@ -177,10 +193,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">
<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-free"></span>свободно</span><span class="st-v st-free" id="lg-free">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-busy"></span>занято</span><span class="st-v st-busy" id="lg-busy">0</span></div>
<span class="lg-item" id="map-counter"></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>
<button type="button" class="lg-nocoords" id="map-nocoords-btn" style="display:none"></button> <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>
</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": "0.2.10", "version": "1.0.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -98,21 +98,53 @@ 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 {
const row = document.createElement('label'); // A stepper: /+ buttons around an editable number field (type a value directly).
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(Math.max(min, Math.min(max, v))); if (!Number.isNaN(v)) onInput(clamp(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(input); row.appendChild(stepper);
return row; return row;
} }

215
frontend/src/datepicker.ts Normal file
View File

@ -0,0 +1,215 @@
// 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,6 +42,30 @@ 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');
@ -64,11 +88,6 @@ 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');
@ -83,6 +102,7 @@ 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);
} }
} }
@ -93,6 +113,14 @@ 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,6 +14,7 @@ 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, statusLabel } from './appearance';
import { createMapView } from './map'; import { createMapView } from './map';
@ -34,6 +35,8 @@ 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,
@ -113,6 +116,7 @@ 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,
@ -132,17 +136,32 @@ 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 }; return { withBrand: false, withCompany: false, withCollisions: false, managerView: false, ...cols };
} }
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,
}; };
} }
@ -230,6 +249,32 @@ 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();
@ -288,11 +333,8 @@ function renderChips(): void {
share.addEventListener('click', () => { share.addEventListener('click', () => {
navigator.clipboard navigator.clipboard
?.writeText(location.href) ?.writeText(location.href)
.then(() => { .then(() => showToast('Ссылка скопирована'))
share.textContent = 'Ссылка скопирована'; .catch(() => showToast('Не удалось скопировать'));
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');
@ -324,7 +366,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.textContent = 'Загрузка…'; el.status.innerHTML = '<span class="st-l">Поверхности</span><span class="st-v">…</span>';
loadStart(); loadStart();
try { try {
const f = currentFilters(); const f = currentFilters();
@ -338,9 +380,10 @@ 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.textContent = `Поверхности: ${boards.length}`; el.status.innerHTML = `<span class="st-l">Поверхности</span><span class="st-v">${boards.length}</span>`;
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();
@ -377,10 +420,25 @@ 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') mapView.closePanel(); // map hidden — drop its floating card 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(
@ -422,11 +480,55 @@ 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.
const ciButtons = Array.from(document.querySelectorAll<HTMLElement>('.ci-toggle'));
function syncInfoButtons(): void {
for (const btn of ciButtons) btn.classList.toggle('is-on', infoCols[btn.dataset.part as keyof InfoCols]);
}
for (const btn of ciButtons) {
btn.addEventListener('click', () => {
const part = btn.dataset.part as keyof InfoCols;
// Never leave the row empty: can't turn off the last remaining part.
if (infoCols[part] && !(['city', 'address', 'code'] as (keyof InfoCols)[]).some((k) => k !== part && infoCols[k])) return;
infoCols[part] = !infoCols[part];
localStorage.setItem(CI_LS, JSON.stringify(infoCols));
syncInfoButtons();
view.refreshLabels(flags());
});
}
syncInfoButtons();
// Clicking a surface in the timeline's left column flies the map to it: switch to // Clicking a surface in the timeline's left column flies the map to it: switch to
// the combined "Карта и график" view if only the timeline is open, then focus the // the combined "Карта и график" view if only the timeline is open, then focus the
@ -454,10 +556,12 @@ async function init(): Promise<void> {
rebuildDecorMenu(); rebuildDecorMenu();
view.applyLayout(); view.applyLayout();
await loadData(true); await loadData(true);
const defaultZoomIdx = 3; const defaultZoomIdx = 1; // 12М
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

@ -63,6 +63,9 @@ export interface MapView {
ensureInit(): Promise<void>; ensureInit(): Promise<void>;
invalidateSize(): void; invalidateSize(): void;
closePanel(): 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. */ /** Pan + zoom the map to the surface's location (its cluster). No-op if it has no coords. */
focusBoard(boardId: string): void; focusBoard(boardId: string): void;
} }
@ -213,6 +216,10 @@ function setupBalloonPhotos(): void {
interface Panel { el: HTMLElement; title: HTMLElement; tabs: HTMLElement; body: HTMLElement; surfaces: MapSurface[]; } interface Panel { el: HTMLElement; title: HTMLElement; tabs: HTMLElement; body: HTMLElement; surfaces: MapSurface[]; }
interface PanelPlace { x: number; y: number; bounds: DOMRect; } interface PanelPlace { x: number; y: number; bounds: DOMRect; }
let mpanel: Panel | null = null; 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 // 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 // half of the map the cluster sits in decides the direction (e.g. bottom-left
@ -301,22 +308,23 @@ function ensureMapPanel(): Panel {
function showMapPanel(surfaces: MapSurface[], idx: number, place: PanelPlace): void { function showMapPanel(surfaces: MapSurface[], idx: number, place: PanelPlace): void {
const p = ensureMapPanel(); const p = ensureMapPanel();
const firstOpen = p.el.style.display === 'none';
p.surfaces = surfaces; p.surfaces = surfaces;
if (firstOpen) { // Every click is a fresh panel: reset size, widen when there are many surfaces
// Fresh open: reset size. Widen when there are many surfaces so the tab list // (tabs flow into more columns → shorter panel), then reposition next to the
// flows into more columns (a shorter panel, less scrolling). // just-clicked cluster. A new click never reuses the previous position.
p.el.style.height = ''; p.el.style.height = '';
p.el.style.maxHeight = ''; p.el.style.maxHeight = '';
const n = surfaces.length; const n = surfaces.length;
const cols = n <= 8 ? 2 : n <= 24 ? 3 : 4; 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.width = cols > 2 ? Math.min(320 + (cols - 2) * 150, Math.round(window.innerWidth * 0.92)) + 'px' : '';
}
p.el.style.display = ''; p.el.style.display = '';
renderPanel(idx); renderPanel(idx);
// Position only on a fresh open (measure the now-rendered size first, so we can positionPanel(p.el, place); // measures the rendered size, then offsets to the free side
// offset it toward the free side); keep position/size while it stays open. // Restart the pop-in animation so each click reads as a new panel appearing.
if (firstOpen) positionPanel(p.el, place); p.el.style.animation = 'none';
void p.el.offsetHeight;
p.el.style.animation = '';
panelOpenedAt = Date.now();
} }
function closeMapPanel(): void { function closeMapPanel(): void {
@ -476,17 +484,19 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
objectManager.removeAll(); objectManager.removeAll();
objectManager.add({ type: 'FeatureCollection', features }); objectManager.add({ type: 'FeatureCollection', features });
// Live legend counts (react to the current filter). // Live legend counts (react to the current filter) — values only; the labels
if (legendFree) legendFree.textContent = `свободно: ${free}`; // are static in the tile markup.
if (legendBusy) legendBusy.textContent = `занято: ${busy}`; if (legendFree) legendFree.textContent = String(free);
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 = `всего: ${surfaces.length}`; els.counter.textContent = String(surfaces.length);
// Actionable "no coordinates" badge + list — manager-only. // Actionable "no coordinates" tile + list — manager-only.
if (noCoordsBtn) { if (noCoordsBtn) {
noCoordsBtn.style.display = managerView && noCoordsList.length ? '' : 'none'; noCoordsBtn.style.display = managerView && noCoordsList.length ? '' : 'none';
noCoordsBtn.textContent = `без координат: ${noCoordsList.length}`; const ncVal = noCoordsBtn.querySelector<HTMLElement>('.st-v');
if (ncVal) ncVal.textContent = String(noCoordsList.length);
} }
if (noCoordsPanel) { if (noCoordsPanel) {
noCoordsPanel.innerHTML = renderNoCoords(noCoordsList); noCoordsPanel.innerHTML = renderNoCoords(noCoordsList);
@ -606,6 +616,12 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
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);
if (list.length) showMapPanel(list, 0, clusterPlace(mx, my)); 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);
@ -620,6 +636,20 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
closePanel(): void { closePanel(): void {
closeMapPanel(); 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 { focusBoard(boardId: string): void {
pendingFocus = boardId; pendingFocus = boardId;
applyFocus(); // applies now if already rendered, else waits for the next draw applyFocus(); // applies now if already rendered, else waits for the next draw

View File

@ -35,6 +35,7 @@
--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);
@ -60,6 +61,7 @@
--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;
} }
@ -107,12 +109,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, minmax(130px, 1fr)); grid-template-columns: repeat(4, 195px);
gap: 12px 14px; gap: 12px 14px;
align-items: start; align-items: start;
align-content: start; align-content: start;
} }
/* Compact grid: 4 dropdowns on row 1; the two dates + search fall to row 2. */ /* Compact grid: equal 195px columns; fields flow left-to-right and wrap. */
.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,
@ -121,6 +123,61 @@ 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],
@ -161,7 +218,10 @@ 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; }
.checks { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; } /* Toggles: aligned to the filter *controls* (not the labels) Бренд + Компания
share the top row, then Коллизии and Все поверхности, evenly distributed
between the top of the first control box and the bottom of Поиск. */
.checks { display: flex; flex-direction: column; align-items: flex-start; justify-content: space-between; flex: 1; align-self: stretch; padding: 19px 0 0; }
.checks .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; }
@ -179,12 +239,7 @@ input::placeholder { color: var(--text-muted); }
} }
/* Counters — compact inset inside the brand block. */ /* Counters — compact inset inside the brand block. */
.brand #status { .brand #status { margin-top: auto; }
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; }
@ -193,11 +248,104 @@ 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;
} }
.checkbox-field input[type=checkbox] { cursor: pointer; accent-color: var(--accent); } /* Toggle switch: the checkbox keeps its semantics/JS, only the look changes.
Scoped to .checkbox-field so the dropdown option checkboxes stay normal. */
.checkbox-field input[type=checkbox] {
appearance: none; -webkit-appearance: none; margin: 0; flex: 0 0 auto;
width: 34px; height: 20px; border-radius: 20px; background: #cbd0d8;
position: relative; cursor: pointer; transition: background var(--transition);
}
.checkbox-field input[type=checkbox]::after {
content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px;
border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .25);
transition: left var(--transition);
}
.checkbox-field input[type=checkbox]:checked { background: var(--accent); }
.checkbox-field input[type=checkbox]:checked::after { left: 16px; }
.checkbox-field input[type=checkbox]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.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; }
@ -215,7 +363,12 @@ 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;
min-width: 220px; max-height: 280px; overflow-y: auto; z-index: 30; /* Fit the widest option instead of a fixed width, capped at 350px wider
content then scrolls horizontally. The .dp-search fills via fill-available
(not width:100%, whose % included the scrollbar area clipped border +
phantom horizontal scrollbar). */
width: max-content; min-width: 220px; max-width: 350px;
max-height: 280px; overflow: auto; z-index: 30;
} }
.dropdown.open .dropdown-panel { display: block; } .dropdown.open .dropdown-panel { display: block; }
.dropdown-panel label { .dropdown-panel label {
@ -233,6 +386,23 @@ 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. */
@ -279,6 +449,24 @@ 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;
@ -302,18 +490,42 @@ 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; background: var(--surface-2); border: none; position: relative;
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 13px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%; padding: 6px 12px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%;
border-radius: var(--radius-sm); transition: background var(--transition), color var(--transition); border-radius: var(--radius-sm); transition: color var(--transition);
flex: 1; display: flex; align-items: center; /* even heights, vertically centred */ flex: 1; display: flex; align-items: center; gap: 8px; /* icon + label */
} }
.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 { background: var(--bg); color: var(--accent); font-weight: 600; box-shadow: var(--shadow-sm); } .mode-btn.active { color: var(--accent); font-weight: 600; }
/* ---- toast notifications (bottom-centre, transient) ---- */
.toasts {
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); z-index: 200;
display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none;
}
.toast {
display: flex; align-items: center; gap: 9px;
background: var(--tip-bg); color: var(--tip-text); font-size: 13px;
padding: 9px 15px; border-radius: 9px; box-shadow: var(--shadow-md);
opacity: 0; transform: translateY(8px);
transition: opacity .22s var(--ease-3), transform .22s var(--ease-3);
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast-ico { color: #37b24d; display: inline-flex; }
/* Theme toggle */ /* Theme toggle */
.theme-toggle { .theme-toggle {
@ -378,26 +590,34 @@ 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;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); display: flex; flex-direction: row; flex-wrap: wrap; gap: 6px;
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 .lg-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; font-size: 12px; } #map-legend .stat-tile { box-shadow: var(--shadow-md); }
#map-legend #map-counter { padding-left: 18px; } /* align "всего" with the dotted rows */ #map-legend .lg-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
#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; }
/* Actionable "no coordinates" badge inside the legend. */ #map-legend .lg-total { background: var(--border-strong); }
.lg-nocoords { /* Actionable "no coordinates" tile — dashed accent, clickable. */
border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); .st-nc { cursor: pointer; background: #fbf6e9; border: 1px dashed #d7c08a; padding: 4px 10px; text-align: left;
color: var(--text-muted); font-size: 12px; padding: 2px 8px; cursor: pointer; white-space: nowrap; transition: border-color var(--transition); }
transition: border-color var(--transition), color var(--transition); .st-nc .lg-dot { background: #e8a317; }
} .st-nc .st-v { color: #b07d16; }
.lg-nocoords:hover { border-color: var(--border-strong); color: var(--text); } .st-nc:hover { border-color: #b07d16; }
/* 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;
@ -460,6 +680,7 @@ html.role-pending .manager-only { display: none !important; }
background: var(--bg); border: 1px solid var(--border); background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-md); border-radius: var(--radius); box-shadow: var(--shadow-md);
font-size: 13px; color: var(--text); font-size: 13px; color: var(--text);
animation: pop-in .13s var(--ease-3);
} }
.mpanel-head { .mpanel-head {
position: sticky; top: 0; z-index: 1; background: var(--bg); position: sticky; top: 0; z-index: 1; background: var(--bg);
@ -552,10 +773,14 @@ 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 */
.vis-label .vis-inner { /* #timeline lifts specificity above vis-timeline's own `.vis-labelset .vis-label
.vis-inner { padding: 5px }`, so the right padding actually applies. */
#timeline .vis-label .vis-inner {
display: inline-block; 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;
} }
@ -564,6 +789,7 @@ html.role-pending .manager-only { display: none !important; }
.vis-panel.vis-center, .vis-panel.vis-left, .vis-panel.vis-right, .vis-panel.vis-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,
@ -571,15 +797,18 @@ 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: 9px; margin-left: -4px; position: absolute; top: 0; bottom: 0; width: 6px; margin-left: -3px;
cursor: col-resize; z-index: 25; display: none; background: var(--border); cursor: col-resize; z-index: 25; display: none;
transition: background var(--transition);
} }
#col-resizer::after { content: ""; position: absolute; top: 0; bottom: 0; left: 4px; width: 1px; background: var(--border); } #col-resizer:hover, #col-resizer.dragging { background: var(--accent); }
#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,6 +14,10 @@ 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 {
@ -24,24 +28,46 @@ 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: 91, label: '3 месяца' }, { days: 183, label: '6М' },
{ days: 183, label: '6 месяцев' }, { days: 365, label: '12М' },
{ days: 274, label: '9 месяцев' }, { days: 548, label: '18М' },
{ days: 365, label: '12 месяцев' }, { days: 731, label: '24М' },
{ days: 457, label: '15 месяцев' }, { days: 913, label: '30М' },
{ 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 MIN_LABEL_W = 140; const CUR_MONTH_BG_ID = '__cur_month_bg';
const MIN_LABEL_W = 200; // fits the three column-info pills (~188px) without clipping
// Uniform 3-letter month labels (moment's ru `MMM` mixes full/abbreviated with
// dots — "март", "май", "нояб.", "дек."). scale is 'month' at every zoom step.
const MONTHS_SHORT = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
function axisMinorLabel(date: unknown, scale: string): string {
const d = new Date((date as { valueOf(): number }).valueOf());
if (scale === 'month') return MONTHS_SHORT[d.getMonth()]!;
if (scale === 'year') return String(d.getFullYear());
return String(d.getDate());
}
function axisMajorLabel(date: unknown): string {
return String(new Date((date as { valueOf(): number }).valueOf()).getFullYear());
}
const MAX_LABEL_W = 760; const MAX_LABEL_W = 760;
// "2026-06-01" -> "01.06.2026" // "2026-06-01" -> "01.06.2026"
@ -72,6 +98,9 @@ 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;
@ -84,7 +113,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 }; let lastFlags: RenderFlags = { withBrand: true, withCompany: false, withCollisions: true, managerView: true, showCity: false, showAddress: true, showCode: true };
let onBoardClick: ((boardId: string) => void) | null = null; let onBoardClick: ((boardId: string) => void) | null = null;
const options: TimelineOptions = { const options: TimelineOptions = {
@ -101,6 +130,7 @@ 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 },
}; };
@ -236,6 +266,22 @@ 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;
@ -370,7 +416,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: escapeHtml(b.board_address) + ' (' + escapeHtml(b.board_id) + ')', content: groupContent(b, flags),
order: idx, order: idx,
className: idx % 2 === 0 ? 'row-even' : 'row-odd', className: idx % 2 === 0 ? 'row-even' : 'row-odd',
})); }));
@ -443,6 +489,16 @@ 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 });
@ -454,6 +510,14 @@ 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();
@ -470,6 +534,7 @@ 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

@ -37,13 +37,23 @@ 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;
@ -95,6 +105,7 @@ 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,