diff --git a/frontend/package.json b/frontend/package.json
index 109009f..54aca76 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "mapdash-frontend",
"private": true,
- "version": "0.2.13",
+ "version": "0.2.14",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/datepicker.ts b/frontend/src/datepicker.ts
index 74bb989..5c8f8fe 100644
--- a/frontend/src/datepicker.ts
+++ b/frontend/src/datepicker.ts
@@ -1,7 +1,9 @@
// Custom date-range picker. The two native
(#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 «Применить».
+// Both endpoints are chosen by clicking days; «Сбросить» clears the in-progress
+// selection.
const MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
const WEEKDAYS = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'];
@@ -32,6 +34,9 @@ export function createDateRange(): DateRange {
let viewY = new Date().getFullYear();
let viewM = new Date().getMonth();
+ // In-progress selection (committed to the inputs only when both ends are set).
+ let selStart = '';
+ let selEnd = '';
function updateText(): void {
const s = startInput.value;
@@ -45,8 +50,8 @@ 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 parts: string[] = [
`
‹ ` +
`${MONTHS[viewM]} ${viewY} ` +
@@ -61,39 +66,50 @@ 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(`${d} `);
}
parts.push('
');
+ const ready = !!(selStart && selEnd);
+ parts.push(
+ ''
+ );
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 (!selStart || (selStart && selEnd)) {
+ // start a fresh selection — first endpoint only
+ selStart = iso;
+ selEnd = '';
+ } else if (iso >= selStart) {
+ // second endpoint — complete the range, but wait for «Применить»
+ selEnd = iso;
} else {
- startInput.value = iso; // clicked before the start — move the start
+ // clicked before the start — move the start (still one endpoint)
+ selStart = iso;
}
- commit();
render();
}
function open(): void {
- const base = parseISO(startInput.value) || new Date();
+ selStart = startInput.value;
+ selEnd = endInput.value;
+ const base = parseISO(selStart) || new Date();
viewY = base.getFullYear();
viewM = base.getMonth();
render();
@@ -113,12 +129,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
('.dr-nav');
if (nav) {
@@ -127,6 +150,17 @@ export function createDateRange(): DateRange {
render();
return;
}
+ const act = target.closest('[data-act]');
+ if (act) {
+ if (act.dataset.act === 'apply') {
+ if (selStart && selEnd) { applyRange(); close(); }
+ } else if (act.dataset.act === 'reset') {
+ selStart = '';
+ selEnd = '';
+ render();
+ }
+ return;
+ }
const day = target.closest('.dr-day');
if (day && day.dataset.iso) pick(day.dataset.iso);
});
@@ -138,7 +172,7 @@ export function createDateRange(): DateRange {
return {
sync(): void {
updateText();
- if (!cal.hidden) render();
+ if (!cal.hidden) { selStart = startInput.value; selEnd = endInput.value; render(); }
},
};
}
diff --git a/frontend/src/map.ts b/frontend/src/map.ts
index 4299576..56873de 100644
--- a/frontend/src/map.ts
+++ b/frontend/src/map.ts
@@ -213,6 +213,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 +321,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 +613,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);
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index e5098d0..a0e5edd 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -107,7 +107,7 @@ header {
.hgroup-fields {
flex-shrink: 4; min-width: 0;
display: grid;
- grid-template-columns: repeat(4, minmax(130px, 1fr));
+ grid-template-columns: repeat(4, 170px);
gap: 12px 14px;
align-items: start;
align-content: start;
@@ -124,6 +124,7 @@ header {
/* ---- date-range field + calendar popup ---- */
.field-dates { grid-column: span 2; }
+.field-dates .dr-field { max-width: 333px; } /* the "Период" field is 40px narrower than its 2-col slot */
.dr-native { display: none; }
.daterange { position: relative; }
.dr-field {
@@ -160,6 +161,12 @@ header {
.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 +207,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; }
@@ -313,14 +323,14 @@ 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. */
+/* In-dropdown search that filters the option list — a clean, evenly-bordered box. */
.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;
+ display: block; width: calc(100% - 16px); box-sizing: border-box; margin: 3px 8px 6px;
+ 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 +381,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);