Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0c53e2ce9 | ||
|
|
17b70b1f44 | ||
|
|
3939dbf8f5 | ||
|
|
43bb72de61 | ||
|
|
691ec36b9e | ||
|
|
77a36a22d9 | ||
|
|
5ac2142c70 | ||
|
|
5902c1e7f0 | ||
|
|
147529c20f | ||
|
|
5ba5a77f97 |
@ -98,7 +98,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="field field-dates">
|
||||
<label>Период (даты)</label>
|
||||
<label>Период</label>
|
||||
<div class="daterange" id="daterange">
|
||||
<div class="dr-field" id="dr-field" tabindex="0" role="button" aria-label="Выбрать период">
|
||||
<svg class="dr-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/></svg>
|
||||
@ -164,13 +164,22 @@
|
||||
<div id="pane-timeline">
|
||||
<div id="chart-wrap">
|
||||
<div id="timeline"></div>
|
||||
<div id="col-info">
|
||||
<span class="ci-label">Показывать</span>
|
||||
<button type="button" class="ci-toggle" id="ci-city" data-part="city">Город</button>
|
||||
<button type="button" class="ci-toggle is-on" id="ci-address" data-part="address">Адрес</button>
|
||||
<button type="button" class="ci-toggle is-on" id="ci-code" data-part="code">Код</button>
|
||||
</div>
|
||||
<div class="field zoom-field" id="zoom-field">
|
||||
<span id="zoom-collapsed" class="zoom-collapsed">12М</span>
|
||||
<div class="zoom-full">
|
||||
<label for="zoom-slider">Масштаб графика</label>
|
||||
<div class="zoom-row">
|
||||
<span id="zoom-label" class="zoom-bubble">12М</span>
|
||||
<input type="range" id="zoom-slider" min="0" max="4" step="1" value="1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div>
|
||||
<div id="empty" class="empty-state" style="display:none">
|
||||
<svg class="empty-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mapdash-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.13",
|
||||
"version": "1.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
// Custom date-range picker. The two native <input type=date> (#date-start /
|
||||
// #date-end) stay in the DOM (hidden) as the source of truth — all filter code
|
||||
// keeps reading/writing them — while this renders the field + calendar popup and
|
||||
// dispatches `change` on them so the existing reload wiring fires.
|
||||
// keeps reading/writing them. The calendar selects into a temporary state; the
|
||||
// filter is applied (and the popup closed) only when the user presses «Применить».
|
||||
//
|
||||
// Two endpoint pills «С» / «По» sit above the grid: one is active, and clicking a
|
||||
// day writes into the active endpoint, then focus auto-advances to the other. Each
|
||||
// pill has a × to clear just that endpoint, so an OPEN range is possible — «с X»
|
||||
// (no end) or «по Y» (no start). «Применить» is enabled once at least one endpoint
|
||||
// is set; «Сбросить» clears both.
|
||||
|
||||
const MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
||||
const WEEKDAYS = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'];
|
||||
@ -14,7 +20,8 @@ function toISO(d: Date): string {
|
||||
}
|
||||
function fmt(iso: string): string {
|
||||
const p = iso.split('-');
|
||||
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso;
|
||||
// Short 2-digit year so the full range fits the compact "Период" field.
|
||||
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0].slice(2)}` : iso;
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
@ -32,6 +39,11 @@ export function createDateRange(): DateRange {
|
||||
|
||||
let viewY = new Date().getFullYear();
|
||||
let viewM = new Date().getMonth();
|
||||
// In-progress selection (committed to the inputs on «Применить»). Either end may
|
||||
// stay empty → an open range. `activeEnd` is the endpoint the next day-click fills.
|
||||
let selStart = '';
|
||||
let selEnd = '';
|
||||
let activeEnd: 'start' | 'end' = 'start';
|
||||
|
||||
function updateText(): void {
|
||||
const s = startInput.value;
|
||||
@ -45,9 +57,16 @@ export function createDateRange(): DateRange {
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const s = parseISO(startInput.value);
|
||||
const e = parseISO(endInput.value);
|
||||
const s = parseISO(selStart);
|
||||
const e = parseISO(selEnd);
|
||||
const endPill = (which: 'start' | 'end', label: string, val: string): string =>
|
||||
`<button type="button" class="dr-end${activeEnd === which ? ' active' : ''}${val ? ' set' : ''}" data-end="${which}">` +
|
||||
`<span class="dr-end-lbl">${label}</span>` +
|
||||
`<span class="dr-end-val">${val ? fmt(val) : '—'}</span>` +
|
||||
(val ? `<span class="dr-end-x" data-clear="${which}" aria-label="Очистить">×</span>` : '') +
|
||||
`</button>`;
|
||||
const parts: string[] = [
|
||||
`<div class="dr-ends">${endPill('start', 'С', selStart)}${endPill('end', 'По', selEnd)}</div>`,
|
||||
`<div class="dr-cal-h"><button type="button" class="dr-nav" data-nav="-1" aria-label="Предыдущий месяц">‹</button>` +
|
||||
`<span class="dr-cal-title">${MONTHS[viewM]} ${viewY}</span>` +
|
||||
`<button type="button" class="dr-nav" data-nav="1" aria-label="Следующий месяц">›</button></div>`,
|
||||
@ -61,39 +80,51 @@ export function createDateRange(): DateRange {
|
||||
const cur = new Date(viewY, viewM, d);
|
||||
const iso = toISO(cur);
|
||||
let cls = 'dr-day';
|
||||
if (iso === startInput.value) cls += ' s';
|
||||
if (iso === endInput.value) cls += ' e';
|
||||
if (iso === selStart) cls += ' s';
|
||||
if (iso === selEnd) cls += ' e';
|
||||
if (s && e && cur > s && cur < e) cls += ' rng';
|
||||
parts.push(`<button type="button" class="${cls}" data-iso="${iso}"><span class="dr-num">${d}</span></button>`);
|
||||
}
|
||||
parts.push('</div>');
|
||||
const ready = !!(selStart || selEnd); // at least one endpoint → applyable (open range OK)
|
||||
parts.push(
|
||||
'<div class="dr-foot">' +
|
||||
`<button type="button" class="dr-reset" data-act="reset">Сбросить</button>` +
|
||||
`<button type="button" class="dr-apply" data-act="apply"${ready ? '' : ' disabled'}>Применить</button>` +
|
||||
'</div>'
|
||||
);
|
||||
cal.innerHTML = parts.join('');
|
||||
}
|
||||
|
||||
function commit(): void {
|
||||
// Write the completed selection to the hidden inputs → triggers the filter reload.
|
||||
function applyRange(): void {
|
||||
startInput.value = selStart;
|
||||
endInput.value = selEnd;
|
||||
startInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
endInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
updateText();
|
||||
}
|
||||
|
||||
function pick(iso: string): void {
|
||||
const s = startInput.value;
|
||||
const e = endInput.value;
|
||||
if (!s || (s && e)) {
|
||||
// start a fresh range
|
||||
startInput.value = iso;
|
||||
endInput.value = '';
|
||||
} else if (iso >= s) {
|
||||
endInput.value = iso; // complete the range
|
||||
if (activeEnd === 'start') {
|
||||
selStart = iso;
|
||||
// Крест начала за концом — конец больше не валиден, очищаем его.
|
||||
if (selEnd && selStart > selEnd) selEnd = '';
|
||||
if (!selEnd) activeEnd = 'end'; // авто-переход к незаданному концу
|
||||
} else {
|
||||
startInput.value = iso; // clicked before the start — move the start
|
||||
selEnd = iso;
|
||||
if (selStart && selEnd < selStart) selStart = '';
|
||||
if (!selStart) activeEnd = 'start';
|
||||
}
|
||||
commit();
|
||||
render();
|
||||
}
|
||||
|
||||
function open(): void {
|
||||
const base = parseISO(startInput.value) || new Date();
|
||||
selStart = startInput.value;
|
||||
selEnd = endInput.value;
|
||||
// Start filling whichever endpoint is empty (start first).
|
||||
activeEnd = !selStart ? 'start' : !selEnd ? 'end' : 'start';
|
||||
const base = parseISO(selStart) || parseISO(selEnd) || new Date();
|
||||
viewY = base.getFullYear();
|
||||
viewM = base.getMonth();
|
||||
render();
|
||||
@ -113,12 +144,19 @@ export function createDateRange(): DateRange {
|
||||
});
|
||||
clearBtn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
selStart = '';
|
||||
selEnd = '';
|
||||
startInput.value = '';
|
||||
endInput.value = '';
|
||||
commit();
|
||||
startInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
endInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
updateText();
|
||||
close();
|
||||
});
|
||||
cal.addEventListener('click', (ev) => {
|
||||
// Keep the click inside the calendar so re-rendering a clicked day doesn't
|
||||
// detach the target and trip the outside-click close below.
|
||||
ev.stopPropagation();
|
||||
const target = ev.target as HTMLElement;
|
||||
const nav = target.closest<HTMLElement>('.dr-nav');
|
||||
if (nav) {
|
||||
@ -127,6 +165,34 @@ export function createDateRange(): DateRange {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const act = target.closest<HTMLElement>('[data-act]');
|
||||
if (act) {
|
||||
if (act.dataset.act === 'apply') {
|
||||
if (selStart || selEnd) { applyRange(); close(); }
|
||||
} else if (act.dataset.act === 'reset') {
|
||||
selStart = '';
|
||||
selEnd = '';
|
||||
activeEnd = 'start';
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Clear one endpoint via its × (→ open range on that side). Checked before the
|
||||
// pill activate below, since the × lives inside the pill button.
|
||||
const clr = target.closest<HTMLElement>('[data-clear]');
|
||||
if (clr) {
|
||||
const which = clr.dataset.clear as 'start' | 'end';
|
||||
if (which === 'start') selStart = ''; else selEnd = '';
|
||||
activeEnd = which; // focus the emptied end for the next pick
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const endBtn = target.closest<HTMLElement>('[data-end]');
|
||||
if (endBtn) {
|
||||
activeEnd = endBtn.dataset.end as 'start' | 'end';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const day = target.closest<HTMLElement>('.dr-day');
|
||||
if (day && day.dataset.iso) pick(day.dataset.iso);
|
||||
});
|
||||
@ -138,7 +204,12 @@ export function createDateRange(): DateRange {
|
||||
return {
|
||||
sync(): void {
|
||||
updateText();
|
||||
if (!cal.hidden) render();
|
||||
if (!cal.hidden) {
|
||||
selStart = startInput.value;
|
||||
selEnd = endInput.value;
|
||||
activeEnd = !selStart ? 'start' : !selEnd ? 'end' : 'start';
|
||||
render();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -35,6 +35,8 @@ const el = {
|
||||
showAllSurfaces: document.getElementById('show-all-surfaces') as HTMLInputElement,
|
||||
zoomSlider: document.getElementById('zoom-slider') as HTMLInputElement,
|
||||
zoomLabel: document.getElementById('zoom-label') as HTMLElement,
|
||||
zoomCollapsed: document.getElementById('zoom-collapsed') as HTMLElement,
|
||||
zoomField: document.getElementById('zoom-field') as HTMLElement,
|
||||
chartWrap: document.getElementById('chart-wrap') as HTMLElement,
|
||||
colResizer: document.getElementById('col-resizer') as HTMLElement,
|
||||
decorBtn: document.getElementById('decor-btn') as HTMLButtonElement,
|
||||
@ -134,17 +136,32 @@ const mapView = createMapView(
|
||||
let lastBoards: Board[] = [];
|
||||
let lastBookings: Booking[] = [];
|
||||
|
||||
// Which info elements the first column shows — toggled in the column header,
|
||||
// persisted per browser. Available to every role (address/code already shown to
|
||||
// anonymous; city is not sensitive). A row is never empty (see the toggle handler).
|
||||
const CI_LS = 'mapdash.infoCols';
|
||||
type InfoCols = { city: boolean; address: boolean; code: boolean };
|
||||
const infoCols: InfoCols = ((): InfoCols => {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem(CI_LS) || 'null');
|
||||
if (s && typeof s.city === 'boolean' && typeof s.address === 'boolean' && typeof s.code === 'boolean') return s;
|
||||
} catch { /* fall through to default */ }
|
||||
return { city: false, address: true, code: true };
|
||||
})();
|
||||
|
||||
function flags(): RenderFlags {
|
||||
const cols = { showCity: infoCols.city, showAddress: infoCols.address, showCode: infoCols.code };
|
||||
// Anonymous tier: no labels (data is blanked anyway) and no collision
|
||||
// highlighting, so every bar renders in the single neutral status colour.
|
||||
if (!isManager()) {
|
||||
return { withBrand: false, withCompany: false, withCollisions: false, managerView: false };
|
||||
return { withBrand: false, withCompany: false, withCollisions: false, managerView: false, ...cols };
|
||||
}
|
||||
return {
|
||||
withBrand: el.showBrand.checked,
|
||||
withCompany: el.showCompany.checked,
|
||||
withCollisions: el.showCollisions.checked,
|
||||
managerView: true,
|
||||
...cols,
|
||||
};
|
||||
}
|
||||
|
||||
@ -403,10 +420,25 @@ async function refreshMap(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
let mapDestroyTimer = 0;
|
||||
async function onModeChange(mode: ViewMode): Promise<void> {
|
||||
if (mode !== 'map') view.applyLayout(); // timeline visible (timeline or split)
|
||||
if (mode === 'timeline') mapView.closePanel(); // map hidden — drop its floating card
|
||||
if (mode !== 'timeline') await refreshMap(); // map visible (map or split)
|
||||
if (mode === 'timeline') {
|
||||
mapView.closePanel(); // map hidden — drop its floating card
|
||||
// Free the Yandex map (tiles/canvas/GPU — the bulk of the tab's memory) a
|
||||
// couple of seconds after it's closed; cancelled if a map view returns, so
|
||||
// quick График⇄Карта toggles don't thrash a re-init.
|
||||
window.clearTimeout(mapDestroyTimer);
|
||||
mapDestroyTimer = window.setTimeout(() => {
|
||||
mapView.destroy();
|
||||
// The fresh map/ObjectManager will be empty, so drop the "already loaded"
|
||||
// key — the next open re-fetches + re-renders instead of short-circuiting.
|
||||
mapLoadedKey = '';
|
||||
}, 2000);
|
||||
} else {
|
||||
window.clearTimeout(mapDestroyTimer); // map visible again — keep it
|
||||
await refreshMap(); // map visible (map or split); ensureInit rebuilds if destroyed
|
||||
}
|
||||
}
|
||||
|
||||
const viewModes = createViewModes(
|
||||
@ -460,11 +492,44 @@ function updateZoomUi(): void {
|
||||
el.zoomSlider.addEventListener('input', () => {
|
||||
const step = zoomSteps[parseInt(el.zoomSlider.value, 10)]!;
|
||||
el.zoomLabel.textContent = step.label;
|
||||
el.zoomCollapsed.textContent = step.label;
|
||||
view.setScale(step.days);
|
||||
updateZoomUi();
|
||||
});
|
||||
window.addEventListener('resize', updateZoomUi);
|
||||
|
||||
// The scale plate is collapsed to a small value pill by default; expand on hover
|
||||
// and collapse again 1s after the pointer leaves (so it doesn't snap shut while
|
||||
// you're reaching back toward it).
|
||||
let zoomCollapseTimer = 0;
|
||||
el.zoomField.addEventListener('mouseenter', () => {
|
||||
window.clearTimeout(zoomCollapseTimer);
|
||||
el.zoomField.classList.add('expanded');
|
||||
updateZoomUi(); // realign the bubble now that the slider has a real width
|
||||
});
|
||||
el.zoomField.addEventListener('mouseleave', () => {
|
||||
window.clearTimeout(zoomCollapseTimer);
|
||||
zoomCollapseTimer = window.setTimeout(() => el.zoomField.classList.remove('expanded'), 1000);
|
||||
});
|
||||
|
||||
// Column-info header: toggle city / address / code shown in the first column.
|
||||
const ciButtons = Array.from(document.querySelectorAll<HTMLElement>('.ci-toggle'));
|
||||
function syncInfoButtons(): void {
|
||||
for (const btn of ciButtons) btn.classList.toggle('is-on', infoCols[btn.dataset.part as keyof InfoCols]);
|
||||
}
|
||||
for (const btn of ciButtons) {
|
||||
btn.addEventListener('click', () => {
|
||||
const part = btn.dataset.part as keyof InfoCols;
|
||||
// Never leave the row empty: can't turn off the last remaining part.
|
||||
if (infoCols[part] && !(['city', 'address', 'code'] as (keyof InfoCols)[]).some((k) => k !== part && infoCols[k])) return;
|
||||
infoCols[part] = !infoCols[part];
|
||||
localStorage.setItem(CI_LS, JSON.stringify(infoCols));
|
||||
syncInfoButtons();
|
||||
view.refreshLabels(flags());
|
||||
});
|
||||
}
|
||||
syncInfoButtons();
|
||||
|
||||
// Clicking a surface in the timeline's left column flies the map to it: switch to
|
||||
// the combined "Карта и график" view if only the timeline is open, then focus the
|
||||
// surface's cluster. Works for anonymous and manager users alike.
|
||||
@ -494,6 +559,7 @@ async function init(): Promise<void> {
|
||||
const defaultZoomIdx = 1; // 12М
|
||||
el.zoomSlider.value = String(defaultZoomIdx);
|
||||
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label;
|
||||
el.zoomCollapsed.textContent = zoomSteps[defaultZoomIdx]!.label;
|
||||
view.setScale(zoomSteps[defaultZoomIdx]!.days);
|
||||
updateZoomUi();
|
||||
// Apply the persisted view mode (inits the map if it starts visible).
|
||||
|
||||
@ -63,6 +63,9 @@ export interface MapView {
|
||||
ensureInit(): Promise<void>;
|
||||
invalidateSize(): void;
|
||||
closePanel(): void;
|
||||
/** Destroy the Yandex map and free its tiles/canvas/GPU memory. The next
|
||||
* ensureInit() re-creates it from scratch. */
|
||||
destroy(): void;
|
||||
/** Pan + zoom the map to the surface's location (its cluster). No-op if it has no coords. */
|
||||
focusBoard(boardId: string): void;
|
||||
}
|
||||
@ -213,6 +216,10 @@ function setupBalloonPhotos(): void {
|
||||
interface Panel { el: HTMLElement; title: HTMLElement; tabs: HTMLElement; body: HTMLElement; surfaces: MapSurface[]; }
|
||||
interface PanelPlace { x: number; y: number; bounds: DOMRect; }
|
||||
let mpanel: Panel | null = null;
|
||||
// Timestamp of the last panel open. Yandex fires the map's own 'click' for the
|
||||
// very same click that opened a cluster panel, so the map-click close handler
|
||||
// ignores clicks landing within a short window of an open.
|
||||
let panelOpenedAt = 0;
|
||||
|
||||
// Open the panel next to the clicked cluster, offset toward the free side: which
|
||||
// half of the map the cluster sits in decides the direction (e.g. bottom-left
|
||||
@ -317,6 +324,7 @@ function showMapPanel(surfaces: MapSurface[], idx: number, place: PanelPlace): v
|
||||
p.el.style.animation = 'none';
|
||||
void p.el.offsetHeight;
|
||||
p.el.style.animation = '';
|
||||
panelOpenedAt = Date.now();
|
||||
}
|
||||
|
||||
function closeMapPanel(): void {
|
||||
@ -608,6 +616,12 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
|
||||
const list = objs.map((o: any) => drawn[o.id as number]).filter(Boolean);
|
||||
if (list.length) showMapPanel(list, 0, clusterPlace(mx, my));
|
||||
});
|
||||
// Clicking the empty map (anywhere but a cluster) closes an open panel.
|
||||
// Yandex also fires this map 'click' for the same click that opened a
|
||||
// cluster panel, so skip closes within a short window of an open.
|
||||
map.events.add('click', () => {
|
||||
if (Date.now() - panelOpenedAt > 250) closeMapPanel();
|
||||
});
|
||||
|
||||
if (pending) {
|
||||
draw(pending);
|
||||
@ -622,6 +636,20 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
|
||||
closePanel(): void {
|
||||
closeMapPanel();
|
||||
},
|
||||
destroy(): void {
|
||||
closeMapPanel();
|
||||
// map.destroy() tears down Yandex's DOM inside #map and releases its tile
|
||||
// cache / canvas / GPU textures. Our legend/counter overlays are siblings
|
||||
// of #map, so they survive. Reset state so ensureInit() rebuilds cleanly.
|
||||
if (map) {
|
||||
try { map.destroy(); } catch { /* already torn down */ }
|
||||
}
|
||||
map = null;
|
||||
objectManager = null;
|
||||
initPromise = null;
|
||||
pendingFocus = null;
|
||||
pending = null;
|
||||
},
|
||||
focusBoard(boardId: string): void {
|
||||
pendingFocus = boardId;
|
||||
applyFocus(); // applies now if already rendered, else waits for the next draw
|
||||
|
||||
@ -35,6 +35,7 @@
|
||||
--row-odd: #ffffff;
|
||||
--row-hover: #fff3cd;
|
||||
--month-hover: rgba(79, 70, 229, .08);
|
||||
--cur-month: rgba(79, 70, 229, .07);
|
||||
--tip-bg: var(--gray-9);
|
||||
--tip-text: #ffffff;
|
||||
--tip-sub: var(--gray-4);
|
||||
@ -60,6 +61,7 @@
|
||||
--row-odd: #0f1420;
|
||||
--row-hover: #33361f;
|
||||
--month-hover: rgba(91, 141, 239, .12);
|
||||
--cur-month: rgba(91, 141, 239, .09);
|
||||
--tip-bg: #0b0f18;
|
||||
}
|
||||
|
||||
@ -107,12 +109,12 @@ header {
|
||||
.hgroup-fields {
|
||||
flex-shrink: 4; min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(130px, 1fr));
|
||||
grid-template-columns: repeat(4, 195px);
|
||||
gap: 12px 14px;
|
||||
align-items: start;
|
||||
align-content: start;
|
||||
}
|
||||
/* Compact grid: 4 dropdowns on row 1; the two dates + search fall to row 2. */
|
||||
/* Compact grid: equal 195px columns; fields flow left-to-right and wrap. */
|
||||
.hgroup-fields .field { min-width: 0; }
|
||||
.hgroup-fields .dropdown,
|
||||
.hgroup-fields .dropdown-btn,
|
||||
@ -123,7 +125,7 @@ header {
|
||||
.hgroup-fields { align-items: flex-start; }
|
||||
|
||||
/* ---- date-range field + calendar popup ---- */
|
||||
.field-dates { grid-column: span 2; }
|
||||
.field-dates .dr-field { max-width: 195px; } /* "Период" matches the other fields (short 2-digit-year date fits) */
|
||||
.dr-native { display: none; }
|
||||
.daterange { position: relative; }
|
||||
.dr-field {
|
||||
@ -144,6 +146,18 @@ header {
|
||||
box-shadow: var(--shadow-md); padding: 10px; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dr-cal[hidden] { display: none; }
|
||||
/* Endpoint pills «С» / «По» above the calendar — styled like the active-filter
|
||||
chips (accent-weak fill, accent text, pill shape); the active one (next click
|
||||
target) gets an accent border; × clears just that endpoint. */
|
||||
.dr-ends { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||
.dr-end { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 5px; padding: 4px 5px 4px 10px; border: 1px solid transparent; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: 12px; font-weight: 500; cursor: pointer; text-align: left; }
|
||||
.dr-end:hover { border-color: var(--accent-ring); }
|
||||
.dr-end.active { border-color: var(--accent); }
|
||||
.dr-end-lbl { flex: 0 0 auto; opacity: .65; }
|
||||
.dr-end-val { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.dr-end:not(.set) .dr-end-val { opacity: .55; }
|
||||
.dr-end-x { flex: 0 0 auto; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; color: inherit; font-size: 15px; line-height: 1; }
|
||||
.dr-end-x:hover { background: rgba(79, 70, 229, .18); }
|
||||
.dr-cal-h { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.dr-cal-title { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.dr-nav { border: none; background: none; color: var(--text-muted); cursor: pointer; font-size: 18px; line-height: 1; width: 26px; height: 26px; border-radius: var(--radius-sm); }
|
||||
@ -152,14 +166,18 @@ header {
|
||||
.dr-wd { text-align: center; font-size: 11px; color: var(--text-muted); padding-bottom: 4px; }
|
||||
.dr-day { position: relative; border: none; background: none; padding: 2px 0; cursor: pointer; display: flex; align-items: center; justify-content: center; height: 30px; }
|
||||
.dr-day::before { content: ""; position: absolute; inset: 0; z-index: 0; }
|
||||
.dr-day.rng::before { background: #eef0fe; }
|
||||
.dr-day.s::before { background: linear-gradient(to right, transparent 50%, #eef0fe 50%); }
|
||||
.dr-day.e::before { background: linear-gradient(to left, transparent 50%, #eef0fe 50%); }
|
||||
.dr-day.s.e::before { background: transparent; }
|
||||
.dr-num { position: relative; z-index: 1; display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; font-size: 12px; color: var(--text); }
|
||||
/* In-range days: a separate light circle, same 27px diameter as the endpoints. */
|
||||
.dr-day.rng::before { inset: auto; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 27px; height: 27px; border-radius: 50%; background: #eef0fe; }
|
||||
.dr-num { position: relative; z-index: 1; display: inline-flex; align-items: center; justify-content: center; width: 27px; height: 27px; border-radius: 50%; font-size: 12px; color: var(--text); }
|
||||
.dr-day:hover .dr-num { background: var(--surface-2); }
|
||||
.dr-day.s .dr-num, .dr-day.e .dr-num { background: var(--accent); color: #fff; font-weight: 600; }
|
||||
.dr-day.s:hover .dr-num, .dr-day.e:hover .dr-num { background: var(--accent); }
|
||||
.dr-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||
.dr-reset { border: none; background: none; color: var(--text-muted); cursor: pointer; font-size: 13px; padding: 7px 10px; border-radius: var(--radius-sm); }
|
||||
.dr-reset:hover { background: var(--surface); color: var(--text); }
|
||||
.dr-apply { border: none; background: var(--accent); color: #fff; cursor: pointer; font-size: 13px; font-weight: 600; padding: 7px 16px; border-radius: var(--radius-sm); transition: background var(--transition); }
|
||||
.dr-apply:hover { background: var(--accent-strong, #4338ca); }
|
||||
.dr-apply:disabled { opacity: .45; cursor: default; }
|
||||
.fcol { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
input[type=date],
|
||||
@ -200,7 +218,10 @@ input::placeholder { color: var(--text-muted); }
|
||||
top, the three checkboxes stacked below. */
|
||||
.hgroup-controls { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.zoom-row { display: flex; align-items: center; gap: 8px; }
|
||||
.checks { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
|
||||
/* Toggles: aligned to the filter *controls* (not the labels) — Бренд + Компания
|
||||
share the top row, then Коллизии and Все поверхности, evenly distributed
|
||||
between the top of the first control box and the bottom of Поиск. */
|
||||
.checks { display: flex; flex-direction: column; align-items: flex-start; justify-content: space-between; flex: 1; align-self: stretch; padding: 19px 0 0; }
|
||||
.checks .checkbox-field { align-self: flex-start; }
|
||||
.checks-row { display: flex; flex-direction: row; align-items: center; gap: 18px; }
|
||||
|
||||
@ -243,16 +264,36 @@ input::placeholder { color: var(--text-muted); }
|
||||
.checkbox-field input[type=checkbox]:checked::after { left: 16px; }
|
||||
.checkbox-field input[type=checkbox]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.zoom-field { gap: 4px; }
|
||||
.zoom-field label { white-space: nowrap; }
|
||||
.zoom-field .zoom-row { position: relative; width: 120px; padding-bottom: 24px; display: block; }
|
||||
/* The scale control floats over the top-right of the timeline, just below the
|
||||
time axis (JS sets `top` = axis bottom + 5px; this is only a fallback). */
|
||||
/* Scale control floats over the top-right of the timeline (JS sets `top` = axis
|
||||
bottom + 5px). Collapsed by default: a small plate showing only the value pill.
|
||||
JS toggles `.expanded` (on hover; collapse is delayed ~1s after mouseleave).
|
||||
Fixed sizes + overflow:hidden let width/height animate; the two faces cross-fade. */
|
||||
#chart-wrap #zoom-field {
|
||||
position: absolute; top: 52px; right: 16px; z-index: 20;
|
||||
background: rgba(255, 255, 255, .92); border-radius: 8px;
|
||||
padding: 3px 12px 4px; box-shadow: var(--shadow-md);
|
||||
width: 65px; height: 35px; overflow: hidden;
|
||||
background: rgba(255, 255, 255, .95); border-radius: 8px; box-shadow: var(--shadow-md);
|
||||
transition: width .24s var(--ease-3), height .24s var(--ease-3);
|
||||
}
|
||||
#chart-wrap #zoom-field.expanded { width: 145px; height: 80px; }
|
||||
/* Collapsed face: the accent value pill, centred in the small plate. */
|
||||
.zoom-collapsed {
|
||||
position: absolute; inset: 0; margin: auto; width: fit-content; height: fit-content;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--accent); color: #fff; font-size: 11px; font-weight: 600;
|
||||
padding: 3px 9px; border-radius: 6px; white-space: nowrap; cursor: pointer;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 1; transition: opacity .14s ease .06s;
|
||||
}
|
||||
#chart-wrap #zoom-field.expanded .zoom-collapsed { opacity: 0; transition-delay: 0s; pointer-events: none; }
|
||||
/* Expanded face: label + slider + bubble. */
|
||||
.zoom-full {
|
||||
position: absolute; top: 4px; left: 0; padding: 3px 12px 4px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
opacity: 0; pointer-events: none; transition: opacity .14s ease;
|
||||
}
|
||||
#chart-wrap #zoom-field.expanded .zoom-full { opacity: 1; pointer-events: auto; transition-delay: .08s; }
|
||||
.zoom-field input[type=range] {
|
||||
-webkit-appearance: none; appearance: none; width: 100%; height: 6px; margin: 0;
|
||||
border-radius: 4px; cursor: pointer; outline: none;
|
||||
@ -279,6 +320,33 @@ input::placeholder { color: var(--text-muted); }
|
||||
border: 4px solid transparent; border-bottom-color: var(--accent); border-top: 0;
|
||||
}
|
||||
|
||||
/* Column-info header: fills the empty top-left corner (above the row labels).
|
||||
Toggle which of city / address / code each first-column row shows. */
|
||||
#chart-wrap #col-info {
|
||||
position: absolute; top: 0; left: 0; width: var(--label-w); z-index: 21;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 0 12px; box-sizing: border-box; overflow: hidden;
|
||||
background: var(--bg); border-bottom: 1px solid var(--border);
|
||||
container-type: inline-size;
|
||||
}
|
||||
.ci-label { flex: 0 0 auto; font-size: 11px; color: var(--text-muted); white-space: nowrap; }
|
||||
/* Narrow column: drop the "Показывать" label so the three pills still fit. */
|
||||
@container (max-width: 255px) { #chart-wrap #col-info .ci-label { display: none; } }
|
||||
.ci-toggle {
|
||||
flex: 0 0 auto; border: none; cursor: pointer; white-space: nowrap;
|
||||
font-size: 12px; font-weight: 500; padding: 5px 11px; border-radius: 999px;
|
||||
background: var(--accent-weak); color: var(--accent);
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.ci-toggle.is-on { background: var(--accent); color: #fff; }
|
||||
.ci-toggle:not(.is-on):hover { background: var(--accent-ring); }
|
||||
/* Row label parts, separated by a 7px dot; address is primary, city/code muted. */
|
||||
.ci-cell { display: inline-flex; align-items: center; flex-wrap: wrap; }
|
||||
.ci-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #c1c4ef; margin: 0 9px; flex: 0 0 auto; }
|
||||
.ci-city { color: var(--text-muted); }
|
||||
.ci-addr { color: var(--text); }
|
||||
.ci-code { color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* checkbox dropdown filter */
|
||||
.dropdown { position: relative; }
|
||||
.dropdown-btn {
|
||||
@ -295,7 +363,12 @@ input::placeholder { color: var(--text-muted); }
|
||||
display: none; position: absolute; top: calc(100% + 4px); left: 0;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md); padding: 6px 0;
|
||||
min-width: 220px; max-height: 280px; overflow-y: auto; z-index: 30;
|
||||
/* Fit the widest option instead of a fixed width, capped at 350px — wider
|
||||
content then scrolls horizontally. The .dp-search fills via fill-available
|
||||
(not width:100%, whose % included the scrollbar area → clipped border +
|
||||
phantom horizontal scrollbar). */
|
||||
width: max-content; min-width: 220px; max-width: 350px;
|
||||
max-height: 280px; overflow: auto; z-index: 30;
|
||||
}
|
||||
.dropdown.open .dropdown-panel { display: block; }
|
||||
.dropdown-panel label {
|
||||
@ -313,14 +386,23 @@ input::placeholder { color: var(--text-muted); }
|
||||
}
|
||||
.dropdown-panel .dp-actions a { font-size: 11px; color: var(--accent); cursor: pointer; text-decoration: none; font-weight: 600; }
|
||||
.dropdown-panel .dp-actions a:hover { text-decoration: underline; }
|
||||
/* In-dropdown search that filters the option list. */
|
||||
.dropdown-panel .dp-search {
|
||||
display: block; width: 100%; box-sizing: border-box; padding: 8px 12px;
|
||||
font-size: 13px; color: var(--text); background: var(--bg);
|
||||
border: none; border-bottom: 1px solid var(--border); outline: none;
|
||||
/* In-dropdown search that filters the option list — a clean, evenly-bordered box.
|
||||
Selector carries `input` so it out-specifies `.hgroup-fields input[type=text]
|
||||
{ width: 100% }` (0,2,1), which would otherwise force width:100% and clip the
|
||||
border / spawn a phantom horizontal scrollbar. */
|
||||
.dropdown-panel input.dp-search {
|
||||
display: block; box-sizing: border-box; margin: 3px 8px 6px;
|
||||
/* Fill the available width MINUS the vertical scrollbar (so the border is never
|
||||
clipped and no phantom horizontal scrollbar appears); calc(100%-16px) is the
|
||||
fallback for engines without fill-available. */
|
||||
width: calc(100% - 16px);
|
||||
width: -moz-available;
|
||||
width: -webkit-fill-available;
|
||||
padding: 7px 10px; font-size: 13px; color: var(--text); background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm); outline: none;
|
||||
}
|
||||
.dropdown-panel .dp-search::placeholder { color: var(--text-muted); }
|
||||
.dropdown-panel .dp-search:focus { border-bottom-color: var(--accent); }
|
||||
.dropdown-panel .dp-search:focus { border-color: var(--border-strong); background: var(--bg); }
|
||||
|
||||
/* Оформление button + context menu */
|
||||
/* Pinned to the header's bottom-right corner. */
|
||||
@ -371,12 +453,12 @@ input::placeholder { color: var(--text-muted); }
|
||||
.decor-stepper { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.decor-stepper .ds-btn {
|
||||
display: flex; align-items: center; justify-content: center; padding: 0;
|
||||
width: 22px; height: 28px; border: none; background: var(--surface);
|
||||
color: var(--accent); font-size: 16px; line-height: 1; cursor: pointer;
|
||||
width: 20px; height: 28px; border: none; background: var(--surface);
|
||||
color: var(--accent); font-size: 15px; line-height: 1; cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.decor-stepper .ds-btn:hover { background: var(--surface-2); }
|
||||
.decor-stepper .ds-input {
|
||||
.decor-stepper input.ds-input {
|
||||
width: 38px; height: 28px; text-align: center; padding: 0 2px;
|
||||
border: none; border-left: 1px solid var(--border); border-right: 1px solid var(--border);
|
||||
border-radius: 0; font-size: 13px; background: var(--bg); color: var(--text);
|
||||
@ -691,10 +773,14 @@ html.role-pending .manager-only { display: none !important; }
|
||||
/* Row hover */
|
||||
.vis-label.row-hover, .vis-group.row-hover { background-color: var(--row-hover) !important; }
|
||||
/* Fixed/resizable label column */
|
||||
.vis-label .vis-inner {
|
||||
/* #timeline lifts specificity above vis-timeline's own `.vis-labelset .vis-label
|
||||
.vis-inner { padding: 5px }`, so the right padding actually applies. */
|
||||
#timeline .vis-label .vis-inner {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
min-width: var(--label-w);
|
||||
max-width: var(--label-w);
|
||||
padding-left: 7px; padding-right: 7px; /* keep the address off the column borders */
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle;
|
||||
}
|
||||
|
||||
@ -703,6 +789,7 @@ html.role-pending .manager-only { display: none !important; }
|
||||
.vis-panel.vis-center, .vis-panel.vis-left, .vis-panel.vis-right,
|
||||
.vis-panel.vis-top, .vis-panel.vis-bottom { border-color: var(--border); }
|
||||
.vis-time-axis .vis-text { color: var(--text-muted); }
|
||||
.vis-time-axis .vis-text.vis-major { color: var(--accent); font-weight: 500; }
|
||||
.vis-time-axis .vis-grid.vis-minor { border-color: var(--grid-line); }
|
||||
.vis-time-axis .vis-grid.vis-major { border-color: var(--border-strong); }
|
||||
.vis-labelset .vis-label,
|
||||
@ -721,6 +808,7 @@ html.role-pending .manager-only { display: none !important; }
|
||||
|
||||
/* Hovered-month column highlight */
|
||||
.vis-item.vis-background.month-hover-bg { background-color: var(--month-hover); }
|
||||
.vis-item.vis-background.cur-month-bg { background-color: var(--cur-month); }
|
||||
|
||||
/* Rich hover tooltip for timeline bars (structured HTML, built in timeline.ts).
|
||||
Shares the dark look of the map tooltip (.map-tip) for a consistent feel. */
|
||||
|
||||
@ -14,6 +14,10 @@ export interface RenderFlags {
|
||||
// Manager tier: show the rich tooltip (title, task №, status, manager, Planfix
|
||||
// hint). Anonymous tier gets a minimal tooltip with only the date range.
|
||||
managerView: boolean;
|
||||
// Which info elements the first column shows (toggled in the column header).
|
||||
showCity: boolean;
|
||||
showAddress: boolean;
|
||||
showCode: boolean;
|
||||
}
|
||||
|
||||
export interface TimelineElements {
|
||||
@ -24,6 +28,19 @@ export interface TimelineElements {
|
||||
colResizer: HTMLElement;
|
||||
}
|
||||
|
||||
// First-column label: city · address · code (only the parts enabled in the
|
||||
// header), separated by a small dot. Falls back to the address so a row is never
|
||||
// empty (the header also forbids turning every part off).
|
||||
function groupContent(b: Board, flags: RenderFlags): string {
|
||||
const dot = '<span class="ci-dot" aria-hidden="true"></span>';
|
||||
const parts: string[] = [];
|
||||
if (flags.showCity && b.board_city) parts.push(`<span class="ci-city">${escapeHtml(b.board_city)}</span>`);
|
||||
if (flags.showAddress && b.board_address) parts.push(`<span class="ci-addr">${escapeHtml(b.board_address)}</span>`);
|
||||
if (flags.showCode && b.board_id) parts.push(`<span class="ci-code">${escapeHtml(b.board_id)}</span>`);
|
||||
if (!parts.length) parts.push(`<span class="ci-addr">${escapeHtml(b.board_address)}</span>`);
|
||||
return `<span class="ci-cell">${parts.join(dot)}</span>`;
|
||||
}
|
||||
|
||||
// Clicking a bar opens the corresponding Planfix task.
|
||||
const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/';
|
||||
|
||||
@ -36,7 +53,21 @@ export const zoomSteps: ReadonlyArray<{ days: number; label: string }> = [
|
||||
];
|
||||
|
||||
const MONTH_BG_ID = '__month_hover_bg';
|
||||
const MIN_LABEL_W = 140;
|
||||
const CUR_MONTH_BG_ID = '__cur_month_bg';
|
||||
const MIN_LABEL_W = 200; // fits the three column-info pills (~188px) without clipping
|
||||
|
||||
// Uniform 3-letter month labels (moment's ru `MMM` mixes full/abbreviated with
|
||||
// dots — "март", "май", "нояб.", "дек."). scale is 'month' at every zoom step.
|
||||
const MONTHS_SHORT = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
|
||||
function axisMinorLabel(date: unknown, scale: string): string {
|
||||
const d = new Date((date as { valueOf(): number }).valueOf());
|
||||
if (scale === 'month') return MONTHS_SHORT[d.getMonth()]!;
|
||||
if (scale === 'year') return String(d.getFullYear());
|
||||
return String(d.getDate());
|
||||
}
|
||||
function axisMajorLabel(date: unknown): string {
|
||||
return String(new Date((date as { valueOf(): number }).valueOf()).getFullYear());
|
||||
}
|
||||
const MAX_LABEL_W = 760;
|
||||
|
||||
// "2026-06-01" -> "01.06.2026"
|
||||
@ -67,6 +98,9 @@ export interface TimelineView {
|
||||
render(boards: Board[], bookings: Booking[], fit: boolean, flags: RenderFlags): void;
|
||||
/** Re-run the last render (e.g. after colours changed). */
|
||||
refresh(): void;
|
||||
/** Update only the first-column labels from new info-column flags (light —
|
||||
* doesn't rebuild bars). */
|
||||
refreshLabels(flags: RenderFlags): void;
|
||||
/** Re-apply row height / centring from appearance and redraw. */
|
||||
applyLayout(): void;
|
||||
setScale(days: number): void;
|
||||
@ -79,7 +113,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
|
||||
const itemsDS = new DataSet<any>();
|
||||
let lastBoards: Board[] = [];
|
||||
let lastBookings: Booking[] = [];
|
||||
let lastFlags: RenderFlags = { withBrand: true, withCompany: false, withCollisions: true, managerView: true };
|
||||
let lastFlags: RenderFlags = { withBrand: true, withCompany: false, withCollisions: true, managerView: true, showCity: false, showAddress: true, showCode: true };
|
||||
let onBoardClick: ((boardId: string) => void) | null = null;
|
||||
|
||||
const options: TimelineOptions = {
|
||||
@ -96,6 +130,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
|
||||
orientation: { axis: 'top' },
|
||||
tooltip: { followMouse: false, delay: 0 },
|
||||
locale: 'ru',
|
||||
format: { minorLabels: axisMinorLabel, majorLabels: axisMajorLabel },
|
||||
xss: { disabled: true },
|
||||
};
|
||||
|
||||
@ -234,10 +269,14 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
|
||||
// Keep the floating scale control just below the time axis (5px gap) so it
|
||||
// never overlaps the axis; the axis height varies, so measure it each redraw.
|
||||
function positionZoomField(): void {
|
||||
const zf = document.getElementById('zoom-field');
|
||||
const axis = el.timelineEl.querySelector<HTMLElement>('.vis-panel.vis-top');
|
||||
if (!zf || !axis) return;
|
||||
zf.style.top = axis.getBoundingClientRect().bottom - el.chartWrap.getBoundingClientRect().top + 5 + 'px';
|
||||
if (!axis) return;
|
||||
const axisBottom = axis.getBoundingClientRect().bottom - el.chartWrap.getBoundingClientRect().top;
|
||||
const zf = document.getElementById('zoom-field');
|
||||
if (zf) zf.style.top = axisBottom + 5 + 'px';
|
||||
// The column-info header fills the empty top-left corner (above the labels).
|
||||
const ci = document.getElementById('col-info');
|
||||
if (ci) ci.style.height = axisBottom + 'px';
|
||||
}
|
||||
timeline.on('changed', positionZoomField);
|
||||
window.addEventListener('resize', positionZoomField);
|
||||
@ -377,7 +416,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
|
||||
|
||||
const groups = boards.map((b, idx) => ({
|
||||
id: b.board_id,
|
||||
content: escapeHtml(b.board_address) + ' (' + escapeHtml(b.board_id) + ')',
|
||||
content: groupContent(b, flags),
|
||||
order: idx,
|
||||
className: idx % 2 === 0 ? 'row-even' : 'row-odd',
|
||||
}));
|
||||
@ -450,6 +489,16 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
|
||||
groupsDS.add(groups);
|
||||
itemsDS.clear();
|
||||
itemsDS.add(items);
|
||||
// Persistent tint on the current-month column (aligned to the axis months,
|
||||
// which the hover highlight also keys off UTC month boundaries).
|
||||
const now = new Date();
|
||||
itemsDS.add({
|
||||
id: CUR_MONTH_BG_ID,
|
||||
type: 'background',
|
||||
start: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)),
|
||||
end: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)),
|
||||
className: 'cur-month-bg',
|
||||
} as never);
|
||||
lastMonthKey = null;
|
||||
|
||||
if (fit) timeline.fit({ animation: false });
|
||||
@ -461,6 +510,14 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
|
||||
if (lastBoards.length) render(lastBoards, lastBookings, false, lastFlags);
|
||||
}
|
||||
|
||||
// Light: only rewrite the group (row-label) contents — the bars/collisions
|
||||
// stay put, so toggling a column doesn't rebuild the whole timeline.
|
||||
function refreshLabels(f: RenderFlags): void {
|
||||
lastFlags = f;
|
||||
if (!lastBoards.length) return;
|
||||
groupsDS.update(lastBoards.map((b) => ({ id: b.board_id, content: groupContent(b, f) })));
|
||||
}
|
||||
|
||||
function applyLayout(): void {
|
||||
timeline.setOptions({ margin: { item: { horizontal: 2, vertical: 0 } } });
|
||||
timeline.redraw();
|
||||
@ -477,6 +534,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
|
||||
return {
|
||||
render,
|
||||
refresh,
|
||||
refreshLabels,
|
||||
applyLayout,
|
||||
setScale,
|
||||
setOnBoardClick(fn: (boardId: string) => void): void { onBoardClick = fn; },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user