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>
<!-- Group 3: checkboxes -->
<div class="hgroup hgroup-checks">
<div class="field checkbox-field">
<label for="show-brand"><input type="checkbox" id="show-brand" checked /> Бренд</label>
<!-- Group 3: scale + appearance + checkboxes (compact stack) -->
<div class="hgroup hgroup-controls">
<div class="field zoom-field">
<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 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 class="checks">
<div class="field checkbox-field">
<label for="show-brand"><input type="checkbox" id="show-brand" checked /> Бренд</label>
</div>
<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>
<!-- Group 4: appearance settings -->
<div class="hgroup hgroup-scale">
<div class="field zoom-field">
<label for="zoom-slider">Масштаб: <span id="zoom-label">12 месяцев</span></label>
<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>
<!-- Appearance ("+") button, pinned to the header's bottom-right corner -->
<div class="decor-wrap">
<button type="button" class="decor-btn" id="decor-btn" aria-label="Оформление" data-tip="Оформление">+</button>
<div class="decor-menu" id="decor-menu"></div>
</div>
</header>
@ -137,10 +139,12 @@
<div id="pane-map">
<div id="map"></div>
<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-busy"></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 id="lg-busy">занята</span></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 id="map-nocoords-panel" style="display:none"></div>
<div id="map-empty" style="display:none">Нет поверхностей с координатами</div>
</div>
</div>

View File

@ -140,7 +140,7 @@ async function loadData(fit: boolean): Promise<void> {
lastBoards = boards;
lastBookings = bookings;
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
// Keep the map in sync when it is visible (facets filter it; dates do not).
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>`;
}
// 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;
function loadYmaps(apiKey: string): Promise<any> {
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(`<div><b>${escapeHtml(s.board_id)}</b> · ${escapeHtml(s.dimension || '')} · ${escapeHtml(s.board_type || '')}</div>`);
rows.push(`<div>${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}</div>`);
const busyColor = s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE;
const rows: string[] = ['<div class="bl">'];
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(
`<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 ? 'Занята сейчас' : 'Свободна сейчас') +
'</div>',
);
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) {
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>`;
rows.push(`<div style="font-size:12px">• ${label} (${escapeHtml(b.start_date)}${escapeHtml(b.end_date)}) · ${link}</div>`);
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 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('');
}
@ -89,7 +120,17 @@ function clusterTip(list: MapSurface[]): string {
rows.push(`<div class="tip-head">Поверхностей: ${list.length} (занято ${busy}, свободно ${list.length - busy})</div>`);
const LIMIT = 25;
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>`);
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) => `<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).
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(
'<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({
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);

View File

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