diff --git a/frontend/src/map.ts b/frontend/src/map.ts index 0e9e549..f713892 100644 --- a/frontend/src/map.ts +++ b/frontend/src/map.ts @@ -6,10 +6,7 @@ import type { MapSurface } from './types'; const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/'; // Marker colour is intentionally "occupied today", independent of the date -// filters. Kept as a single function so the colour scheme is easy to change later. -function presetFor(s: MapSurface): string { - return s.occupied_now ? 'islands#redCircleDotIcon' : 'islands#greenCircleDotIcon'; -} +// filters. Single markers reuse the exact donut colours (below) via iconColor. function dot(occupied: boolean): string { return ``; } @@ -174,8 +171,23 @@ export function createMapView(els: MapElements, apiKey: string): MapView { let mx = 0; let my = 0; function positionTip(): void { - tip.style.left = mx + 14 + 'px'; - tip.style.top = my + 14 + 'px'; + const pad = 14; + const margin = 6; + const vw = window.innerWidth; + const vh = window.innerHeight; + const r = tip.getBoundingClientRect(); + const tw = r.width || 260; + const th = r.height || 120; + // Horizontal: to the right of the cursor, flip left if it would overflow. + let left = mx + pad; + if (left + tw > vw - margin) left = mx - pad - tw; + left = Math.max(margin, Math.min(left, vw - tw - margin)); + // Vertical: below the cursor, flip above if it would overflow the bottom. + let top = my + pad; + if (top + th > vh - margin) top = my - pad - th; + top = Math.max(margin, Math.min(top, vh - th - margin)); + tip.style.left = left + 'px'; + tip.style.top = top + 'px'; } document.addEventListener('mousemove', (e) => { mx = e.clientX; @@ -207,16 +219,17 @@ export function createMapView(els: MapElements, apiKey: string): MapView { balloonContentBody: balloonBody(s), occupied: s.occupied_now, }, - options: { preset: presetFor(s) }, + // Same colours as the donut cluster (islands#circleDotIcon is colourable). + options: { preset: 'islands#circleDotIcon', iconColor: s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE }, })); hideTip(); objectManager.removeAll(); objectManager.add({ type: 'FeatureCollection', features }); // Live legend counts (react to the current filter). - if (legendFree) legendFree.textContent = `свободна: ${free}`; - if (legendBusy) legendBusy.textContent = `занята: ${busy}`; - els.counter.textContent = `поверхностей: ${withCoords.length}`; + if (legendFree) legendFree.textContent = `свободно: ${free}`; + if (legendBusy) legendBusy.textContent = `занято: ${busy}`; + els.counter.textContent = `всего: ${withCoords.length}`; // Actionable "no coordinates" badge + list. if (noCoordsBtn) { @@ -289,7 +302,8 @@ export function createMapView(els: MapElements, apiKey: string): MapView { objectManager = new ymaps.ObjectManager({ clusterize: true, gridSize: 112, - clusterDisableClickZoom: false, + // Click opens the balloon at any zoom (instead of zooming into the cluster). + clusterDisableClickZoom: true, clusterIconLayout: DonutClusterLayout, // Hit area for hover/click — a circle centred on the geo point matching // the 60px donut (radius 30). Without a shape the icon is inert. @@ -310,6 +324,11 @@ export function createMapView(els: MapElements, apiKey: string): MapView { if (list.length) showTip(clusterTip(list)); }); objectManager.clusters.events.add(['mouseleave'], hideTip); + // Hide the hover tooltip when a balloon opens — otherwise mouseleave may + // not fire (the balloon covers the marker) and the tip stays stuck, + // following the cursor across the whole map. + objectManager.objects.events.add(['click'], hideTip); + objectManager.clusters.events.add(['click'], hideTip); if (pending) { draw(pending); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e5354a2..392d0ed 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -343,10 +343,13 @@ input::placeholder { color: var(--text-muted); } #map-legend { position: absolute; left: 10px; bottom: 44px; z-index: 5; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); - padding: 7px 11px; font-size: 12px; color: var(--text); display: flex; gap: 14px; align-items: center; + padding: 8px 12px; font-size: 12px; color: var(--text); + display: flex; flex-direction: column; align-items: flex-start; gap: 5px; box-shadow: var(--shadow-md); } -#map-legend .lg-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } +#map-legend .lg-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; font-size: 12px; } +#map-legend #map-counter { padding-left: 18px; } /* align "всего" with the dotted rows */ +#map-legend .lg-nocoords { margin-top: 2px; font-size: 12px; } #map-legend .lg-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; } #map-legend .lg-free { background: #37b24d; } #map-legend .lg-busy { background: #e24b4a; } @@ -359,7 +362,7 @@ input::placeholder { color: var(--text-muted); } .lg-nocoords:hover { border-color: var(--border-strong); color: var(--text); } /* Panel listing surfaces missing coordinates. */ #map-nocoords-panel { - position: absolute; left: 10px; bottom: 86px; z-index: 6; + position: absolute; left: 10px; bottom: 150px; z-index: 6; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-md); padding: 8px 10px; font-size: 12px; color: var(--text); max-width: 300px; max-height: 300px; overflow-y: auto; @@ -397,6 +400,7 @@ input::placeholder { color: var(--text-muted); } position: fixed; z-index: 1000; pointer-events: none; background: var(--tip-bg); color: var(--tip-text); font-size: 12px; line-height: 1.35; padding: 8px 10px; border-radius: var(--radius-sm); max-width: 340px; + max-height: calc(100vh - 24px); overflow: hidden; box-shadow: var(--shadow-md); } .map-tip .tip-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; vertical-align: baseline; }