Compare commits

..

23 Commits
v0.2.0 ... main

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
aaverbitskiy
f4c1de70b4 feat: timeline label -> fly map to surface; keep panel inside the window (v0.2.10)
Timeline: clicking a surface in the left column now flies the map to it — it
switches to the combined "Карта и график" view when only the timeline is open,
then centres+zooms on the surface's cluster (deferred focus waits for the map to
render; works for anonymous and manager users). Replaces the old manager-only
board detail panel on that click (the map card already shows photos + bookings).

Map panel: for clusters with many surfaces it now widens (tabs flow into 3-4
columns) and its height is capped to the space available in the window, so it
never overflows the browser window and scrolls inside instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 09:59:52 +00:00
aaverbitskiy
78d62ae36c ui: render lone surfaces as donut clusters of 1 for a unified map (v0.2.9)
Set minClusterSize:1 so a solitary surface is drawn with the same donut icon
(showing "1", green when free / red when busy) instead of a small dot, and every
click flows through the single cluster handler into our panel. A cluster of one
keeps the rich single-surface hover tooltip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 09:04:27 +00:00
aaverbitskiy
e83bb5ad49 feat: custom map surface panel (fits content, resizable, smart-positioned) (v0.2.8)
Replace the Yandex balloon with our own floating card, opened on marker/cluster
click. It fits its content (no forced scroll), is draggable by the header and
resizable from the bottom-right corner, and — for clusters — carries tabs to
switch between the co-located surfaces. Photo gallery + lightbox are preserved.

On open it positions itself next to the clicked cluster and offsets toward the
free side of the map (the map half the cluster sits in picks the direction, e.g.
bottom-left cluster -> panel opens up and to the right), clamped inside the map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 08:40:48 +00:00
aaverbitskiy
8e6c19e9a4 feat: surface photos in the map balloon + fullscreen lightbox (v0.2.7)
Photos live on disk as photos/<board_key>/<file>.{jpg,jpeg,png} (a read-only
volume). A cached directory index (60s TTL) attaches a `photos` URL list to each
surface in /api/map; a /photos StaticFiles mount serves the files. Photos are
public — shown to anonymous visitors and managers alike.

The balloon renders a gallery (main image + thumbnail strip + "1 / N" counter);
clicking a thumbnail swaps the main image, and clicking the main image / "+N"
opens a fullscreen lightbox over the map (prev/next, Esc, backdrop close).
Surfaces without photos render exactly as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 08:00:10 +00:00
aaverbitskiy
dec639581b ui: green "Размещено" default + green circle favicon (v0.2.6)
- default colour for status "РК. Размещено" is now green #26c04c (was indigo)
- add a #26c04c circle favicon (inline SVG data URI) for the browser tab

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 20:14:01 +00:00
aaverbitskiy
de4cf1743b polish: flip overflowing bar label to the left when it would hit the next bar (v0.2.5)
Extends the bar-label overflow handling: an oversized label defaults to the
right of the bar, but if it would collide with the next bar on the same row it
is placed to the left instead (whichever side has room; larger gap wins when
neither fits). Also make the trigger a debounce (was a throttle) so the layout
runs after a zoom/redraw burst settles, once bar widths are final.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 19:31:26 +00:00
aaverbitskiy
9b6ca6f848 polish: appearance menu offsets, anon map badge, bar label overflow (v0.2.4)
- appearance menu opens 20px below the header/chips and 58px from the right edge
- map "no coordinates" badge is manager-only (hidden for anonymous visitors)
- timeline bar label that is wider than its bar now renders just past the bar's
  right edge as dark text on the track, instead of being clipped; labels that
  fit still sit inside the bar in white. Applied post-render (measure overflow),
  re-run on changed/rangechanged with a double rAF so bar widths are settled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 19:09:47 +00:00
aaverbitskiy
159590d62e analytics: add Yandex.Metrika counter 111613184 (v0.2.3)
Counter snippet at the top of <body> (webvisor, clickmap, trackLinks).
Lives in index.html only — no change to the JS/CSS bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 18:32:11 +00:00
aaverbitskiy
b4d56d0540 polish: chips bar + appearance menu placement + brand title (v0.2.2)
- appearance menu drops below the active-filter chips row when it is shown,
  and sits 10px further from the right edge
- "Скопировать ссылку" moved to the left of the chips, separated by a divider
- enlarge "Green Media" title so it is flush with the "Адресная программа"
  line below it

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 16:00:47 +00:00
aaverbitskiy
aeb4651f6f polish: appearance menu + clean status names everywhere (v0.2.1)
Appearance ("Оформление") menu:
- add missing status "РК. Предварительное бронирование" (8 statuses total)
- strip the systemic "РК. " prefix from status names in the UI (menu,
  tooltip, status filter dropdown, active-filter chips); raw values stay
  intact for filtering/URLs
- fixed-position menu: opens 25px below the header, ends >=30px above the
  window bottom, scrolls inside — never overflows the viewport
