diff --git a/frontend/index.html b/frontend/index.html index 013a1a6..2e485e2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -99,29 +99,31 @@ - -
-
- + +
+
+ +
+ +
-
- -
-
- +
+
+ +
+
+ +
+
+ +
- -
-
- - -
-
- -
-
+ +
+ +
@@ -137,10 +139,12 @@
- свободна сейчас - занята сейчас + свободна + занята +
+
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 1b4fe51..2df400e 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -140,7 +140,7 @@ async function loadData(fit: boolean): Promise { lastBoards = boards; lastBookings = bookings; view.render(boards, bookings, fit, flags()); - el.status.innerHTML = `Бордов: ${boards.length}
Бронирований: ${bookings.length}`; + el.status.innerHTML = `Поверхности: ${boards.length}
Брони: ${bookings.length}`; rebuildDecorMenu(); // status list may have changed // Keep the map in sync when it is visible (facets filter it; dates do not). if (mapVisible()) void refreshMap(); diff --git a/frontend/src/map.ts b/frontend/src/map.ts index c34548d..0e9e549 100644 --- a/frontend/src/map.ts +++ b/frontend/src/map.ts @@ -14,6 +14,28 @@ function dot(occupied: boolean): string { return ``; } +// Cluster icon: a donut split green (free) / red (busy) by occupancy ratio, +// with the surface count in the centre. free = green ring, busy = red arc. +const CLUSTER_FREE = '#37b24d'; +const CLUSTER_BUSY = '#e24b4a'; +function donutSvg(total: number, busy: number): string { + const r = 22; + const sw = 8; + const c = 2 * Math.PI * r; + const busyLen = total > 0 ? (busy / total) * c : 0; + const size = (r + sw) * 2; + const m = size / 2; + return ( + `` + + `` + + `` + + `${total}` + + `` + ); +} + let ymapsPromise: Promise | null = null; function loadYmaps(apiKey: string): Promise { if (ymapsPromise) return ymapsPromise; @@ -45,24 +67,33 @@ export interface MapView { invalidateSize(): void; } -// Balloon (click) — richer, with PlanFix links. +// "2026-06-01" -> "01.06.2026" +function fmtD(iso: string): string { + const p = (iso || '').split('-'); + return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso; +} + +// Balloon (click) — structured card matching the timeline tooltip style. function balloonBody(s: MapSurface): string { - const rows: string[] = []; - rows.push(`
${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')} · ${escapeHtml(s.board_type || '')}
`); - rows.push(`
${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}
`); + const busyColor = s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE; + const rows: string[] = ['
']; + 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; }