map/header UI: unified marker colours, vertical legend, smart tooltip, click fixes

Map (map.ts, styles.css):
- Single markers now use the same donut colours (islands#circleDotIcon +
  iconColor #37b24d / #e24b4a) instead of Yandex's brighter presets, so single
  and cluster markers match.
- Legend is a vertical stack with relabelled live counts: "свободно / занято /
  всего", and the "без координат" badge hides when the count is zero.
- Cluster tooltip shows the brand for occupied surfaces (coral); legend raised
  above the Yandex controls.
- Hover tooltip: smart positioning — flips above/left of the cursor when it would
  overflow the viewport (bottom clusters no longer clip), max-height safety.
- Bug fix: hide the hover tooltip on marker/cluster click — it no longer sticks
  and follows the cursor after a balloon opens.
- Bug fix: clusterDisableClickZoom=true so a cluster click opens its balloon at
  any zoom instead of only at max zoom.

Header (index.html, styles.css):
- Scale + appearance + checkboxes recomposed into one compact block; the
  "Оформление" control is a round "+" button pinned to the header's bottom-right
  corner with a hover tooltip; counters relabelled "Поверхности / Брони"; the
  counters plate and view-mode switch share one borderless grey fill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
aaverbitskiy 2026-08-06 18:48:01 +00:00
parent a68cac80d1
commit de0c369bb5
2 changed files with 37 additions and 14 deletions

View File

@ -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 `<span class="tip-dot ${occupied ? 'tip-busy' : 'tip-free'}"></span>`;
}
@ -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);

View File

@ -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; }