From 5ba5a77f976f50f62ae58bcd5c1309083c87921a Mon Sep 17 00:00:00 2001 From: aaverbitskiy Date: Sat, 15 Aug 2026 20:39:12 +0000 Subject: [PATCH] =?UTF-8?q?UI-=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B8:=20=D0=B2=D1=8B=D1=80=D0=B0=D0=B2=D0=BD=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=82=D0=BE=D0=B3=D0=B3=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2,=20=D0=B4=D0=B0=D1=82=D0=B0=D0=BF=D0=B8=D0=BA?= =?UTF-8?q?=D0=B5=D1=80=20=D1=81=20=C2=AB=D0=9F=D1=80=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=C2=BB,=20=D1=81=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D1=8B=2080px,=20=D0=BF=D0=BE=D0=BB=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0,=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D0=B1?= =?UTF-8?q?=D0=B0=D0=BB=D1=83=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Тогглы: Бренд+Компания в одну строку, блок выровнен ровно по полосе контролов (верх = верх полей, низ = низ «Поиска»), равномерные зазоры - Датапикер: выбор диапазона больше не применяется автоматически по второму клику — только кнопкой «Применить» (+ «Сбросить» для незавершённого выбора) - Степперы «Оформления»: ровно 80px по ширине - Поле поиска в дропдаунах: равномерная рамка 1px по всем сторонам вместо разноцветных краёв - Балун на карте: клик по пустой карте закрывает балун (guard от самозакрытия по «своему» клику, клик по другому кластеру открывает новый) - Полоса фильтров: поля сужены на 10px, «Период» до 333px Co-Authored-By: Claude Opus 4.8 --- frontend/index.html | 2 +- frontend/package.json | 2 +- frontend/src/datepicker.ts | 74 +++++++++++++++++++++++++++----------- frontend/src/map.ts | 11 ++++++ frontend/src/styles.css | 30 ++++++++++------ 5 files changed, 87 insertions(+), 32 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 26f3ebe..8c1c282 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -98,7 +98,7 @@
- +
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(``); } 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);