build: static/{index.html,assets} are generated artifacts (source-only tracking)

This commit is contained in:
aaverbitskiy 2026-07-17 16:22:39 +00:00
parent 6704a50b33
commit 2d66871b62
2 changed files with 4 additions and 914 deletions

6
.gitignore vendored
View File

@ -1,6 +1,8 @@
*.bak-* *.bak-*
dist/
node_modules/
*.predeploy-* *.predeploy-*
__pycache__/ __pycache__/
node_modules/ static/assets/
dist/ static/index.html
static/next/ static/next/

View File

@ -1,912 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Адресная программа</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/vis-timeline/8.3.1/vis-timeline-graph2d.min.css" />
<style>
:root {
--bar-color: #4169E1;
--grid-line: #e2e2e2;
--label-w: 340px;
--row-even: #f0f0f0;
--row-odd: #FFFFFF;
--row-hover: #FFECB3;
--month-hover: rgba(65, 105, 225, 0.10);
}
* { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
font-family: -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
background: #fff;
color: #222;
}
header {
flex: 0 0 auto;
padding: 14px 20px;
border-bottom: 1px solid #e2e2e2;
display: flex;
gap: 18px;
align-items: center;
flex-wrap: wrap;
background: #fff;
z-index: 20;
}
.brand {
display: flex;
flex-direction: column;
justify-content: center;
line-height: 1.2;
margin-right: 8px;
}
.brand-name {
font-size: 18px;
font-weight: 700;
white-space: nowrap;
}
.brand-sub {
font-size: 15px;
font-weight: 400;
color: #666;
white-space: nowrap;
}
.field { display: flex; flex-direction: column; gap: 4px; }
.field label { font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: .03em; }
input[type=text] {
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 8px;
font-size: 13px;
min-width: 180px;
}
#status { font-size: 12px; color: #888; margin-left: auto; align-self: center; }
.checkbox-field {
flex-direction: row;
align-items: center;
gap: 6px;
align-self: center;
}
.checkbox-field label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #222;
text-transform: none;
letter-spacing: normal;
cursor: pointer;
white-space: nowrap;
}
.checkbox-field input[type=checkbox] { cursor: pointer; }
.zoom-field input[type=range] {
width: 150px;
cursor: pointer;
}
.zoom-field label { white-space: nowrap; }
/* checkbox dropdown filter */
.dropdown { position: relative; }
.dropdown-btn {
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
padding: 6px 10px;
font-size: 13px;
min-width: 180px;
text-align: left;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.dropdown-btn:hover { border-color: #999; }
.dropdown-btn .caret { font-size: 10px; color: #888; }
.dropdown-panel {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 4px 14px rgba(0,0,0,.12);
padding: 6px 0;
min-width: 220px;
max-height: 280px;
overflow-y: auto;
z-index: 30;
}
.dropdown.open .dropdown-panel { display: block; }
.dropdown-panel label {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
}
.dropdown-panel label:hover { background: #f4f4f4; }
.dropdown-panel .dp-actions {
display: flex;
gap: 10px;
padding: 4px 12px 8px;
border-bottom: 1px solid #eee;
margin-bottom: 4px;
}
.dropdown-panel .dp-actions a {
font-size: 11px;
color: #567;
cursor: pointer;
text-decoration: underline;
}
/* Chart area fills all remaining vertical space below the header.
#timeline itself is given NO height here on purpose: neither a fixed
`height` nor a CSS `max-height` reliably drives vis-timeline's own
internal scroll behavior across reloads/resizes (both were tried and
both broke in different ways). The actual pixel height is computed
and pushed into vis-timeline via `timeline.setOptions({height})` in
JS instead (see fitTimelineHeight()), recalculated on every redraw and
window resize, which is the reliable way to keep this in sync. */
#chart-wrap {
flex: 1 1 auto;
min-height: 0;
position: relative;
overflow: hidden;
}
#timeline { border: none; }
/* vis-timeline overrides */
.vis-item.vis-range {
background-color: var(--bar-color);
border-color: #2c50b0;
border-radius: 3px;
}
.vis-item.vis-range:hover { filter: brightness(0.92); }
/* Date-collision highlight: overrides the normal bar color when a
booking overlaps another one on the same board. Higher specificity
(two classes) wins over the plain .vis-item.vis-range rule above. */
.vis-item.vis-range.collision {
background-color: #BA55D3;
border-color: #9932CC;
}
.vis-item.vis-range.archived {
background-color: #708090;
border-color: #5a6472;
}
.vis-item .vis-item-content { font-size: 11px; color: #FFFFFF; padding: 2px 6px; line-height: 1.3; white-space: nowrap; }
.vis-labelset .vis-label { font-size: 12px; }
/* Zebra striping for board rows: applied via each group's `className`
(set in JS from the board's position in the already-sorted list), so
it targets both the label column and the row background together. */
.vis-label.row-even,
.vis-group.row-even { background-color: var(--row-even); }
.vis-label.row-odd,
.vis-group.row-odd { background-color: var(--row-odd); }
/* Row hover highlight: added/removed directly on the DOM (not through the
vis DataSet) from the `mouseMove` handler in JS, since doing it via a
DataSet update would trigger a full redraw on every mouse movement.
Declared after the zebra rules so it wins at equal specificity when a
row carries both a zebra class and this one at the same time. */
.vis-label.row-hover,
.vis-group.row-hover { background-color: var(--row-hover) !important; }
/* Constrain label content width so vis-timeline's own layout engine
auto-sizes the left panel around it. Do NOT force vis-panel widths
directly with !important: vis re-applies its own computed inline
widths/offsets on every redraw and would fight a hard CSS override,
causing a misaligned gap between the label column and the chart. */
.vis-label .vis-inner {
display: inline-block;
min-width: var(--label-w);
max-width: var(--label-w);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
/* Hovered-month column highlight: a `type: 'background'` vis item with no
`group` set spans the full height of all rows. Its start/end is kept
snapped to calendar-month boundaries in JS regardless of current zoom
level, so this only ever highlights whole months, never days or years. */
.vis-item.vis-background.month-hover-bg {
background-color: var(--month-hover);
}
#col-resizer {
position: absolute;
top: 0;
bottom: 0;
width: 9px;
margin-left: -4px;
cursor: col-resize;
z-index: 25;
display: none;
}
#col-resizer::after {
content: "";
position: absolute;
top: 0; bottom: 0; left: 4px;
width: 1px;
background: #ccc;
}
#col-resizer:hover::after,
#col-resizer.dragging::after {
background: var(--bar-color);
width: 2px;
left: 3px;
}
#tooltip {
position: fixed;
display: none;
background: #222;
color: #fff;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
z-index: 100;
max-width: 320px;
line-height: 1.4;
white-space: pre-line;
}
#empty {
padding: 40px;
text-align: center;
color: #999;
font-size: 13px;
}
</style>
</head>
<body>
<header>
<div class="brand">
<div class="brand-name">Green Media</div>
<div class="brand-sub">Адресная программа</div>
</div>
<div class="field">
<label>Город</label>
<div class="dropdown" id="city-dropdown">
<button type="button" class="dropdown-btn" id="city-btn">
<span id="city-btn-text">Все города</span>
<span class="caret"></span>
</button>
<div class="dropdown-panel" id="city-panel"></div>
</div>
</div>
<div class="field">
<label>Размер</label>
<div class="dropdown" id="dimension-dropdown">
<button type="button" class="dropdown-btn" id="dimension-btn">
<span id="dimension-btn-text">Все размеры</span>
<span class="caret"></span>
</button>
<div class="dropdown-panel" id="dimension-panel"></div>
</div>
</div>
<div class="field">
<label for="search">Поиск (адрес / код)</label>
<input type="text" id="search" placeholder="например, Азина" />
</div>
<div class="field checkbox-field">
<label for="show-brand">
<input type="checkbox" id="show-brand" checked />
Бренд
</label>
</div>
<div class="field checkbox-field">
<label for="show-company">
<input type="checkbox" id="show-company" />
Компания
</label>
</div>
<div class="field checkbox-field">
<label for="show-collisions">
<input type="checkbox" id="show-collisions" checked />
Коллизии размещения
</label>
</div>
<div class="field zoom-field">
<label for="zoom-slider">Масштаб: <span id="zoom-label">12 месяцев</span></label>
<input type="range" id="zoom-slider" min="0" max="9" step="1" value="3" />
</div>
<div id="status"></div>
</header>
<div id="chart-wrap">
<div id="timeline"></div>
<div id="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div>
<div id="empty" style="display:none">Нет данных по выбранным фильтрам</div>
</div>
<div id="tooltip"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vis-timeline/8.3.1/vis-timeline-graph2d.min.js"></script>
<script>
const el = {
search: document.getElementById('search'),
status: document.getElementById('status'),
timelineEl: document.getElementById('timeline'),
empty: document.getElementById('empty'),
tooltip: document.getElementById('tooltip'),
showBrand: document.getElementById('show-brand'),
showCompany: document.getElementById('show-company'),
showCollisions: document.getElementById('show-collisions'),
zoomSlider: document.getElementById('zoom-slider'),
zoomLabel: document.getElementById('zoom-label'),
chartWrap: document.getElementById('chart-wrap'),
colResizer: document.getElementById('col-resizer'),
};
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
// ---- date-collision detection ----
// Two bookings collide only if their date ranges genuinely overlap by at
// least one day. A booking ending the same day another starts is a normal
// same-day checkout/checkin handoff, not a collision (strict inequality
// on both sides excludes that exact boundary touch). Known limitation:
// two bookings that are BOTH a single, identical day (start === end for
// both) will not be flagged by this strict formula — an edge case not
// expected in practice for month-long board bookings.
function datesOverlap(a, b) {
const aStart = new Date(a.start_date + 'T00:00:00Z').getTime();
const aEnd = new Date(a.end_date + 'T00:00:00Z').getTime();
const bStart = new Date(b.start_date + 'T00:00:00Z').getTime();
const bEnd = new Date(b.end_date + 'T00:00:00Z').getTime();
return aStart < bEnd && bStart < aEnd;
}
// Returns a Map from booking index -> array of colliding partner indices.
// Comparison is scoped per board_id and only over the bookings array that
// was actually passed in (i.e. already filtered by city/dimension/search),
// matching what's visible on screen.
function computeCollisions(bookings) {
const byBoard = new Map();
bookings.forEach((bk, idx) => {
if (!byBoard.has(bk.board_id)) byBoard.set(bk.board_id, []);
byBoard.get(bk.board_id).push(idx);
});
const partners = new Map();
for (const idxs of byBoard.values()) {
for (let i = 0; i < idxs.length; i++) {
for (let j = i + 1; j < idxs.length; j++) {
const ia = idxs[i], ib = idxs[j];
if (datesOverlap(bookings[ia], bookings[ib])) {
if (!partners.has(ia)) partners.set(ia, []);
if (!partners.has(ib)) partners.set(ib, []);
partners.get(ia).push(ib);
partners.get(ib).push(ia);
}
}
}
}
return partners;
}
// ---- checkbox dropdown filter component ----
function createDropdown(id) {
const root = document.getElementById(id + '-dropdown');
const btn = document.getElementById(id + '-btn');
const btnText = document.getElementById(id + '-btn-text');
const panel = document.getElementById(id + '-panel');
let allValues = [];
let selected = new Set();
let defaultLabel = '';
let onChange = () => {};
function renderPanel() {
panel.innerHTML = '';
const actions = document.createElement('div');
actions.className = 'dp-actions';
const selAll = document.createElement('a');
selAll.textContent = 'Выбрать все';
selAll.addEventListener('click', () => { selected = new Set(allValues); syncCheckboxes(); updateBtnText(); onChange(); });
const selNone = document.createElement('a');
selNone.textContent = 'Сбросить';
selNone.addEventListener('click', () => { selected.clear(); syncCheckboxes(); updateBtnText(); onChange(); });
actions.appendChild(selAll);
actions.appendChild(selNone);
panel.appendChild(actions);
for (const v of allValues) {
const label = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = v;
cb.checked = selected.has(v);
cb.addEventListener('change', () => {
if (cb.checked) selected.add(v); else selected.delete(v);
updateBtnText();
onChange();
});
label.appendChild(cb);
label.appendChild(document.createTextNode(v));
panel.appendChild(label);
}
}
function syncCheckboxes() {
panel.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.checked = selected.has(cb.value); });
}
function updateBtnText() {
if (selected.size === 0) btnText.textContent = defaultLabel;
else if (selected.size === allValues.length) btnText.textContent = 'Все (' + allValues.length + ')';
else if (selected.size <= 2) btnText.textContent = Array.from(selected).join(', ');
else btnText.textContent = 'Выбрано: ' + selected.size;
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.dropdown.open').forEach(d => { if (d !== root) d.classList.remove('open'); });
root.classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!root.contains(e.target)) root.classList.remove('open');
});
return {
setValues(values, label) {
allValues = values;
defaultLabel = label;
selected = new Set();
updateBtnText();
renderPanel();
},
getSelected() { return Array.from(selected); },
onChange(fn) { onChange = fn; },
};
}
const cityDropdown = createDropdown('city');
const dimensionDropdown = createDropdown('dimension');
// ---- timeline setup ----
const groupsDS = new vis.DataSet();
const itemsDS = new vis.DataSet();
// Discrete zoom steps driving the header slider, one per 3-month increment
// from 3 to 30 months. The slider both gives a single always-visible
// control for the zoom level and hard-caps how far in/out a user can go, so
// the timeline can no longer be dragged into a degenerate (too dense / too
// sparse) view. zoomMin/zoomMax below mirror the same bounds as a
// defensive fallback. Index 3 (12 months) is the default shown on load —
// see init() below.
const zoomSteps = [
{ days: 91, label: '3 месяца' },
{ days: 183, label: '6 месяцев' },
{ days: 274, label: '9 месяцев' },
{ days: 365, label: '12 месяцев' },
{ days: 457, label: '15 месяцев' },
{ days: 548, label: '18 месяцев' },
{ days: 639, label: '21 месяц' },
{ days: 731, label: '24 месяца' },
{ days: 822, label: '27 месяцев' },
{ days: 913, label: '30 месяцев' },
];
const timeline = new vis.Timeline(el.timelineEl, itemsDS, groupsDS, {
editable: false,
selectable: false,
stack: true,
// Mouse-wheel / pinch zooming is intentionally disabled: zoom level is
// only changed via the header slider now (see zoomSteps + setScale()).
// `verticalScroll` takes over the wheel instead, scrolling the list of
// boards up/down (only kicks in once #timeline has a bounded height —
// see the CSS flex layout — so there's actually vertical overflow to
// scroll through).
zoomable: false,
moveable: true,
// No `height`/`maxHeight` set here at construction time: both were tried
// as static CSS-percentage-driven values and both proved unreliable
// (either always-bottom-anchored empty space with few rows, or scrolling
// silently stopping working again after a reload/resize). The height is
// instead computed as a real pixel number in JS and pushed in via
// `timeline.setOptions({height})` — see fitTimelineHeight() below, wired
// to the timeline's own `changed` event and to window resize, so it's
// always resynced against whatever is actually on screen right now.
verticalScroll: true,
zoomMin: zoomSteps[0].days * 86400000,
zoomMax: zoomSteps[zoomSteps.length - 1].days * 86400000,
groupHeightMode: 'fixed',
margin: { item: { horizontal: 2, vertical: 6 } },
orientation: { axis: 'top' },
tooltip: { followMouse: false, delay: 0 },
locale: 'ru',
});
// ---- reliable dynamic height / scroll fix ----
// Neither a static `height` nor a static `maxHeight` CSS-percentage value
// survives every combination of row count + page reload + window resize:
// `height` always forces the box to that size (bottom-anchoring short
// lists), while `maxHeight` stopped reliably enabling the scrollbar in some
// reload/resize scenarios. Instead we measure the REAL pixel sizes of the
// already-rendered DOM and push an explicit `height` in pixels via
// setOptions — recomputed every time vis-timeline redraws (`changed` event)
// and on window resize, so it can never go stale.
//
// Content height is read from `.vis-labelset`'s scrollHeight rather than
// `singleRowHeight * groupCount`: a per-row estimate undercounts whenever a
// board has multiple overlapping bookings stacked within its row (the row
// then needs more vertical space than a row with a single booking), which
// previously caused the container to end up shorter than the real content
// even with few boards — bars got clipped and an unwanted scrollbar showed
// up. `scrollHeight` reflects the label column's true, unclipped total
// height regardless of any per-row stacking, so it stays accurate.
let lastSetHeightPx = null;
function fitTimelineHeight() {
const available = el.chartWrap.clientHeight;
if (!available) return; // not laid out yet (e.g. display:none)
const labelSet = el.timelineEl.querySelector('.vis-labelset');
const axisEl = el.timelineEl.querySelector('.vis-panel.vis-top');
const groupCount = groupsDS.length;
const axisHeight = axisEl ? axisEl.offsetHeight : 0;
const contentHeight = labelSet ? axisHeight + labelSet.scrollHeight : 0;
// No usable measurements yet (first paint before rows exist) — skip and
// let the next `changed` event (which always follows real content being
// added) do the job instead of setting a bogus height now.
if (!labelSet && groupCount > 0) return;
const target = groupCount === 0 ? available : Math.min(contentHeight || available, available);
const px = Math.max(1, Math.round(target));
if (px === lastSetHeightPx) return;
lastSetHeightPx = px;
timeline.setOptions({ height: px + 'px' });
}
let heightFitQueued = false;
function scheduleHeightFit() {
if (heightFitQueued) return;
heightFitQueued = true;
requestAnimationFrame(() => {
heightFitQueued = false;
fitTimelineHeight();
});
}
timeline.on('changed', scheduleHeightFit);
window.addEventListener('resize', scheduleHeightFit);
scheduleHeightFit();
// ---- draggable label-column width ----
const MIN_LABEL_W = 140;
const MAX_LABEL_W = 760;
function currentLabelW() {
const v = getComputedStyle(document.documentElement).getPropertyValue('--label-w');
return parseInt(v, 10) || 340;
}
function positionResizer() {
const leftPanel = el.timelineEl.querySelector('.vis-panel.vis-left');
if (!leftPanel || el.timelineEl.style.display === 'none') {
el.colResizer.style.display = 'none';
return;
}
const wrapRect = el.chartWrap.getBoundingClientRect();
const panelRect = leftPanel.getBoundingClientRect();
el.colResizer.style.left = (panelRect.right - wrapRect.left) + 'px';
el.colResizer.style.display = 'block';
}
timeline.on('changed', positionResizer);
window.addEventListener('resize', positionResizer);
let colDragging = false;
let colDragStartX = 0;
let colDragStartW = 0;
let colDragLastX = 0;
let colDragRafPending = false;
function applyColDrag() {
colDragRafPending = false;
let w = colDragStartW + (colDragLastX - colDragStartX);
w = Math.max(MIN_LABEL_W, Math.min(MAX_LABEL_W, w));
document.documentElement.style.setProperty('--label-w', w + 'px');
timeline.redraw();
positionResizer();
}
el.colResizer.addEventListener('mousedown', (e) => {
colDragging = true;
colDragStartX = e.clientX;
colDragStartW = currentLabelW();
el.colResizer.classList.add('dragging');
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!colDragging) return;
colDragLastX = e.clientX;
if (colDragRafPending) return;
colDragRafPending = true;
requestAnimationFrame(applyColDrag);
});
window.addEventListener('mouseup', () => {
if (!colDragging) return;
colDragging = false;
el.colResizer.classList.remove('dragging');
document.body.style.userSelect = '';
document.body.style.cursor = '';
scheduleHeightFit();
});
let hoveredItemId = null;
timeline.on('itemover', (props) => {
hoveredItemId = props.item;
const item = itemsDS.get(props.item);
if (item) {
el.tooltip.textContent = item.tooltipText;
el.tooltip.style.display = 'block';
}
});
timeline.on('itemout', () => {
hoveredItemId = null;
el.tooltip.style.display = 'none';
});
document.addEventListener('mousemove', (e) => {
if (hoveredItemId !== null) {
el.tooltip.style.left = (e.clientX + 12) + 'px';
el.tooltip.style.top = (e.clientY + 12) + 'px';
}
});
// ---- row hover highlight ----
// vis-timeline's `mouseMove` event reports which group id (board) is under
// the cursor. Groups are rendered in the same `order` sequence as the
// `lastBoards` array (see render() below), so the nth board maps to the
// nth `.vis-label` / `.vis-group` DOM element — the same indexing trick the
// zebra striping already relies on. Classes are toggled directly on the DOM
// (not via the vis DataSet) so a mouse move never triggers a full redraw.
let hoveredRowIdx = -1;
function setRowHover(idx) {
if (idx === hoveredRowIdx) return;
const labels = document.querySelectorAll('#timeline .vis-label');
const rows = document.querySelectorAll('#timeline .vis-group');
if (hoveredRowIdx >= 0) {
if (labels[hoveredRowIdx]) labels[hoveredRowIdx].classList.remove('row-hover');
if (rows[hoveredRowIdx]) rows[hoveredRowIdx].classList.remove('row-hover');
}
if (idx >= 0) {
if (labels[idx]) labels[idx].classList.add('row-hover');
if (rows[idx]) rows[idx].classList.add('row-hover');
}
hoveredRowIdx = idx;
}
// ---- hovered-month column highlight ----
// A single `type: 'background'` item (no `group` set, so it spans the full
// height of all rows) is upserted into itemsDS with start/end snapped to
// calendar-month boundaries of whatever date is under the cursor. This
// always highlights exactly one month regardless of current zoom level —
// never a day, a quarter or a year — per spec. `lastMonthKey` avoids
// pushing a DataSet update (and the redraw it triggers) on every single
// mousemove, only when the cursor actually crosses into a different month.
let lastMonthKey = null;
const MONTH_BG_ID = '__month_hover_bg';
timeline.on('mouseMove', (props) => {
if (props.time) {
const t = props.time;
const y = t.getUTCFullYear();
const m = t.getUTCMonth();
const key = y + '-' + m;
if (key !== lastMonthKey) {
lastMonthKey = key;
const monthStart = new Date(Date.UTC(y, m, 1));
const monthEnd = new Date(Date.UTC(y, m + 1, 1));
itemsDS.update([{ id: MONTH_BG_ID, type: 'background', start: monthStart, end: monthEnd, className: 'month-hover-bg' }]);
}
}
if (props.group !== null && props.group !== undefined) {
const idx = lastBoards.findIndex(b => b.board_id === props.group);
setRowHover(idx);
} else {
setRowHover(-1);
}
});
el.timelineEl.addEventListener('mouseleave', () => {
lastMonthKey = null;
itemsDS.remove(MONTH_BG_ID);
setRowHover(-1);
});
el.showBrand.addEventListener('change', () => {
render(lastBoards, lastBookings, false);
});
el.showCompany.addEventListener('change', () => {
render(lastBoards, lastBookings, false);
});
el.showCollisions.addEventListener('change', () => {
render(lastBoards, lastBookings, false);
});
function setScale(days) {
const range = timeline.getWindow();
const center = (range.start.getTime() + range.end.getTime()) / 2;
const half = (days * 86400000) / 2;
timeline.setWindow(new Date(center - half), new Date(center + half));
}
el.zoomSlider.addEventListener('input', () => {
const step = zoomSteps[parseInt(el.zoomSlider.value, 10)];
el.zoomLabel.textContent = step.label;
setScale(step.days);
});
// ---- data loading ----
let lastBoards = [];
let lastBookings = [];
async function loadMeta() {
const r = await fetch('/api/meta');
const meta = await r.json();
cityDropdown.setValues(meta.cities, 'Все города');
dimensionDropdown.setValues(meta.dimensions, 'Все размеры');
}
async function loadData(fit) {
el.status.textContent = 'Загрузка…';
const params = new URLSearchParams({
city: cityDropdown.getSelected().join(','),
dimension: dimensionDropdown.getSelected().join(','),
search: el.search.value.trim(),
});
const [boardsRes, bookingsRes] = await Promise.all([
fetch('/api/boards?' + params.toString()),
fetch('/api/bookings?' + params.toString()),
]);
const boards = await boardsRes.json();
const bookings = await bookingsRes.json();
lastBoards = boards;
lastBookings = bookings;
render(boards, bookings, fit);
el.status.textContent = `Бордов: ${boards.length}, бронирований: ${bookings.length}`;
}
function render(boards, bookings, fit) {
if (boards.length === 0) {
groupsDS.clear();
itemsDS.clear();
el.timelineEl.style.display = 'none';
el.empty.style.display = 'block';
return;
}
el.timelineEl.style.display = '';
el.empty.style.display = 'none';
const withBrand = el.showBrand.checked;
const withCompany = el.showCompany.checked;
const withCollisions = el.showCollisions.checked;
const collisionPartners = withCollisions ? computeCollisions(bookings) : new Map();
// `order` pins the render order to the same alphabetical sequence the
// backend already returned (vis-timeline's default group order is
// otherwise "undetermined" without an explicit order field), so the
// even/odd className below reliably alternates row by visual row.
const groups = boards.map((b, idx) => ({
id: b.board_id,
content: escapeHtml(b.board_address) + ' (' + escapeHtml(b.board_id) + ')',
order: idx,
className: idx % 2 === 0 ? 'row-even' : 'row-odd',
}));
const items = bookings.map((bk, idx) => {
const end = new Date(bk.end_date + 'T00:00:00Z');
end.setUTCDate(end.getUTCDate() + 1); // make end exclusive so the last day is fully shown
const brand = bk.brand || '';
const company = bk.company_name || '';
// Bar: single line "brand | company_name". Each piece only appears
// when its own checkbox is on and the value is present.
const barParts = [];
if (withBrand && brand) barParts.push(escapeHtml(brand));
if (withCompany && company) barParts.push(escapeHtml(company));
const barContent = barParts.join(' | ');
// Tooltip always shows all three pieces of information on separate
// lines, regardless of the checkbox state.
const tooltipLines = [];
if (bk.task_id) tooltipLines.push(bk.task_id);
if (brand) tooltipLines.push(brand);
if (company) tooltipLines.push(company);
tooltipLines.push(bk.start_date + ' — ' + bk.end_date);
if (bk.task_status) tooltipLines.push(bk.task_status);
const partnerIdxs = collisionPartners.get(idx);
const isCollision = !!(partnerIdxs && partnerIdxs.length);
if (isCollision) {
tooltipLines.push('⚠ Коллизия с:');
for (const pIdx of partnerIdxs) {
const p = bookings[pIdx];
const pLabel = p.brand || p.company_name || 'Без названия';
tooltipLines.push(' ' + pLabel + ' ' + p.start_date + ' — ' + p.end_date);
}
}
const item = {
id: bk.board_id + '__' + idx,
group: bk.board_id,
start: bk.start_date,
end: end.toISOString().slice(0, 10),
content: barContent,
type: 'range',
tooltipText: tooltipLines.join('\n'),
};
const isArchived = bk.task_status === 'РК. Архив';
if (isCollision) item.className = 'collision';
else if (isArchived) item.className = 'archived';
return item;
});
groupsDS.clear();
groupsDS.add(groups);
itemsDS.clear();
itemsDS.add(items);
// The hovered-month background item lives in the same DataSet and gets
// wiped by the clear() above; reset the tracked key so the next
// mousemove re-creates it instead of silently no-op'ing.
lastMonthKey = null;
if (fit) timeline.fit({ animation: false });
// Safety net: `changed` normally fires after this DataSet update anyway,
// but scheduling explicitly here means the height is never left stale
// even in edge cases where vis-timeline batches/skips that event.
scheduleHeightFit();
}
let debounceTimer;
function scheduleReload() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => loadData(true), 250);
}
cityDropdown.onChange(scheduleReload);
dimensionDropdown.onChange(scheduleReload);
el.search.addEventListener('input', scheduleReload);
(async function init() {
await loadMeta();
await loadData(true);
// Default zoom on first load: 12 months (index 3 in zoomSteps), applied
// after the initial `fit` so it overrides vis-timeline's auto-fit window
// with our fixed default instead of whatever span the loaded data spans.
const defaultZoomIdx = 3;
el.zoomSlider.value = String(defaultZoomIdx);
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx].label;
setScale(zoomSteps[defaultZoomIdx].days);
})();
</script>
</body>
</html>