'];
+ const meta = [s.dimension, s.board_type].filter(Boolean).map(escapeHtml).join(' · ');
+ if (meta) rows.push(`
${meta}
`);
+ rows.push(`
${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}
`);
rows.push(
- `
` +
+ `
` +
(s.occupied_now ? 'Занята сейчас' : 'Свободна сейчас') +
'
',
);
if (s.bookings.length) {
- rows.push('
Текущие размещения:
');
+ rows.push('
Текущие размещения
');
for (const b of s.bookings) {
const label = escapeHtml(b.brand || b.company_name || 'Без названия');
- const link = `
${escapeHtml(b.task_id)}`;
- rows.push(`
• ${label} (${escapeHtml(b.start_date)} — ${escapeHtml(b.end_date)}) · ${link}
`);
+ const link = `
№${escapeHtml(b.task_id)} ↗`;
+ rows.push(`
${label} ${fmtD(b.start_date)}–${fmtD(b.end_date)} ${link}
`);
}
}
+ rows.push('
');
return rows.join('');
}
@@ -89,7 +120,17 @@ function clusterTip(list: MapSurface[]): string {
rows.push(`
Поверхностей: ${list.length} (занято ${busy}, свободно ${list.length - busy})
`);
const LIMIT = 25;
for (const s of list.slice(0, LIMIT)) {
- rows.push(`
${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}
`);
+ // For occupied surfaces, show which brand(s) currently hold them.
+ let brandHtml = '';
+ if (s.occupied_now && s.bookings.length) {
+ const names = Array.from(
+ new Set(s.bookings.map((b) => b.brand || b.company_name || '').filter(Boolean)),
+ ).join(', ');
+ if (names) brandHtml = ` ·
${escapeHtml(names)}`;
+ }
+ rows.push(
+ `
${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}${brandHtml}
`,
+ );
}
if (list.length > LIMIT) rows.push(`
…и ещё ${list.length - LIMIT}
`);
return rows.join('');
@@ -109,6 +150,22 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
// feature id (index) -> surface, so hover handlers can look data up.
let drawn: MapSurface[] = [];
+ // Legend / no-coords panel refs (static markup in index.html).
+ const legendFree = document.getElementById('lg-free');
+ const legendBusy = document.getElementById('lg-busy');
+ const noCoordsBtn = document.getElementById('map-nocoords-btn');
+ const noCoordsPanel = document.getElementById('map-nocoords-panel');
+ if (noCoordsBtn && noCoordsPanel) {
+ noCoordsBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ noCoordsPanel.style.display = noCoordsPanel.style.display === 'none' ? 'block' : 'none';
+ });
+ }
+ function renderNoCoords(list: MapSurface[]): string {
+ const items = list.map((s) => `
${escapeHtml(s.board_id)}`).join('');
+ return `
Без координат
${items}
`;
+ }
+
// Custom floating tooltip (ymaps hints are too limited for our content).
const tip = document.createElement('div');
tip.className = 'map-tip';
@@ -137,7 +194,10 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
function draw(surfaces: MapSurface[]): void {
if (!objectManager) return;
const withCoords = surfaces.filter((s) => s.lat != null && s.lon != null);
+ const noCoordsList = surfaces.filter((s) => s.lat == null || s.lon == null);
drawn = withCoords;
+ const busy = withCoords.filter((s) => s.occupied_now).length;
+ const free = withCoords.length - busy;
const features = withCoords.map((s, i) => ({
type: 'Feature',
id: i,
@@ -145,6 +205,7 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
properties: {
balloonContentHeader: escapeHtml(s.board_id),
balloonContentBody: balloonBody(s),
+ occupied: s.occupied_now,
},
options: { preset: presetFor(s) },
}));
@@ -152,14 +213,32 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
objectManager.removeAll();
objectManager.add({ type: 'FeatureCollection', features });
- const noCoords = surfaces.length - withCoords.length;
- els.counter.textContent = `Поверхностей: ${withCoords.length}` + (noCoords ? ` (без координат: ${noCoords})` : '');
+ // Live legend counts (react to the current filter).
+ if (legendFree) legendFree.textContent = `свободна: ${free}`;
+ if (legendBusy) legendBusy.textContent = `занята: ${busy}`;
+ els.counter.textContent = `поверхностей: ${withCoords.length}`;
+
+ // Actionable "no coordinates" badge + list.
+ if (noCoordsBtn) {
+ noCoordsBtn.style.display = noCoordsList.length ? '' : 'none';
+ noCoordsBtn.textContent = `без координат: ${noCoordsList.length}`;
+ }
+ if (noCoordsPanel) {
+ noCoordsPanel.innerHTML = renderNoCoords(noCoordsList);
+ if (!noCoordsList.length) noCoordsPanel.style.display = 'none';
+ }
+
els.empty.style.display = withCoords.length ? 'none' : 'block';
if (features.length) {
const points = features.map((f) => f.geometry.coordinates);
const bounds = ymaps.util.bounds.fromPoints(points);
- map.setBounds(bounds, { checkZoomRange: true, zoomMargin: 40 });
+ const done = map.setBounds(bounds, { checkZoomRange: true, zoomMargin: 40 });
+ // Search-to-locate: when the filter narrows to a single surface, fly in
+ // and open its balloon so the user lands right on the card.
+ if (features.length === 1 && done && typeof done.then === 'function') {
+ done.then(() => objectManager.objects.balloon.open(features[0].id));
+ }
}
}
@@ -181,10 +260,40 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
{ center: [56.85, 53.2], zoom: 7, controls: ['zoomControl', 'geolocationControl', 'fullscreenControl'] },
{ suppressMapOpenBlock: true },
);
+ // Custom cluster icon: a donut coloured by the free/busy split of the
+ // surfaces it contains (each feature carries `occupied` in properties).
+ const DonutClusterLayout = ymaps.templateLayoutFactory.createClass(
+ '
',
+ {
+ build: function (this: any) {
+ DonutClusterLayout.superclass.build.call(this);
+ try {
+ const props = this.getData() && this.getData().properties;
+ const objs = (props && (props.get ? props.get('geoObjects') : props.geoObjects)) || [];
+ let busy = 0;
+ for (const o of objs) {
+ const p = o && o.properties;
+ const occ = p && (typeof p.get === 'function' ? p.get('occupied') : p.occupied);
+ if (occ) busy++;
+ }
+ const parent = this.getParentElement && this.getParentElement();
+ const el = parent && parent.getElementsByClassName('mapdash-cluster')[0];
+ if (el) el.innerHTML = donutSvg(objs.length, busy);
+ } catch (e) {
+ console.error('cluster layout build failed', e);
+ }
+ },
+ },
+ );
+
objectManager = new ymaps.ObjectManager({
clusterize: true,
- gridSize: 64,
+ gridSize: 112,
clusterDisableClickZoom: false,
+ 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.
+ clusterIconShape: { type: 'Circle', coordinates: [0, 0], radius: 30 },
});
map.geoObjects.add(objectManager);
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 56a1136..e5354a2 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -151,15 +151,12 @@ input::placeholder { color: var(--text-muted); }
filter: invert(1) opacity(.65);
}
-/* Compact single-column layout: the three checkboxes stack in three rows. */
-.hgroup-checks {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- justify-content: center;
- gap: 8px;
-}
-.hgroup-checks .checkbox-field { align-self: flex-start; }
+/* Compact controls block: scale (label + short slider + round Оформление) on
+ 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; }
+.checks .checkbox-field { align-self: flex-start; }
/* Let the brand block fill the header height so the counters can sit at the
bottom, level with the date fields / mode switch. */
@@ -176,16 +173,13 @@ input::placeholder { color: var(--text-muted); }
/* Counters — compact inset inside the brand block. */
.brand #status {
- margin-top: auto; padding: 6px 10px;
- background: var(--surface); border: 1px solid var(--border);
+ margin-top: auto; padding: 8px 12px;
+ background: var(--surface-2); border: none;
border-radius: var(--radius-sm); font-size: 12px; color: var(--text-muted);
line-height: 1.5; white-space: nowrap;
}
/* Scale group: stack the "Оформление" button under the zoom slider. */
-.hgroup-scale { flex-direction: column; align-items: flex-start; gap: 12px; }
-.hgroup-scale .decor-wrap { align-self: flex-start; }
-
.checkbox-field { flex-direction: row; align-items: center; gap: 6px; align-self: center; }
.checkbox-field label {
display: flex; align-items: center; gap: 6px;
@@ -194,7 +188,8 @@ input::placeholder { color: var(--text-muted); }
}
.checkbox-field input[type=checkbox] { cursor: pointer; accent-color: var(--accent); }
-.zoom-field input[type=range] { width: 160px; cursor: pointer; accent-color: var(--accent); }
+.zoom-field { gap: 4px; }
+.zoom-field input[type=range] { width: 100px; cursor: pointer; accent-color: var(--accent); }
.zoom-field label { white-space: nowrap; }
/* checkbox dropdown filter */
@@ -233,13 +228,26 @@ input::placeholder { color: var(--text-muted); }
.dropdown-panel .dp-actions a:hover { text-decoration: underline; }
/* Оформление button + context menu */
-.decor-wrap { position: relative; align-self: center; }
+/* Pinned to the header's bottom-right corner. */
+.decor-wrap { position: absolute; right: 18px; bottom: 12px; }
+/* Round "+" icon button; label shown as a hover tooltip (data-tip). */
.decor-btn {
- border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg);
- color: var(--text); padding: 8px 14px; font-size: 13px; cursor: pointer; white-space: nowrap;
- transition: border-color var(--transition), background var(--transition);
+ position: relative;
+ width: 36px; height: 36px; border-radius: 50%; padding: 0;
+ border: 1px solid var(--border); background: var(--bg); color: var(--text-muted);
+ font-size: 23px; line-height: 1; cursor: pointer;
+ display: flex; align-items: center; justify-content: center;
+ transition: border-color var(--transition), background var(--transition), color var(--transition);
}
-.decor-btn:hover { border-color: var(--border-strong); background: var(--surface); }
+.decor-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--surface); }
+.decor-btn::after {
+ content: attr(data-tip);
+ position: absolute; top: calc(100% + 7px); right: 0;
+ background: var(--tip-bg); color: var(--tip-text); font-size: 11px; white-space: nowrap;
+ padding: 4px 8px; border-radius: var(--radius-sm); box-shadow: var(--shadow-md);
+ opacity: 0; pointer-events: none; transition: opacity var(--transition); z-index: 60;
+}
+.decor-btn:hover::after { opacity: 1; }
.decor-menu {
display: none; position: absolute; top: calc(100% + 6px); right: 0;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
@@ -276,9 +284,9 @@ input::placeholder { color: var(--text-muted); }
/* ---- view modes: segmented control ---- */
.mode-switch {
- display: inline-flex; flex-direction: column; gap: 3px;
- background: var(--surface-2); border: 1px solid var(--border);
- border-radius: var(--radius); padding: 3px;
+ display: inline-flex; flex-direction: column; gap: 5px;
+ background: var(--surface-2); border: none;
+ border-radius: var(--radius); padding: 6px;
height: 100%; /* fill the header row so the bottom lines up with the date fields */
}
.mode-btn {
@@ -329,16 +337,57 @@ input::placeholder { color: var(--text-muted); }
}
#split-resizer:hover { background: var(--accent); }
+/* Donut cluster icon (built in map.ts) — centre the 60px svg on the geo point. */
+.mapdash-cluster { width: 60px; height: 60px; margin: -30px 0 0 -30px; line-height: 0; cursor: pointer; }
+
#map-legend {
- position: absolute; left: 10px; bottom: 10px; z-index: 5;
+ 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;
box-shadow: var(--shadow-md);
}
#map-legend .lg-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
#map-legend .lg-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
-#map-legend .lg-free { background: #59a831; }
-#map-legend .lg-busy { background: #e35b45; }
+#map-legend .lg-free { background: #37b24d; }
+#map-legend .lg-busy { background: #e24b4a; }
+/* Actionable "no coordinates" badge inside the legend. */
+.lg-nocoords {
+ border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface);
+ color: var(--text-muted); font-size: 12px; padding: 2px 8px; cursor: pointer; white-space: nowrap;
+ transition: border-color var(--transition), color var(--transition);
+}
+.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;
+ 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;
+}
+#map-nocoords-panel .nc-head {
+ font-weight: 600; color: var(--text-muted); margin-bottom: 6px;
+ text-transform: uppercase; font-size: 11px; letter-spacing: .04em;
+}
+#map-nocoords-panel .nc-list { display: flex; flex-wrap: wrap; gap: 4px 6px; }
+#map-nocoords-panel .nc-code {
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm);
+ padding: 2px 6px; font-size: 12px; white-space: nowrap;
+}
+
+/* Muted map tiles so green/red markers stand out (ground pane only, not markers). */
+[class*="-ground-pane"] { filter: saturate(0.72) brightness(1.02); }
+
+/* Balloon card (click) — matches the timeline tooltip structure. */
+.bl { font-size: 13px; color: var(--text); line-height: 1.4; min-width: 190px; }
+.bl-sub { color: var(--text-muted); }
+.bl-status { display: flex; align-items: center; gap: 7px; font-weight: 600; margin: 6px 0; }
+.bl-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
+.bl-sep { height: 1px; background: var(--border); margin: 8px 0; }
+.bl-label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin-bottom: 4px; }
+.bl-booking { font-size: 12px; margin: 3px 0; }
+.bl-dates { color: var(--text-muted); }
+.bl-link { color: var(--accent); text-decoration: none; font-weight: 600; }
+.bl-link:hover { text-decoration: underline; }
#map-empty {
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
color: var(--text-muted); font-size: 14px; z-index: 5;
@@ -356,6 +405,7 @@ input::placeholder { color: var(--text-muted); }
.map-tip .tip-row { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.map-tip .tip-head { font-weight: 700; margin-bottom: 3px; }
.map-tip .tip-sub { color: var(--tip-sub); }
+.map-tip .tip-brand { color: #f2a08f; }
.map-tip .tip-more { color: var(--tip-sub); margin-top: 3px; }
#chart-wrap { flex: 1 1 auto; min-height: 0; position: relative; overflow: hidden; }