map: donut clusters, occupancy legend/search/no-coords, styled balloon; header polish

Map (map.ts, styles.css, index.html):
- Cluster icons are now donut charts split green (free) / red (busy) by the
  occupancy of the surfaces they contain, count in the centre. Custom
  clusterIconLayout with a hit-area shape; hover/click preserved.
- gridSize 64 -> 112 so nearby clusters stop overlapping at the overview zoom.
- Live legend: "свободна: X · занята: Y · поверхностей: N", reacts to filters.
- "Без координат" is now a clickable badge opening a compact codes-only panel
  (header "Без координат") so the missing-coordinate surfaces can be fixed.
- Search-to-locate: when a filter narrows to one surface the map flies in and
  opens its balloon.
- Balloon restyled to match the timeline tooltip; muted map tiles (ground-pane
  filter) so the markers stand out.
- Cluster hover tooltip shows the brand for occupied surfaces; legend plate
  raised above the Yandex controls.

Header (index.html, styles.css, main.ts):
- Scale + appearance + checkboxes recomposed into one compact block; shorter
  slider; the "Масштаб" label uses the shared field-label style again.
- "Оформление" is now a round "+" button pinned to the header's bottom-right
  corner with a hover tooltip; opens the same appearance menu.
- Counters relabelled "Поверхности" / "Брони"; the counters plate and the
  view-mode switch now share one borderless grey fill; mode buttons sit on a
  more visible grey frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
aaverbitskiy 2026-08-06 09:39:11 +00:00
parent 557522afcf
commit a68cac80d1
4 changed files with 224 additions and 61 deletions

View File

@ -99,29 +99,31 @@
</div> </div>
</div> </div>
<!-- Group 3: checkboxes --> <!-- Group 3: scale + appearance + checkboxes (compact stack) -->
<div class="hgroup hgroup-checks"> <div class="hgroup hgroup-controls">
<div class="field checkbox-field"> <div class="field zoom-field">
<label for="show-brand"><input type="checkbox" id="show-brand" checked /> Бренд</label> <label for="zoom-slider">Масштаб: <span id="zoom-label">12 месяцев</span></label>
<div class="zoom-row">
<input type="range" id="zoom-slider" min="0" max="9" step="1" value="3" />
</div>
</div> </div>
<div class="field checkbox-field"> <div class="checks">
<label for="show-collisions"><input type="checkbox" id="show-collisions" checked /> Коллизии размещения</label> <div class="field checkbox-field">
</div> <label for="show-brand"><input type="checkbox" id="show-brand" checked /> Бренд</label>
<div class="field checkbox-field"> </div>
<label for="show-company"><input type="checkbox" id="show-company" /> Компания</label> <div class="field checkbox-field">
<label for="show-collisions"><input type="checkbox" id="show-collisions" checked /> Коллизии размещения</label>
</div>
<div class="field checkbox-field">
<label for="show-company"><input type="checkbox" id="show-company" /> Компания</label>
</div>
</div> </div>
</div> </div>
<!-- Group 4: appearance settings --> <!-- Appearance ("+") button, pinned to the header's bottom-right corner -->
<div class="hgroup hgroup-scale"> <div class="decor-wrap">
<div class="field zoom-field"> <button type="button" class="decor-btn" id="decor-btn" aria-label="Оформление" data-tip="Оформление">+</button>
<label for="zoom-slider">Масштаб: <span id="zoom-label">12 месяцев</span></label> <div class="decor-menu" id="decor-menu"></div>
<input type="range" id="zoom-slider" min="0" max="9" step="1" value="3" />
</div>
<div class="decor-wrap">
<button type="button" class="decor-btn" id="decor-btn">Оформление ▾</button>
<div class="decor-menu" id="decor-menu"></div>
</div>
</div> </div>
</header> </header>
@ -137,10 +139,12 @@
<div id="pane-map"> <div id="pane-map">
<div id="map"></div> <div id="map"></div>
<div id="map-legend"> <div id="map-legend">
<span class="lg-item"><span class="lg-dot lg-free"></span>свободна сейчас</span> <span class="lg-item"><span class="lg-dot lg-free"></span><span id="lg-free">свободна</span></span>
<span class="lg-item"><span class="lg-dot lg-busy"></span>занята сейчас</span> <span class="lg-item"><span class="lg-dot lg-busy"></span><span id="lg-busy">занята</span></span>
<span class="lg-item" id="map-counter"></span> <span class="lg-item" id="map-counter"></span>
<button type="button" class="lg-nocoords" id="map-nocoords-btn" style="display:none"></button>
</div> </div>
<div id="map-nocoords-panel" style="display:none"></div>
<div id="map-empty" style="display:none">Нет поверхностей с координатами</div> <div id="map-empty" style="display:none">Нет поверхностей с координатами</div>
</div> </div>
</div> </div>

