Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0c53e2ce9 | ||
|
|
17b70b1f44 | ||
|
|
3939dbf8f5 | ||
|
|
43bb72de61 | ||
|
|
691ec36b9e | ||
|
|
77a36a22d9 | ||
|
|
5ac2142c70 | ||
|
|
5902c1e7f0 | ||
|
|
147529c20f | ||
|
|
5ba5a77f97 | ||
|
|
e9eee60dab | ||
|
|
f5166b888a | ||
|
|
d7c1ce7b77 | ||
|
|
f4c1de70b4 | ||
|
|
78d62ae36c | ||
|
|
e83bb5ad49 | ||
|
|
8e6c19e9a4 | ||
|
|
dec639581b | ||
|
|
de4cf1743b | ||
|
|
9b6ca6f848 | ||
|
|
159590d62e | ||
|
|
b4d56d0540 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,4 +6,5 @@ __pycache__/
|
|||||||
static/assets/
|
static/assets/
|
||||||
static/index.html
|
static/index.html
|
||||||
static/next/
|
static/next/
|
||||||
|
photos/
|
||||||
.env
|
.env
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mapdash-frontend",
|
"name": "mapdash-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.1",
|
"version": "1.0.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export const DEFAULT_STATUS_COLORS: Record<string, string> = {
|
|||||||
'РК. Новая': '#4f46e5',
|
'РК. Новая': '#4f46e5',
|
||||||
'РК. Предварительное бронирование': '#4f46e5',
|
'РК. Предварительное бронирование': '#4f46e5',
|
||||||
'РК. Макет РИМ согласован': '#4f46e5',
|
'РК. Макет РИМ согласован': '#4f46e5',
|
||||||
'РК. Размещено': '#4f46e5',
|
'РК. Размещено': '#26c04c',
|
||||||
'РК. Ожидает монтаж': '#4f46e5',
|
'РК. Ожидает монтаж': '#4f46e5',
|
||||||
'РК. Ожидает демонтаж': '#4f46e5',
|
'РК. Ожидает демонтаж': '#4f46e5',
|
||||||
'РК. Ожидает перемонтаж': '#4f46e5',
|
'РК. Ожидает перемонтаж': '#4f46e5',
|
||||||
@ -98,21 +98,53 @@ export function applyCssVars(a: Appearance): void {
|
|||||||
// ---- settings menu UI ----------------------------------------------------
|
// ---- settings menu UI ----------------------------------------------------
|
||||||
|
|
||||||
function numberRow(labelText: string, value: number, min: number, max: number, onInput: (v: number) => void): HTMLElement {
|
function numberRow(labelText: string, value: number, min: number, max: number, onInput: (v: number) => void): HTMLElement {
|
||||||
const row = document.createElement('label');
|
// A stepper: −/+ buttons around an editable number field (type a value directly).
|
||||||
|
const row = document.createElement('div');
|
||||||
row.className = 'decor-row';
|
row.className = 'decor-row';
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = labelText;
|
span.textContent = labelText;
|
||||||
|
|
||||||
|
const stepper = document.createElement('div');
|
||||||
|
stepper.className = 'decor-stepper';
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'number';
|
input.type = 'number';
|
||||||
|
input.className = 'ds-input';
|
||||||
input.min = String(min);
|
input.min = String(min);
|
||||||
input.max = String(max);
|
input.max = String(max);
|
||||||
input.value = String(value);
|
input.value = String(value);
|
||||||
|
const dec = document.createElement('button');
|
||||||
|
dec.type = 'button';
|
||||||
|
dec.className = 'ds-btn';
|
||||||
|
dec.textContent = '−';
|
||||||
|
dec.setAttribute('aria-label', 'Уменьшить');
|
||||||
|
const inc = document.createElement('button');
|
||||||
|
inc.type = 'button';
|
||||||
|
inc.className = 'ds-btn';
|
||||||
|
inc.textContent = '+';
|
||||||
|
inc.setAttribute('aria-label', 'Увеличить');
|
||||||
|
|
||||||
|
const clamp = (v: number): number => Math.max(min, Math.min(max, v));
|
||||||
|
// While typing: apply the clamped value live but leave the field as-is so
|
||||||
|
// multi-digit input isn't fought (e.g. typing "30" when min is 8).
|
||||||
input.addEventListener('input', () => {
|
input.addEventListener('input', () => {
|
||||||
const v = parseInt(input.value, 10);
|
const v = parseInt(input.value, 10);
|
||||||
if (!Number.isNaN(v)) onInput(Math.max(min, Math.min(max, v)));
|
if (!Number.isNaN(v)) onInput(clamp(v));
|
||||||
});
|
});
|
||||||
|
// On blur/Enter and on the buttons: normalise the field to the clamped value.
|
||||||
|
const commit = (v: number): void => {
|
||||||
|
const c = clamp(Number.isNaN(v) ? value : v);
|
||||||
|
input.value = String(c);
|
||||||
|
onInput(c);
|
||||||
|
};
|
||||||
|
input.addEventListener('change', () => commit(parseInt(input.value, 10)));
|
||||||
|
dec.addEventListener('click', () => commit((parseInt(input.value, 10) || min) - 1));
|
||||||
|
inc.addEventListener('click', () => commit((parseInt(input.value, 10) || min) + 1));
|
||||||
|
|
||||||
|
stepper.appendChild(dec);
|
||||||
|
stepper.appendChild(input);
|
||||||
|
stepper.appendChild(inc);
|
||||||
row.appendChild(span);
|
row.appendChild(span);
|
||||||
row.appendChild(input);
|
row.appendChild(stepper);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
215
frontend/src/datepicker.ts
Normal file
215
frontend/src/datepicker.ts
Normal 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -42,6 +42,30 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
|
|||||||
|
|
||||||
function renderPanel(): void {
|
function renderPanel(): void {
|
||||||
panel.innerHTML = '';
|
panel.innerHTML = '';
|
||||||
|
|
||||||
|
// Show all available options, plus any currently-selected value that is no
|
||||||
|
// longer among them (so a selection made incompatible by other filters
|
||||||
|
// stays visible and can still be unchecked).
|
||||||
|
const extra = Array.from(selected).filter((v) => !allValues.includes(v));
|
||||||
|
const display = allValues.concat(extra);
|
||||||
|
|
||||||
|
const optionEls: { v: string; el: HTMLElement }[] = [];
|
||||||
|
// Search box — filters the option list in place (only for longer lists).
|
||||||
|
if (display.length > 7) {
|
||||||
|
const search = document.createElement('input');
|
||||||
|
search.type = 'text';
|
||||||
|
search.className = 'dp-search';
|
||||||
|
search.placeholder = 'Поиск…';
|
||||||
|
search.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
search.addEventListener('input', () => {
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
for (const o of optionEls) {
|
||||||
|
o.el.style.display = !q || formatLabel(o.v).toLowerCase().includes(q) ? '' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
panel.appendChild(search);
|
||||||
|
}
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'dp-actions';
|
actions.className = 'dp-actions';
|
||||||
const selAll = document.createElement('a');
|
const selAll = document.createElement('a');
|
||||||
@ -64,11 +88,6 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
|
|||||||
actions.appendChild(selNone);
|
actions.appendChild(selNone);
|
||||||
panel.appendChild(actions);
|
panel.appendChild(actions);
|
||||||
|
|
||||||
// Show all available options, plus any currently-selected value that is no
|
|
||||||
// longer among them (so a selection made incompatible by other filters
|
|
||||||
// stays visible and can still be unchecked).
|
|
||||||
const extra = Array.from(selected).filter((v) => !allValues.includes(v));
|
|
||||||
const display = allValues.concat(extra);
|
|
||||||
for (const v of display) {
|
for (const v of display) {
|
||||||
const label = document.createElement('label');
|
const label = document.createElement('label');
|
||||||
const cb = document.createElement('input');
|
const cb = document.createElement('input');
|
||||||
@ -83,6 +102,7 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
|
|||||||
});
|
});
|
||||||
label.appendChild(cb);
|
label.appendChild(cb);
|
||||||
label.appendChild(document.createTextNode(formatLabel(v)));
|
label.appendChild(document.createTextNode(formatLabel(v)));
|
||||||
|
optionEls.push({ v, el: label });
|
||||||
panel.appendChild(label);
|
panel.appendChild(label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,6 +113,14 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
|
|||||||
if (d !== root) d.classList.remove('open');
|
if (d !== root) d.classList.remove('open');
|
||||||
});
|
});
|
||||||
root.classList.toggle('open');
|
root.classList.toggle('open');
|
||||||
|
if (root.classList.contains('open')) {
|
||||||
|
const s = panel.querySelector<HTMLInputElement>('.dp-search');
|
||||||
|
if (s) {
|
||||||
|
s.value = '';
|
||||||
|
s.dispatchEvent(new Event('input')); // reset any prior filtering
|
||||||
|
s.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (!root.contains(e.target as Node)) root.classList.remove('open');
|
if (!root.contains(e.target as Node)) root.classList.remove('open');
|
||||||
|
|||||||
@ -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, statusLabel } 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,
|
||||||
@ -114,6 +116,7 @@ const brandDropdown = createDropdown('brand');
|
|||||||
const dimensionDropdown = createDropdown('dimension');
|
const dimensionDropdown = createDropdown('dimension');
|
||||||
const managerDropdown = createDropdown('manager');
|
const managerDropdown = createDropdown('manager');
|
||||||
const statusDropdown = createDropdown('status', statusLabel);
|
const statusDropdown = createDropdown('status', statusLabel);
|
||||||
|
const dateRange = createDateRange();
|
||||||
const view = createTimelineView(
|
const view = createTimelineView(
|
||||||
{
|
{
|
||||||
timelineEl: el.timelineEl,
|
timelineEl: el.timelineEl,
|
||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,8 +202,13 @@ function rebuildDecorMenu(): void {
|
|||||||
// closer than 30px to the window edge; overflow scrolls inside.
|
// closer than 30px to the window edge; overflow scrolls inside.
|
||||||
function positionDecorMenu(): void {
|
function positionDecorMenu(): void {
|
||||||
const header = document.querySelector('header');
|
const header = document.querySelector('header');
|
||||||
const headerBottom = header ? header.getBoundingClientRect().bottom : 0;
|
let refBottom = header ? header.getBoundingClientRect().bottom : 0;
|
||||||
const top = headerBottom + 25;
|
// 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.top = top + 'px';
|
||||||
el.decorMenu.style.maxHeight = Math.max(120, window.innerHeight - top - 30) + 'px';
|
el.decorMenu.style.maxHeight = Math.max(120, window.innerHeight - top - 30) + 'px';
|
||||||
}
|
}
|
||||||
@ -226,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();
|
||||||
@ -275,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';
|
||||||
@ -294,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();
|
||||||
@ -328,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();
|
||||||
@ -359,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 {
|
||||||
@ -367,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(
|
||||||
@ -411,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([], 'Все города');
|
||||||
@ -488,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());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,9 +426,9 @@ input::placeholder { color: var(--text-muted); }
|
|||||||
}
|
}
|
||||||
.decor-btn:hover::after { opacity: 1; }
|
.decor-btn:hover::after { opacity: 1; }
|
||||||
.decor-menu {
|
.decor-menu {
|
||||||
/* Fixed to the viewport: JS sets `top` (25px below the header) and `max-height`
|
/* Fixed to the viewport: JS sets `top` (20px below the header/chips) and
|
||||||
(ending ≥30px above the window bottom) on open, so it never overflows. */
|
`max-height` (ending ≥30px above the window bottom) on open. */
|
||||||
display: none; position: fixed; right: 18px;
|
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); overflow-y: auto;
|
width: max-content; max-width: min(420px, 92vw); overflow-y: auto;
|
||||||
@ -279,6 +449,24 @@ input::placeholder { color: var(--text-muted); }
|
|||||||
width: 84px; padding: 5px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm);
|
width: 84px; padding: 5px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||||
font-size: 13px; background: var(--bg); color: var(--text);
|
font-size: 13px; background: var(--bg); color: var(--text);
|
||||||
}
|
}
|
||||||
|
/* Stepper: −/+ buttons around an editable number field. */
|
||||||
|
.decor-stepper { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
|
||||||
|
.decor-stepper .ds-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 0;
|
||||||
|
width: 20px; height: 28px; border: none; background: var(--surface);
|
||||||
|
color: var(--accent); font-size: 15px; line-height: 1; cursor: pointer;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.decor-stepper .ds-btn:hover { background: var(--surface-2); }
|
||||||
|
.decor-stepper input.ds-input {
|
||||||
|
width: 38px; height: 28px; text-align: center; padding: 0 2px;
|
||||||
|
border: none; border-left: 1px solid var(--border); border-right: 1px solid var(--border);
|
||||||
|
border-radius: 0; font-size: 13px; background: var(--bg); color: var(--text);
|
||||||
|
font-variant-numeric: tabular-nums; -moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
.decor-stepper .ds-input::-webkit-outer-spin-button,
|
||||||
|
.decor-stepper .ds-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
|
.decor-stepper .ds-input:focus { outline: none; }
|
||||||
.decor-row input[type=color] {
|
.decor-row input[type=color] {
|
||||||
width: 42px; height: 28px; padding: 0; border: 1px solid var(--border);
|
width: 42px; height: 28px; padding: 0; border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm); background: var(--bg); cursor: pointer;
|
border-radius: var(--radius-sm); background: var(--bg); cursor: pointer;
|
||||||
@ -302,18 +490,42 @@ input::placeholder { color: var(--text-muted); }
|
|||||||
/* ---- view modes: segmented control ---- */
|
/* ---- view modes: segmented control ---- */
|
||||||
.mode-switch {
|
.mode-switch {
|
||||||
display: inline-flex; flex-direction: column; gap: 5px;
|
display: inline-flex; flex-direction: column; gap: 5px;
|
||||||
background: var(--surface-2); border: none;
|
background: var(--surface-2); border: none; position: relative;
|
||||||
border-radius: var(--radius); padding: 6px;
|
border-radius: var(--radius); padding: 6px;
|
||||||
height: 100%; /* fill the header row so the bottom lines up with the date fields */
|
height: 100%; /* fill the header row so the bottom lines up with the date fields */
|
||||||
|
width: 160px; /* fixed so the bold active label doesn't resize the block */
|
||||||
|
}
|
||||||
|
/* Sliding active-mode indicator (JS sets top/height to the active button). */
|
||||||
|
.mode-ind {
|
||||||
|
position: absolute; left: 6px; right: 6px; top: 6px; height: 0; z-index: 0;
|
||||||
|
background: var(--bg); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm);
|
||||||
|
transition: top .2s var(--ease-3), height .2s var(--ease-3); pointer-events: none;
|
||||||
}
|
}
|
||||||
.mode-btn {
|
.mode-btn {
|
||||||
|
position: relative; z-index: 1;
|
||||||
border: none; background: transparent; color: var(--text-muted); cursor: pointer;
|
border: none; background: transparent; color: var(--text-muted); cursor: pointer;
|
||||||
padding: 6px 13px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%;
|
padding: 6px 12px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%;
|
||||||
border-radius: var(--radius-sm); transition: background var(--transition), color var(--transition);
|
border-radius: var(--radius-sm); transition: color var(--transition);
|
||||||
flex: 1; display: flex; align-items: center; /* even heights, vertically centred */
|
flex: 1; display: flex; align-items: center; gap: 8px; /* icon + label */
|
||||||
}
|
}
|
||||||
|
.mode-ico { width: 16px; height: 16px; flex: 0 0 auto; }
|
||||||
.mode-btn:hover { color: var(--text); }
|
.mode-btn:hover { color: var(--text); }
|
||||||
.mode-btn.active { background: var(--bg); color: var(--accent); font-weight: 600; box-shadow: var(--shadow-sm); }
|
.mode-btn.active { color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ---- toast notifications (bottom-centre, transient) ---- */
|
||||||
|
.toasts {
|
||||||
|
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); z-index: 200;
|
||||||
|
display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
display: flex; align-items: center; gap: 9px;
|
||||||
|
background: var(--tip-bg); color: var(--tip-text); font-size: 13px;
|
||||||
|
padding: 9px 15px; border-radius: 9px; box-shadow: var(--shadow-md);
|
||||||
|
opacity: 0; transform: translateY(8px);
|
||||||
|
transition: opacity .22s var(--ease-3), transform .22s var(--ease-3);
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; transform: translateY(0); }
|
||||||
|
.toast-ico { color: #37b24d; display: inline-flex; }
|
||||||
|
|
||||||
/* Theme toggle */
|
/* Theme toggle */
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
@ -378,26 +590,34 @@ html.role-pending .manager-only { display: none !important; }
|
|||||||
/* Donut cluster icon (built in map.ts) — centre the 60px svg on the geo point. */
|
/* Donut cluster icon (built in map.ts) — centre the 60px svg on the geo point. */
|
||||||
.mapdash-cluster { width: 60px; height: 60px; margin: -30px 0 0 -30px; line-height: 0; cursor: pointer; }
|
.mapdash-cluster { width: 60px; height: 60px; margin: -30px 0 0 -30px; line-height: 0; cursor: pointer; }
|
||||||
|
|
||||||
|
/* ---- stat tiles (header counter + map legend) ---- */
|
||||||
|
.stat-tile {
|
||||||
|
display: inline-flex; flex-direction: column; align-items: flex-start; gap: 1px;
|
||||||
|
background: var(--surface); border-radius: 9px; padding: 5px 11px;
|
||||||
|
font-variant-numeric: tabular-nums; border: none;
|
||||||
|
}
|
||||||
|
.st-l { font-size: 11px; color: var(--text-muted); display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||||
|
.st-v { font-size: 18px; font-weight: 600; line-height: 1.15; color: var(--text); }
|
||||||
|
.stat-accent { background: #eef0fe; }
|
||||||
|
.stat-accent .st-v { color: var(--accent); }
|
||||||
|
.st-v.st-free { color: #2f9e44; }
|
||||||
|
.st-v.st-busy { color: #d64545; }
|
||||||
|
|
||||||
#map-legend {
|
#map-legend {
|
||||||
position: absolute; left: 10px; bottom: 44px; z-index: 5;
|
position: absolute; left: 10px; bottom: 44px; z-index: 5;
|
||||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
display: flex; flex-direction: row; flex-wrap: wrap; gap: 6px;
|
||||||
padding: var(--size-2) var(--size-3); font-size: 12px; color: var(--text);
|
|
||||||
display: flex; flex-direction: column; align-items: flex-start; gap: var(--size-1);
|
|
||||||
box-shadow: var(--shadow-md); font-variant-numeric: tabular-nums;
|
|
||||||
}
|
}
|
||||||
#map-legend .lg-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; font-size: 12px; }
|
#map-legend .stat-tile { box-shadow: var(--shadow-md); }
|
||||||
#map-legend #map-counter { padding-left: 18px; } /* align "всего" with the dotted rows */
|
#map-legend .lg-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||||||
#map-legend .lg-nocoords { margin-top: 2px; font-size: 12px; }
|
|
||||||
#map-legend .lg-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
|
|
||||||
#map-legend .lg-free { background: #37b24d; }
|
#map-legend .lg-free { background: #37b24d; }
|
||||||
#map-legend .lg-busy { background: #e24b4a; }
|
#map-legend .lg-busy { background: #e24b4a; }
|
||||||
/* Actionable "no coordinates" badge inside the legend. */
|
#map-legend .lg-total { background: var(--border-strong); }
|
||||||
.lg-nocoords {
|
/* Actionable "no coordinates" tile — dashed accent, clickable. */
|
||||||
border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface);
|
.st-nc { cursor: pointer; background: #fbf6e9; border: 1px dashed #d7c08a; padding: 4px 10px; text-align: left;
|
||||||
color: var(--text-muted); font-size: 12px; padding: 2px 8px; cursor: pointer; white-space: nowrap;
|
transition: border-color var(--transition); }
|
||||||
transition: border-color var(--transition), color var(--transition);
|
.st-nc .lg-dot { background: #e8a317; }
|
||||||
}
|
.st-nc .st-v { color: #b07d16; }
|
||||||
.lg-nocoords:hover { border-color: var(--border-strong); color: var(--text); }
|
.st-nc:hover { border-color: #b07d16; }
|
||||||
/* Panel listing surfaces missing coordinates. */
|
/* Panel listing surfaces missing coordinates. */
|
||||||
#map-nocoords-panel {
|
#map-nocoords-panel {
|
||||||
position: absolute; left: 10px; bottom: 150px; z-index: 6;
|
position: absolute; left: 10px; bottom: 150px; z-index: 6;
|
||||||
@ -429,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 {
|
||||||
@ -469,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;
|
||||||
@ -483,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); }
|
||||||
|
|
||||||
@ -492,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -504,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,
|
||||||
@ -511,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. */
|
||||||
@ -596,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,
|
||||||
|
|||||||
@ -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',
|
||||||
}));
|
}));
|
||||||
@ -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; },
|
||||||
|
|||||||
@ -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). */
|
||||||
|
|||||||
@ -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
39
main.py
@ -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")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user