build: static/{index.html,assets} are generated artifacts (source-only tracking)
This commit is contained in:
parent
6704a50b33
commit
2d66871b62
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,6 +1,8 @@
|
||||
*.bak-*
|
||||
dist/
|
||||
node_modules/
|
||||
*.predeploy-*
|
||||
__pycache__/
|
||||
node_modules/
|
||||
dist/
|
||||
static/assets/
|
||||
static/index.html
|
||||
static/next/
|
||||
|
||||
@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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>
|
||||
Loading…
Reference in New Issue
Block a user