View File

@ -140,7 +140,7 @@ async function loadData(fit: boolean): Promise<void> {
lastBoards = boards; lastBoards = boards;
lastBookings = bookings; lastBookings = bookings;
view.render(boards, bookings, fit, flags()); view.render(boards, bookings, fit, flags());
el.status.innerHTML = `Бордов: ${boards.length}<br>Бронирований: ${bookings.length}`; el.status.innerHTML = `Поверхности: ${boards.length}<br>Брони: ${bookings.length}`;
rebuildDecorMenu(); // status list may have changed rebuildDecorMenu(); // status list may have changed
// Keep the map in sync when it is visible (facets filter it; dates do not). // Keep the map in sync when it is visible (facets filter it; dates do not).
if (mapVisible()) void refreshMap(); if (mapVisible()) void refreshMap();

View File

@ -14,6 +14,28 @@ function dot(occupied: boolean): string {
return `<span class="tip-dot ${occupied ? 'tip-busy' : 'tip-free'}"></span>`; return `<span class="tip-dot ${occupied ? 'tip-busy' : 'tip-free'}"></span>`;
} }
// 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 (
`<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">` +
`<circle cx="${m}" cy="${m}" r="${r}" fill="#ffffff" stroke="${CLUSTER_FREE}" stroke-width="${sw}"/>` +
`<circle cx="${m}" cy="${m}" r="${r}" fill="none" stroke="${CLUSTER_BUSY}" stroke-width="${sw}" ` +
`stroke-dasharray="${busyLen.toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 ${m} ${m})"/>` +
`<text x="${m}" y="${m}" text-anchor="middle" dominant-baseline="central" ` +
`font-family="-apple-system,Segoe UI,Roboto,Arial,sans-serif" font-size="14" font-weight="700" fill="#1f2430">${total}</text>` +
`</svg>`
);
}
let ymapsPromise: Promise<any> | null = null; let ymapsPromise: Promise<any> | null = null;
function loadYmaps(apiKey: string): Promise<any> { function loadYmaps(apiKey: string): Promise<any> {
if (ymapsPromise) return ymapsPromise; if (ymapsPromise) return ymapsPromise;
@ -45,24 +67,33 @@ export interface MapView {
invalidateSize(): void; 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 { function balloonBody(s: MapSurface): string {
const rows: string[] = []; const busyColor = s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE;
rows.push(`<div><b>${escapeHtml(s.board_id)}</b> · ${escapeHtml(s.dimension || '')} · ${escapeHtml(s.board_type || '')}</div>`); const rows: string[] = ['<div class="bl">'];
rows.push(`<div>${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}</div>`); const meta = [s.dimension, s.board_type].filter(Boolean).map(escapeHtml).join(' · ');
if (meta) rows.push(`<div class="bl-sub">${meta}</div>`);
rows.push(`<div class="bl-sub">${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}</div>`);
rows.push( rows.push(
`<div style="margin:6px 0;font-weight:600;color:${s.occupied_now ? '#c0392b' : '#2e7d32'}">` + `<div class="bl-status"><span class="bl-dot" style="background:${busyColor}"></span>` +
(s.occupied_now ? 'Занята сейчас' : 'Свободна сейчас') + (s.occupied_now ? 'Занята сейчас' : 'Свободна сейчас') +
'</div>', '</div>',
); );
if (s.bookings.length) { if (s.bookings.length) {
rows.push('<div style="font-size:12px">Текущие размещения:</div>'); rows.push('<div class="bl-sep"></div><div class="bl-label">Текущие размещения</div>');
for (const b of s.bookings) { for (const b of s.bookings) {
const label = escapeHtml(b.brand || b.company_name || 'Без названия'); const label = escapeHtml(b.brand || b.company_name || 'Без названия');
const link = `<a href="${PLANFIX_TASK_URL}${encodeURIComponent(b.task_id)}" target="_blank" rel="noopener">${escapeHtml(b.task_id)}</a>`; const link = `<a href="${PLANFIX_TASK_URL}${encodeURIComponent(b.task_id)}" target="_blank" rel="noopener" class="bl-link">${escapeHtml(b.task_id)}</a>`;
rows.push(`<div style="font-size:12px">• ${label} (${escapeHtml(b.start_date)}${escapeHtml(b.end_date)}) · ${link}</div>`); rows.push(`<div class="bl-booking">${label} <span class="bl-dates">${fmtD(b.start_date)}${fmtD(b.end_date)}</span> ${link}</div>`);
} }
} }
rows.push('</div>');
return rows.join(''); return rows.join('');
} }
@ -89,7 +120,17 @@ function clusterTip(list: MapSurface[]): string {
rows.push(`<div class="tip-head">Поверхностей: ${list.length} (занято ${busy}, свободно ${list.length - busy})</div>`); rows.push(`<div class="tip-head">Поверхностей: ${list.length} (занято ${busy}, свободно ${list.length - busy})</div>`);
const LIMIT = 25; const LIMIT = 25;
for (const s of list.slice(0, LIMIT)) { for (const s of list.slice(0, LIMIT)) {
rows.push(`<div class="tip-row">${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}</div>`); // 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 = ` · <span class="tip-brand">${escapeHtml(names)}</span>`;
}
rows.push(
`<div class="tip-row">${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}${brandHtml}</div>`,
);
} }
if (list.length > LIMIT) rows.push(`<div class="tip-more">…и ещё ${list.length - LIMIT}</div>`); if (list.length > LIMIT) rows.push(`<div class="tip-more">…и ещё ${list.length - LIMIT}</div>`);
return rows.join(''); 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. // feature id (index) -> surface, so hover handlers can look data up.
let drawn: MapSurface[] = []; 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) => `<span class="nc-code">${escapeHtml(s.board_id)}</span>`).join('');
return `<div class="nc-head">Без координат</div><div class="nc-list">${items}</div>`;
}
// Custom floating tooltip (ymaps hints are too limited for our content). // Custom floating tooltip (ymaps hints are too limited for our content).
const tip = document.createElement('div'); const tip = document.createElement('div');
tip.className = 'map-tip'; tip.className = 'map-tip';
@ -137,7 +194,10 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
function draw(surfaces: MapSurface[]): void { function draw(surfaces: MapSurface[]): void {
if (!objectManager) return; if (!objectManager) return;
const withCoords = surfaces.filter((s) => s.lat != null && s.lon != null); const withCoords = surfaces.filter((s) => s.lat != null && s.lon != null);
const noCoordsList = surfaces.filter((s) => s.lat == null || s.lon == null);
drawn = withCoords; drawn = withCoords;
const busy = withCoords.filter((s) => s.occupied_now).length;
const free = withCoords.length - busy;
const features = withCoords.map((s, i) => ({ const features = withCoords.map((s, i) => ({
type: 'Feature', type: 'Feature',
id: i, id: i,
@ -145,6 +205,7 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
properties: { properties: {
balloonContentHeader: escapeHtml(s.board_id), balloonContentHeader: escapeHtml(s.board_id),
balloonContentBody: balloonBody(s), balloonContentBody: balloonBody(s),
occupied: s.occupied_now,
}, },
options: { preset: presetFor(s) }, options: { preset: presetFor(s) },
})); }));
@ -152,14 +213,32 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
objectManager.removeAll(); objectManager.removeAll();
objectManager.add({ type: 'FeatureCollection', features }); objectManager.add({ type: 'FeatureCollection', features });
const noCoords = surfaces.length - withCoords.length; // Live legend counts (react to the current filter).
els.counter.textContent = `Поверхностей: ${withCoords.length}` + (noCoords ? ` (без координат: ${noCoords})` : ''); 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'; els.empty.style.display = withCoords.length ? 'none' : 'block';
if (features.length) { if (features.length) {
const points = features.map((f) => f.geometry.coordinates); const points = features.map((f) => f.geometry.coordinates);
const bounds = ymaps.util.bounds.fromPoints(points); 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'] }, { center: [56.85, 53.2], zoom: 7, controls: ['zoomControl', 'geolocationControl', 'fullscreenControl'] },
{ suppressMapOpenBlock: true }, { 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(
'<div class="mapdash-cluster"></div>',
{
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({ objectManager = new ymaps.ObjectManager({
clusterize: true, clusterize: true,
gridSize: 64, gridSize: 112,
clusterDisableClickZoom: false, 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); map.geoObjects.add(objectManager);

View File

@ -151,15 +151,12 @@ input::placeholder { color: var(--text-muted); }
filter: invert(1) opacity(.65); filter: invert(1) opacity(.65);
} }
/* Compact single-column layout: the three checkboxes stack in three rows. */ /* Compact controls block: scale (label + short slider + round Оформление) on
.hgroup-checks { top, the three checkboxes stacked below. */
display: flex; .hgroup-controls { flex-direction: column; align-items: flex-start; gap: 8px; }
flex-direction: column; .zoom-row { display: flex; align-items: center; gap: 8px; }
align-items: flex-start; .checks { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
justify-content: center; .checks .checkbox-field { align-self: flex-start; }
gap: 8px;
}
.hgroup-checks .checkbox-field { align-self: flex-start; }
/* Let the brand block fill the header height so the counters can sit at the /* Let the brand block fill the header height so the counters can sit at the
bottom, level with the date fields / mode switch. */ 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. */ /* Counters — compact inset inside the brand block. */
.brand #status { .brand #status {
margin-top: auto; padding: 6px 10px; margin-top: auto; padding: 8px 12px;
background: var(--surface); border: 1px solid var(--border); background: var(--surface-2); border: none;
border-radius: var(--radius-sm); font-size: 12px; color: var(--text-muted); border-radius: var(--radius-sm); font-size: 12px; color: var(--text-muted);
line-height: 1.5; white-space: nowrap; line-height: 1.5; white-space: nowrap;
} }
/* Scale group: stack the "Оформление" button under the zoom slider. */ /* 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 { flex-direction: row; align-items: center; gap: 6px; align-self: center; }
.checkbox-field label { .checkbox-field label {
display: flex; align-items: center; gap: 6px; 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); } .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; } .zoom-field label { white-space: nowrap; }
/* checkbox dropdown filter */ /* checkbox dropdown filter */
@ -233,13 +228,26 @@ input::placeholder { color: var(--text-muted); }
.dropdown-panel .dp-actions a:hover { text-decoration: underline; } .dropdown-panel .dp-actions a:hover { text-decoration: underline; }
/* Оформление button + context menu */ /* Оформление 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 { .decor-btn {
border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); position: relative;
color: var(--text); padding: 8px 14px; font-size: 13px; cursor: pointer; white-space: nowrap; width: 36px; height: 36px; border-radius: 50%; padding: 0;
transition: border-color var(--transition), background var(--transition); 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 { .decor-menu {
display: none; position: absolute; top: calc(100% + 6px); right: 0; display: none; position: absolute; top: calc(100% + 6px); right: 0;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); 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 ---- */ /* ---- view modes: segmented control ---- */
.mode-switch { .mode-switch {
display: inline-flex; flex-direction: column; gap: 3px; display: inline-flex; flex-direction: column; gap: 5px;
background: var(--surface-2); border: 1px solid var(--border); background: var(--surface-2); border: none;
border-radius: var(--radius); padding: 3px; border-radius: var(--radius); padding: 6px;
height: 100%; /* fill the header row so the bottom lines up with the date fields */ height: 100%; /* fill the header row so the bottom lines up with the date fields */
} }
.mode-btn { .mode-btn {
@ -329,16 +337,57 @@ input::placeholder { color: var(--text-muted); }
} }
#split-resizer:hover { background: var(--accent); } #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 { #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); 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: 7px 11px; font-size: 12px; color: var(--text); display: flex; gap: 14px; align-items: center;
box-shadow: var(--shadow-md); 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; }
#map-legend .lg-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; } #map-legend .lg-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
#map-legend .lg-free { background: #59a831; } #map-legend .lg-free { background: #37b24d; }
#map-legend .lg-busy { background: #e35b45; } #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 { #map-empty {
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
color: var(--text-muted); font-size: 14px; z-index: 5; 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-row { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.map-tip .tip-head { font-weight: 700; margin-bottom: 3px; } .map-tip .tip-head { font-weight: 700; margin-bottom: 3px; }
.map-tip .tip-sub { color: var(--tip-sub); } .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; } .map-tip .tip-more { color: var(--tip-sub); margin-top: 3px; }
#chart-wrap { flex: 1 1 auto; min-height: 0; position: relative; overflow: hidden; } #chart-wrap { flex: 1 1 auto; min-height: 0; position: relative; overflow: hidden; }