- drop the "Прочие статусы" row
- per-status grey reset glyph (circular arrows) restoring its default colour

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 15:37:03 +00:00
14 changed files with 1435 additions and 208 deletions

1
.gitignore vendored
View File

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

View File

@ -9,6 +9,7 @@ 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,6 +3,7 @@
<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
@ -17,6 +18,20 @@
</head> </head>
<body> <body>
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=111613184', 'ym');
ym(111613184, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/111613184" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
<div id="load-bar" aria-hidden="true"></div> <div id="load-bar" aria-hidden="true"></div>
<header> <header>
@ -25,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>
@ -81,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>
@ -105,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">
@ -149,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>
@ -162,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.0", "version": "1.0.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -20,17 +20,23 @@ export interface Appearance {
const LS_KEY = 'mapdash.appearance.v1'; const LS_KEY = 'mapdash.appearance.v1';
/** Sensible out-of-the-box colours for the known statuses. */ /** Sensible out-of-the-box colours for the known statuses (lifecycle order). */
export const DEFAULT_STATUS_COLORS: Record<string, string> = { export const DEFAULT_STATUS_COLORS: Record<string, string> = {
'РК. Новая': '#4f46e5', 'РК. Новая': '#4f46e5',
'РК. Размещено': '#4f46e5', 'РК. Предварительное бронирование': '#4f46e5',
'РК. Макет РИМ согласован': '#4f46e5', 'РК. Макет РИМ согласован': '#4f46e5',
'РК. Размещено': '#26c04c',
'РК. Ожидает монтаж': '#4f46e5', 'РК. Ожидает монтаж': '#4f46e5',
'РК. Ожидает демонтаж': '#4f46e5', 'РК. Ожидает демонтаж': '#4f46e5',
'РК. Ожидает перемонтаж': '#4f46e5', 'РК. Ожидает перемонтаж': '#4f46e5',
'РК. Архив': '#4f46e5', 'РК. Архив': '#4f46e5',
}; };
/** Display name for a status: strip the systemic "РК. " prefix. */
export function statusLabel(status: string): string {
return status.replace(/^РК\.\s+/, '');
}
export function defaultAppearance(): Appearance { export function defaultAppearance(): Appearance {
return { return {
colors: { ...DEFAULT_STATUS_COLORS }, colors: { ...DEFAULT_STATUS_COLORS },
@ -92,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;
} }
@ -124,6 +162,44 @@ function colorRow(labelText: string, value: string, onInput: (v: string) => void
return row; return row;
} }
// Circular two-arrows "reset" glyph (Feather refresh-cw).
const RESET_ICON =
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>';
/**
* A status colour row: clean status name, a grey reset glyph that restores this
* status's default colour, then the colour picker.
*/
function statusColorRow(status: string, value: string, defColor: string, onInput: (v: string) => void): HTMLElement {
const row = document.createElement('div');
row.className = 'decor-row';
const span = document.createElement('span');
span.textContent = statusLabel(status);
const wrap = document.createElement('div');
wrap.className = 'decor-color-wrap';
const reset = document.createElement('button');
reset.type = 'button';
reset.className = 'decor-color-reset';
reset.title = 'Сбросить цвет статуса';
reset.setAttribute('aria-label', 'Сбросить цвет статуса');
reset.innerHTML = RESET_ICON;
const input = document.createElement('input');
input.type = 'color';
input.value = value;
input.addEventListener('input', () => onInput(input.value));
reset.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
input.value = defColor;
onInput(defColor);
});
wrap.appendChild(reset);
wrap.appendChild(input);
row.appendChild(span);
row.appendChild(wrap);
return row;
}
function section(title: string): HTMLElement { function section(title: string): HTMLElement {
const s = document.createElement('div'); const s = document.createElement('div');
s.className = 'decor-section'; s.className = 'decor-section';
@ -153,7 +229,7 @@ export function buildAppearanceMenu(
const all = Array.from(new Set([...known, ...statuses])); const all = Array.from(new Set([...known, ...statuses]));
for (const st of all) { for (const st of all) {
colours.appendChild( colours.appendChild(
colorRow(st, colorForStatus(a, st), (v) => { statusColorRow(st, colorForStatus(a, st), DEFAULT_STATUS_COLORS[st] || a.defaultColor, (v) => {
a.colors[st] = v; a.colors[st] = v;
saveAppearance(a); saveAppearance(a);
onChange(); onChange();
@ -167,13 +243,6 @@ export function buildAppearanceMenu(
onChange(); onChange();
}), }),
); );
colours.appendChild(
colorRow('Прочие статусы', a.defaultColor, (v) => {
a.defaultColor = v;
saveAppearance(a);
onChange();
}),
);
panel.appendChild(colours); panel.appendChild(colours);
// 2) Sizes // 2) Sizes

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

@ -14,7 +14,9 @@ export interface Dropdown {
onChange(fn: () => void): void; onChange(fn: () => void): void;
} }
export function createDropdown(id: string): Dropdown { // `formatLabel` maps a raw value to its display text (values stay raw for
// filtering/URLs); defaults to identity.
export function createDropdown(id: string, formatLabel: (v: string) => string = (v) => v): Dropdown {
const root = document.getElementById(id + '-dropdown') as HTMLElement; const root = document.getElementById(id + '-dropdown') as HTMLElement;
const btn = document.getElementById(id + '-btn') as HTMLElement; const btn = document.getElementById(id + '-btn') as HTMLElement;
const btnText = document.getElementById(id + '-btn-text') as HTMLElement; const btnText = document.getElementById(id + '-btn-text') as HTMLElement;
@ -28,7 +30,7 @@ export function createDropdown(id: string): Dropdown {
function updateBtnText(): void { function updateBtnText(): void {
if (selected.size === 0) btnText.textContent = defaultLabel; if (selected.size === 0) btnText.textContent = defaultLabel;
else if (selected.size === allValues.length) btnText.textContent = 'Все (' + allValues.length + ')'; else if (selected.size === allValues.length) btnText.textContent = 'Все (' + allValues.length + ')';
else if (selected.size <= 2) btnText.textContent = Array.from(selected).join(', '); else if (selected.size <= 2) btnText.textContent = Array.from(selected).map(formatLabel).join(', ');
else btnText.textContent = 'Выбрано: ' + selected.size; else btnText.textContent = 'Выбрано: ' + selected.size;
} }
@ -40,6 +42,30 @@ export function createDropdown(id: string): Dropdown {
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');
@ -62,11 +88,6 @@ export function createDropdown(id: string): Dropdown {
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');
@ -80,7 +101,8 @@ export function createDropdown(id: string): Dropdown {
onChange(); onChange();
}); });
label.appendChild(cb); label.appendChild(cb);
label.appendChild(document.createTextNode(v)); label.appendChild(document.createTextNode(formatLabel(v)));
optionEls.push({ v, el: label });
panel.appendChild(label); panel.appendChild(label);
} }
} }
@ -91,6 +113,14 @@ export function createDropdown(id: string): Dropdown {
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, colorForStatus } from './appearance'; import { loadAppearance, applyCssVars, buildAppearanceMenu, 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,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,7 +115,8 @@ const cityDropdown = createDropdown('city');
const brandDropdown = createDropdown('brand'); const brandDropdown = createDropdown('brand');
const dimensionDropdown = createDropdown('dimension'); const dimensionDropdown = createDropdown('dimension');
const managerDropdown = createDropdown('manager'); const managerDropdown = createDropdown('manager');
const statusDropdown = createDropdown('status'); const statusDropdown = createDropdown('status', statusLabel);
const dateRange = createDateRange();
const view = createTimelineView( const view = createTimelineView(
{ {
timelineEl: el.timelineEl, timelineEl: el.timelineEl,
@ -133,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,
}; };
} }
@ -180,9 +198,28 @@ function rebuildDecorMenu(): void {
}); });
} }
// Position the (fixed) appearance menu: top 25px below the header, bottom no
// closer than 30px to the window edge; overflow scrolls inside.
function positionDecorMenu(): void {
const header = document.querySelector('header');
let refBottom = header ? header.getBoundingClientRect().bottom : 0;
// Drop below the active-filter chips row too, when it is showing.
const chips = el.filterChips;
if (chips && chips.style.display !== 'none' && chips.getClientRects().length) {
refBottom = Math.max(refBottom, chips.getBoundingClientRect().bottom);
}
const top = refBottom + 20;
el.decorMenu.style.top = top + 'px';
el.decorMenu.style.maxHeight = Math.max(120, window.innerHeight - top - 30) + 'px';
}
el.decorBtn.addEventListener('click', (e) => { el.decorBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
const willOpen = !el.decorMenu.classList.contains('open');
el.decorMenu.classList.toggle('open'); el.decorMenu.classList.toggle('open');
if (willOpen) positionDecorMenu();
});
window.addEventListener('resize', () => {
if (el.decorMenu.classList.contains('open')) positionDecorMenu();
}); });
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!el.decorMenu.contains(e.target as Node) && e.target !== el.decorBtn) { if (!el.decorMenu.contains(e.target as Node) && e.target !== el.decorBtn) {
@ -212,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();
@ -249,8 +312,9 @@ function resetFilters(): void {
function renderChips(): void { function renderChips(): void {
const items: { label: string; remove: () => void }[] = []; const items: { label: string; remove: () => void }[] = [];
for (const dd of [cityDropdown, brandDropdown, dimensionDropdown, managerDropdown, statusDropdown]) { for (const dd of [cityDropdown, brandDropdown, dimensionDropdown, managerDropdown, statusDropdown]) {
const fmt = dd === statusDropdown ? statusLabel : (x: string) => x;
for (const v of dd.getSelected()) { for (const v of dd.getSelected()) {
items.push({ label: v, remove: () => { dd.setSelected(dd.getSelected().filter((x) => x !== v)); void loadData(true); } }); items.push({ label: fmt(v), remove: () => { dd.setSelected(dd.getSelected().filter((x) => x !== v)); void loadData(true); } });
} }
} }
if (el.search.value.trim()) items.push({ label: 'Поиск: ' + el.search.value.trim(), remove: () => { el.search.value = ''; void loadData(true); } }); if (el.search.value.trim()) items.push({ label: 'Поиск: ' + el.search.value.trim(), remove: () => { el.search.value = ''; void loadData(true); } });
@ -260,6 +324,24 @@ function renderChips(): void {
el.filterChips.innerHTML = ''; el.filterChips.innerHTML = '';
if (!items.length) { el.filterChips.style.display = 'none'; return; } if (!items.length) { el.filterChips.style.display = 'none'; return; }
el.filterChips.style.display = ''; el.filterChips.style.display = '';
// "Скопировать ссылку" — always leftmost, separated from the chips by a rule.
const share = document.createElement('button');
share.type = 'button';
share.className = 'chips-share';
share.textContent = 'Скопировать ссылку';
share.addEventListener('click', () => {
navigator.clipboard
?.writeText(location.href)
.then(() => showToast('Ссылка скопирована'))
.catch(() => showToast('Не удалось скопировать'));
});
el.filterChips.appendChild(share);
const divider = document.createElement('span');
divider.className = 'chips-divider';
divider.textContent = '|';
el.filterChips.appendChild(divider);
for (const it of items) { for (const it of items) {
const chip = document.createElement('span'); const chip = document.createElement('span');
chip.className = 'chip'; chip.className = 'chip';
@ -279,27 +361,12 @@ function renderChips(): void {
clear.textContent = 'Сбросить всё'; clear.textContent = 'Сбросить всё';
clear.addEventListener('click', resetFilters); clear.addEventListener('click', resetFilters);
el.filterChips.appendChild(clear); el.filterChips.appendChild(clear);
const share = document.createElement('button');
share.type = 'button';
share.className = 'chips-share';
share.textContent = 'Скопировать ссылку';
share.addEventListener('click', () => {
navigator.clipboard
?.writeText(location.href)
.then(() => {
share.textContent = 'Ссылка скопирована';
window.setTimeout(() => { share.textContent = 'Скопировать ссылку'; }, 1600);
})
.catch(() => { /* clipboard unavailable */ });
});
el.filterChips.appendChild(share);
} }
el.emptyResetTimeline.addEventListener('click', resetFilters); 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();
@ -313,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();
@ -344,7 +412,7 @@ async function refreshMap(): Promise<void> {
if (key === mapLoadedKey) return; if (key === mapLoadedKey) return;
mapLoadedKey = key; mapLoadedKey = key;
const surfaces = await api.map(currentFilters()); const surfaces = await api.map(currentFilters());
mapView.render(surfaces); mapView.render(surfaces, isManager());
} catch (e) { } catch (e) {
console.error('map load failed', e); console.error('map load failed', e);
} finally { } finally {
@ -352,9 +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') await refreshMap(); // map visible (map or split) if (mode === 'timeline') {
mapView.closePanel(); // map hidden — drop its floating card
// Free the Yandex map (tiles/canvas/GPU — the bulk of the tab's memory) a
// couple of seconds after it's closed; cancelled if a map view returns, so
// quick График⇄Карта toggles don't thrash a re-init.
window.clearTimeout(mapDestroyTimer);
mapDestroyTimer = window.setTimeout(() => {
mapView.destroy();
// The fresh map/ObjectManager will be empty, so drop the "already loaded"
// key — the next open re-fetches + re-renders instead of short-circuiting.
mapLoadedKey = '';
}, 2000);
} else {
window.clearTimeout(mapDestroyTimer); // map visible again — keep it
await refreshMap(); // map visible (map or split); ensureInit rebuilds if destroyed
}
} }
const viewModes = createViewModes( const viewModes = createViewModes(
@ -396,65 +480,64 @@ 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);
}); });
// ---- board detail panel (opened from a timeline row label) ---- // Column-info header: toggle city / address / code shown in the first column.
const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/'; const ciButtons = Array.from(document.querySelectorAll<HTMLElement>('.ci-toggle'));
const boardPanel = document.getElementById('board-panel') as HTMLElement; function syncInfoButtons(): void {
const boardBackdrop = document.getElementById('board-backdrop') as HTMLElement; for (const btn of ciButtons) btn.classList.toggle('is-on', infoCols[btn.dataset.part as keyof InfoCols]);
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;
} }
function openBoardPanel(boardId: string): void { for (const btn of ciButtons) {
// Detail panel exposes client/brand/manager + Planfix deep-links — managers only. btn.addEventListener('click', () => {
if (!isManager()) return; const part = btn.dataset.part as keyof InfoCols;
const board = lastBoards.find((b) => b.board_id === boardId); // Never leave the row empty: can't turn off the last remaining part.
const bookings = lastBookings if (infoCols[part] && !(['city', 'address', 'code'] as (keyof InfoCols)[]).some((k) => k !== part && infoCols[k])) return;
.filter((b) => b.board_id === boardId) infoCols[part] = !infoCols[part];
.sort((a, b) => a.start_date.localeCompare(b.start_date)); localStorage.setItem(CI_LS, JSON.stringify(infoCols));
boardPanelTitle.textContent = boardId; syncInfoButtons();
const parts: string[] = []; view.refreshLabels(flags());
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;
} }
(document.getElementById('board-panel-close') as HTMLElement).addEventListener('click', closeBoardPanel); syncInfoButtons();
boardBackdrop.addEventListener('click', closeBoardPanel);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeBoardPanel(); }); // Clicking a surface in the timeline's left column flies the map to it: switch to
view.setOnBoardClick(openBoardPanel); // the combined "Карта и график" view if only the timeline is open, then focus the
// surface's cluster. Works for anonymous and manager users alike.
function goToSurfaceOnMap(boardId: string): void {
if (viewModes.getMode() === 'timeline') viewModes.setMode('split');
mapView.focusBoard(boardId);
}
view.setOnBoardClick(goToSurfaceOnMap);
async function init(): Promise<void> { async function init(): Promise<void> {
cityDropdown.setValues([], 'Все города'); cityDropdown.setValues([], 'Все города');
@ -473,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

@ -59,9 +59,15 @@ export interface MapElements {
} }
export interface MapView { export interface MapView {
render(surfaces: MapSurface[]): 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"
@ -70,10 +76,38 @@ 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>`);
@ -94,6 +128,209 @@ 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[] = [];
@ -134,6 +371,12 @@ 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;
@ -203,8 +446,26 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
tip.style.display = 'none'; tip.style.display = 'none';
} }
// Whether the current viewer is a manager. The "no coordinates" badge is a
// manager-only tool, so it stays hidden for anonymous visitors.
let managerView = false;
// A pending "fly to this surface" request (e.g. from a timeline label click)
// that is applied once the map has rendered the surfaces.
let pendingFocus: string | null = null;
function applyFocus(): void {
if (!map || !pendingFocus || drawn.length === 0) return; // wait for a render
const bid = pendingFocus;
pendingFocus = null;
const s = drawn.find((x) => x.board_id === bid);
if (s && s.lat != null && s.lon != null) {
map.setCenter([s.lat, s.lon], 16, { duration: 400, checkZoomRange: true });
}
}
function draw(surfaces: MapSurface[]): void { 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;
@ -214,51 +475,60 @@ 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] },
properties: { // Clicks open our own resizable, content-fitting panel — not a Yandex balloon.
balloonContentHeader: escapeHtml(s.board_id), properties: { occupied: s.occupied_now },
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 }, options: { preset: 'islands#circleDotIcon', iconColor: s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE, hasBalloon: false },
})); }));
hideTip(); hideTip();
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. // Actionable "no coordinates" tile + list — manager-only.
if (noCoordsBtn) { if (noCoordsBtn) {
noCoordsBtn.style.display = 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);
if (!noCoordsList.length) noCoordsPanel.style.display = 'none'; if (!managerView || !noCoordsList.length) noCoordsPanel.style.display = 'none';
} }
els.empty.style.display = withCoords.length ? 'none' : 'flex'; els.empty.style.display = withCoords.length ? 'none' : 'flex';
if (features.length) { if (features.length) {
const points = features.map((f) => f.geometry.coordinates); if (pendingFocus) {
const bounds = ymaps.util.bounds.fromPoints(points); // A pending "fly to this surface" (e.g. timeline label click) supersedes
const done = map.setBounds(bounds, { checkZoomRange: true, zoomMargin: 40 }); // the default fit-to-all-points view.
// Search-to-locate: when the filter narrows to a single surface, fly in applyFocus();
// and open its balloon so the user lands right on the card. } else {
if (features.length === 1 && done && typeof done.then === 'function') { const points = features.map((f) => f.geometry.coordinates);
done.then(() => objectManager.objects.balloon.open(features[0].id)); const bounds = ymaps.util.bounds.fromPoints(points);
const done = map.setBounds(bounds, { checkZoomRange: true, zoomMargin: 40 });
// Search-to-locate: when the filter narrows to a single surface, fly in
// and open its card so the user lands right on it.
if (withCoords.length === 1 && done && typeof done.then === 'function') {
done.then(() => {
const b = els.container.getBoundingClientRect();
showMapPanel([withCoords[0]!], 0, { x: (b.left + b.right) / 2, y: (b.top + b.bottom) / 2, bounds: b });
});
}
} }
} }
} }
return { return {
render(surfaces: MapSurface[]): void { render(surfaces: MapSurface[], mv: boolean): void {
managerView = mv;
if (!objectManager) { if (!objectManager) {
pending = surfaces; pending = surfaces;
return; return;
@ -304,8 +574,13 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
objectManager = new ymaps.ObjectManager({ objectManager = new ymaps.ObjectManager({
clusterize: true, clusterize: true,
gridSize: 112, gridSize: 112,
// Click opens the balloon at any zoom (instead of zooming into the cluster). // Even a lone surface becomes a cluster of 1 (donut showing "1"), so all
// map graphics share the donut style and every click flows through the
// one cluster handler -> our panel.
minClusterSize: 1,
// No Yandex balloons — clicks open our own panel. Clusters must not zoom.
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.
@ -323,14 +598,30 @@ 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);
if (list.length) showTip(clusterTip(list)); // A cluster of one keeps the rich single-surface tooltip.
if (list.length === 1) showTip(singleTip(list[0]!));
else if (list.length) showTip(clusterTip(list));
}); });
objectManager.clusters.events.add(['mouseleave'], hideTip); objectManager.clusters.events.add(['mouseleave'], hideTip);
// Hide the hover tooltip when a balloon opens — otherwise mouseleave may // Click opens our own panel (and hides the hover tip so it doesn't stick).
// not fire (the balloon covers the marker) and the tip stays stuck, objectManager.objects.events.add(['click'], (e: any) => {
// following the cursor across the whole map. hideTip();
objectManager.objects.events.add(['click'], hideTip); const s = drawn[e.get('objectId') as number];
objectManager.clusters.events.add(['click'], hideTip); if (s) showMapPanel([s], 0, clusterPlace(mx, my));
});
objectManager.clusters.events.add(['click'], (e: any) => {
hideTip();
const cluster = objectManager.clusters.getById(e.get('objectId'));
const objs = cluster ? cluster.properties.geoObjects : [];
const list = objs.map((o: any) => drawn[o.id as number]).filter(Boolean);
if (list.length) showMapPanel(list, 0, clusterPlace(mx, my));
});
// Clicking the empty map (anywhere but a cluster) closes an open panel.
// Yandex also fires this map 'click' for the same click that opened a
// cluster panel, so skip closes within a short window of an open.
map.events.add('click', () => {
if (Date.now() - panelOpenedAt > 250) closeMapPanel();
});
if (pending) { if (pending) {
draw(pending); draw(pending);
@ -342,5 +633,26 @@ 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,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; }
@ -169,7 +229,7 @@ input::placeholder { color: var(--text-muted); }
bottom, level with the date fields / mode switch. */ bottom, level with the date fields / mode switch. */
.hgroup-brand { align-items: stretch; } .hgroup-brand { align-items: stretch; }
.brand { display: flex; flex-direction: column; justify-content: flex-start; line-height: 1.2; } .brand { display: flex; flex-direction: column; justify-content: flex-start; line-height: 1.2; }
.brand-name { font-size: 18px; font-weight: 700; white-space: nowrap; } .brand-name { font-size: 23px; font-weight: 700; white-space: nowrap; }
.brand-sub { font-size: 15px; font-weight: 400; color: var(--text-muted); white-space: nowrap; } .brand-sub { font-size: 15px; font-weight: 400; color: var(--text-muted); white-space: nowrap; }
.field { display: flex; flex-direction: column; gap: var(--size-1); } .field { display: flex; flex-direction: column; gap: var(--size-1); }
@ -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. */
@ -256,10 +426,12 @@ input::placeholder { color: var(--text-muted); }
} }
.decor-btn:hover::after { opacity: 1; } .decor-btn:hover::after { opacity: 1; }
.decor-menu { .decor-menu {
display: none; position: absolute; top: calc(100% + 6px); right: 0; /* Fixed to the viewport: JS sets `top` (20px below the header/chips) and
`max-height` (ending 30px above the window bottom) on open. */
display: none; position: fixed; right: 58px;
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: var(--size-3); z-index: 50; box-shadow: var(--shadow-md); padding: var(--size-3); z-index: 50;
width: max-content; max-width: min(420px, 92vw); max-height: calc(100vh - 96px); overflow-y: auto; width: max-content; max-width: min(420px, 92vw); overflow-y: auto;
} }
.decor-menu.open { display: block; } .decor-menu.open { display: block; }
.decor-section { margin-bottom: 22px; padding-bottom: 14px; border-bottom: 1px solid var(--border); } .decor-section { margin-bottom: 22px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
@ -277,10 +449,36 @@ 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;
} }
.decor-color-wrap { display: inline-flex; align-items: center; gap: 8px; }
.decor-color-reset {
display: inline-flex; align-items: center; justify-content: center;
width: 24px; height: 24px; padding: 0; border: none; background: none;
color: var(--text-muted); cursor: pointer; border-radius: var(--radius-sm);
transition: color var(--transition), background var(--transition);
}
.decor-color-reset:hover { color: var(--accent); background: var(--surface-2); }
.decor-reset { .decor-reset {
margin-top: 6px; width: 100%; padding: 8px; font-size: 12px; margin-top: 6px; width: 100%; padding: 8px; font-size: 12px;
border: 1px solid var(--border); border-radius: var(--radius-sm); border: 1px solid var(--border); border-radius: var(--radius-sm);
@ -292,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 {
@ -368,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;
@ -419,6 +649,52 @@ 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 {
@ -459,6 +735,7 @@ html.role-pending .manager-only { display: none !important; }
.vis-item.vis-range .vis-item-overflow { display: flex; align-items: center; height: 100%; width: 100%; overflow: hidden; } .vis-item.vis-range .vis-item-overflow { display: flex; align-items: center; height: 100%; width: 100%; overflow: hidden; }
.vis-item.vis-range .vis-item-content { padding: 0; width: 100%; } .vis-item.vis-range .vis-item-content { padding: 0; width: 100%; }
.bar-inner { .bar-inner {
position: relative;
height: var(--bar-h); height: var(--bar-h);
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
@ -473,6 +750,20 @@ html.role-pending .manager-only { display: none !important; }
overflow: hidden; overflow: hidden;
} }
.vis-item.vis-range:hover .bar-inner { filter: brightness(0.92); } .vis-item.vis-range:hover .bar-inner { filter: brightness(0.92); }
/* Label wider than the bar: JS adds .bar-of (label past the bar's right edge) or
.bar-of-left (past the left edge, when the right side would hit the next bar),
as dark text on the track instead of clipping inside the bar. */
.vis-item.vis-range.bar-of .vis-item-overflow,
.vis-item.vis-range.bar-of-left .vis-item-overflow { overflow: visible; }
.vis-item.vis-range.bar-of .bar-inner,
.vis-item.vis-range.bar-of-left .bar-inner { overflow: visible; }
.vis-item.vis-range.bar-of .bar-text,
.vis-item.vis-range.bar-of-left .bar-text {
position: absolute; top: 50%; transform: translateY(-50%);
color: var(--text); white-space: nowrap; pointer-events: none;
}
.vis-item.vis-range.bar-of .bar-text { left: 100%; margin-left: 5px; }
.vis-item.vis-range.bar-of-left .bar-text { right: 100%; margin-right: 5px; text-align: right; }
.vis-foreground .vis-group { min-height: var(--row-h); } .vis-foreground .vis-group { min-height: var(--row-h); }
.vis-labelset .vis-label { font-size: var(--label-font); display: flex; align-items: center; min-height: var(--row-h); } .vis-labelset .vis-label { font-size: var(--label-font); display: flex; align-items: center; min-height: var(--row-h); }
@ -482,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;
} }
@ -494,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,
@ -501,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. */
@ -586,9 +885,10 @@ html.role-pending .manager-only { display: none !important; }
.chips-share { .chips-share {
border: none; background: transparent; color: var(--accent); cursor: pointer; border: none; background: transparent; color: var(--accent); cursor: pointer;
font-size: 12px; font-weight: 500; padding: 3px 8px; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500; padding: 3px 8px; border-radius: var(--radius-sm);
margin-left: auto; transition: background var(--transition); transition: background var(--transition);
} }
.chips-share:hover { background: var(--accent-weak); } .chips-share:hover { background: var(--accent-weak); }
.chips-divider { color: var(--border-strong); font-weight: 400; user-select: none; }
/* ---- polish: focus rings, animations, scrollbars, tabular figures ---- */ /* ---- polish: focus rings, animations, scrollbars, tabular figures ---- */
button:focus-visible, input[type=text]:focus-visible, input[type=date]:focus-visible, button:focus-visible, input[type=text]:focus-visible, input[type=date]:focus-visible,

View File

@ -3,7 +3,7 @@ import { Timeline, DataSet } from 'vis-timeline/standalone';
import type { TimelineOptions } from 'vis-timeline/standalone'; import type { TimelineOptions } from 'vis-timeline/standalone';
import { escapeHtml } from './util'; import { escapeHtml } from './util';
import { computeCollisions } from './collisions'; import { computeCollisions } from './collisions';
import { colorForStatus } from './appearance'; import { colorForStatus, statusLabel } from './appearance';
import type { Appearance } from './appearance'; import type { Appearance } from './appearance';
import type { Board, Booking } from './types'; import type { Board, Booking } from './types';
@ -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 },
}; };
@ -138,6 +168,71 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
window.addEventListener('resize', scheduleHeightFit); window.addEventListener('resize', scheduleHeightFit);
scheduleHeightFit(); scheduleHeightFit();
// When a bar's label is wider than the bar itself, move it just outside the bar
// (dark text on the track) instead of clipping it inside. Default side is the
// right of the bar; if the label would collide with the next bar on the same
// row, put it to the left instead. Re-run on every redraw (zoom/pan changes bar
// widths). Batched: reset all, measure all, then apply — at most one reflow.
let barTextRaf = 0;
const BAR_LABEL_GAP = 6;
function layoutBarText(): void {
const items = Array.from(el.timelineEl.querySelectorAll<HTMLElement>('.vis-item.vis-range'));
for (const it of items) it.classList.remove('bar-of', 'bar-of-left');
const center = el.timelineEl.querySelector<HTMLElement>('.vis-panel.vis-center');
const bound = center ? center.getBoundingClientRect() : { left: 0, right: window.innerWidth };
// Per row (bars share a vertical band), collect every bar's horizontal span so
// we can find each overflowing label's nearest neighbour on either side.
const rows = new Map<number, { left: number; right: number }[]>();
const overflow: { it: HTMLElement; row: number; left: number; right: number; textW: number }[] = [];
for (const it of items) {
const inner = it.querySelector<HTMLElement>('.bar-inner');
if (!inner) continue;
const r = inner.getBoundingClientRect();
if (r.width === 0) continue;
const row = Math.round(r.top);
(rows.get(row) ?? rows.set(row, []).get(row)!).push({ left: r.left, right: r.right });
if (inner.scrollWidth > inner.clientWidth + 1) {
const txt = it.querySelector<HTMLElement>('.bar-text');
overflow.push({ it, row, left: r.left, right: r.right, textW: txt ? txt.scrollWidth : 0 });
}
}
const left: HTMLElement[] = [];
const right: HTMLElement[] = [];
for (const c of overflow) {
const bars = rows.get(c.row)!;
let nextLeft = bound.right;
let prevRight = bound.left;
for (const b of bars) {
if (b.left > c.right + 0.5 && b.left < nextLeft) nextLeft = b.left;
if (b.right < c.left - 0.5 && b.right > prevRight) prevRight = b.right;
}
const need = c.textW + BAR_LABEL_GAP;
const gapRight = nextLeft - c.right;
const gapLeft = c.left - prevRight;
// Prefer right; fall back to left when the right label would overlap the
// next bar; if neither side fits, take whichever has more room.
if (need <= gapRight) right.push(c.it);
else if (need <= gapLeft) left.push(c.it);
else if (gapRight >= gapLeft) right.push(c.it);
else left.push(c.it);
}
for (const it of right) it.classList.add('bar-of');
for (const it of left) it.classList.add('bar-of-left');
}
function scheduleBarText(): void {
// Debounce (not throttle): a zoom/redraw fires several 'changed' events in a
// burst and vis recreates item DOM between them. Wait for the burst to settle
// so we measure final bar widths and our classes aren't wiped by a later pass.
clearTimeout(barTextRaf);
barTextRaf = window.setTimeout(layoutBarText, 110);
}
// 'changed' = data/redraw; 'rangechanged' = zoom/pan settled (bars resized).
timeline.on('changed', scheduleBarText);
timeline.on('rangechanged', scheduleBarText);
// ---- clicks: bar -> Planfix task; row label -> board detail panel ---- // ---- clicks: bar -> Planfix task; row label -> board detail panel ----
timeline.on('click', (props: any) => { timeline.on('click', (props: any) => {
if (props.item) { if (props.item) {
@ -171,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;
@ -305,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',
})); }));
@ -347,7 +458,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
if (brand && company) tt.push(`<div class="tt-sub">${esc(company)}</div>`); if (brand && company) tt.push(`<div class="tt-sub">${esc(company)}</div>`);
tt.push('<div class="tt-sep"></div>'); tt.push('<div class="tt-sep"></div>');
tt.push(datesRow); tt.push(datesRow);
if (bk.task_status) tt.push(`<div class="tt-row"><span class="tt-dot" style="background:${statusColor}"></span><span>${esc(bk.task_status)}</span></div>`); if (bk.task_status) tt.push(`<div class="tt-row"><span class="tt-dot" style="background:${statusColor}"></span><span>${esc(statusLabel(bk.task_status))}</span></div>`);
if (managers) tt.push(`<div class="tt-row"><span class="tt-ico">👤</span><span>${esc(managers)}</span></div>`); if (managers) tt.push(`<div class="tt-row"><span class="tt-ico">👤</span><span>${esc(managers)}</span></div>`);
if (isCollision) { if (isCollision) {
tt.push('<div class="tt-warn"><div class="tt-warn-head">⚠ Коллизия</div>'); tt.push('<div class="tt-warn"><div class="tt-warn-head">⚠ Коллизия</div>');
@ -367,7 +478,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
group: bk.board_id, group: bk.board_id,
start: bk.start_date, start: bk.start_date,
end: end.toISOString().slice(0, 10), end: end.toISOString().slice(0, 10),
content: `<div class="bar-inner" style="background-color:${color};">${barContent}</div>`, content: `<div class="bar-inner" style="background-color:${color};"><span class="bar-text">${barContent}</span></div>`,
type: 'range', type: 'range',
tooltipHtml, tooltipHtml,
taskId: bk.task_id, taskId: bk.task_id,
@ -378,16 +489,35 @@ 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 });
scheduleHeightFit(); scheduleHeightFit();
scheduleBarText();
} }
function refresh(): void { function refresh(): void {
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();
@ -404,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

@ -66,6 +66,8 @@ 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,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,

39
main.py
View File

@ -1,5 +1,7 @@
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
@ -305,6 +307,39 @@ 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":
@ -361,6 +396,7 @@ 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"], [])
@ -370,8 +406,11 @@ 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")