mapdash/static/index.html

913 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>