ui: toggles, stat tiles, dropdown search, bubble scale slider (v0.2.12)

Modernised several controls (reference: easystudy ui_elements catalogue):
- Display checkboxes (Бренд/Компания/Коллизии/Все поверхности) are now toggle
  switches; the option checkboxes inside filter dropdowns stay normal.
- Counters became stat tiles: header "Поверхности N" (accent) and the map
  legend (свободно/занято/всего + dashed "без координат").
- Filter dropdowns gained an in-panel search that filters the option list
  (shown for lists >7, auto-focused on open).
- Timeline scale control restyled as a filled slider with a value bubble, moved
  out of the header to float over the top-right of the chart just below the time
  axis (5px gap), bubble pointing up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
aaverbitskiy 2026-08-15 14:46:04 +00:00
parent d7c1ce7b77
commit f5166b888a
7 changed files with 161 additions and 49 deletions

View File

@ -40,7 +40,7 @@
<div class="brand">
<div class="brand-name">Green Media</div>
<div class="brand-sub">Адресная программа</div>
<div id="status"></div>
<div id="status" class="stat-tile stat-accent"><span class="st-l">Поверхности</span><span class="st-v"></span></div>
</div>
</div>
@ -120,14 +120,8 @@
</div>
</div>
<!-- Group 3: scale + appearance + checkboxes (compact stack) -->
<!-- Group 3: display toggles (the scale control now floats over the timeline) -->
<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="4" step="1" value="1" />
</div>
</div>
<div class="checks manager-only">
<div class="checks-row">
<div class="field checkbox-field">
@ -164,6 +158,13 @@
<div id="pane-timeline">
<div id="chart-wrap">
<div id="timeline"></div>
<div class="field zoom-field" id="zoom-field">
<label for="zoom-slider">Масштаб графика</label>
<div class="zoom-row">
<span id="zoom-label" class="zoom-bubble">12М</span>
<input type="range" id="zoom-slider" min="0" max="4" step="1" value="1" />
</div>
</div>
<div id="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div>
<div id="empty" class="empty-state" style="display:none">
<svg class="empty-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
@ -177,10 +178,10 @@
<div id="pane-map">
<div id="map"></div>
<div id="map-legend">
<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 class="stat-tile"><span class="st-l"><span class="lg-dot lg-free"></span>свободно</span><span class="st-v st-free" id="lg-free">0</span></div>
<div class="stat-tile"><span class="st-l"><span class="lg-dot lg-busy"></span>занято</span><span class="st-v st-busy" id="lg-busy">0</span></div>
<div class="stat-tile"><span class="st-l"><span class="lg-dot lg-total"></span>всего</span><span class="st-v" id="map-counter">0</span></div>
<button type="button" class="stat-tile st-nc" id="map-nocoords-btn" style="display:none"><span class="st-l"><span class="lg-dot"></span>без координат</span><span class="st-v">0</span></button>
</div>
<div id="map-nocoords-panel" style="display:none"></div>
<div id="map-empty" class="empty-state" style="display:none">

View File

@ -1,7 +1,7 @@
{
"name": "mapdash-frontend",
"private": true,
"version": "0.2.11",
"version": "0.2.12",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -42,6 +42,30 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
function renderPanel(): void {
panel.innerHTML = '';
// Show all available options, plus any currently-selected value that is no
// longer among them (so a selection made incompatible by other filters
// stays visible and can still be unchecked).
const extra = Array.from(selected).filter((v) => !allValues.includes(v));
const display = allValues.concat(extra);
const optionEls: { v: string; el: HTMLElement }[] = [];
// Search box — filters the option list in place (only for longer lists).
if (display.length > 7) {
const search = document.createElement('input');
search.type = 'text';
search.className = 'dp-search';
search.placeholder = 'Поиск…';
search.addEventListener('click', (e) => e.stopPropagation());
search.addEventListener('input', () => {
const q = search.value.trim().toLowerCase();
for (const o of optionEls) {
o.el.style.display = !q || formatLabel(o.v).toLowerCase().includes(q) ? '' : 'none';
}
});
panel.appendChild(search);
}
const actions = document.createElement('div');
actions.className = 'dp-actions';
const selAll = document.createElement('a');
@ -64,11 +88,6 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
actions.appendChild(selNone);
panel.appendChild(actions);
// Show all available options, plus any currently-selected value that is no
// longer among them (so a selection made incompatible by other filters
// stays visible and can still be unchecked).
const extra = Array.from(selected).filter((v) => !allValues.includes(v));
const display = allValues.concat(extra);
for (const v of display) {
const label = document.createElement('label');
const cb = document.createElement('input');
@ -83,6 +102,7 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
});
label.appendChild(cb);
label.appendChild(document.createTextNode(formatLabel(v)));
optionEls.push({ v, el: label });
panel.appendChild(label);
}
}
@ -93,6 +113,14 @@ export function createDropdown(id: string, formatLabel: (v: string) => string =
if (d !== root) d.classList.remove('open');
});
root.classList.toggle('open');
if (root.classList.contains('open')) {
const s = panel.querySelector<HTMLInputElement>('.dp-search');
if (s) {
s.value = '';
s.dispatchEvent(new Event('input')); // reset any prior filtering
s.focus();
}
}
});
document.addEventListener('click', (e) => {
if (!root.contains(e.target as Node)) root.classList.remove('open');

View File

@ -324,7 +324,7 @@ el.emptyResetTimeline.addEventListener('click', resetFilters);
el.emptyResetMap.addEventListener('click', resetFilters);
async function loadData(fit: boolean): Promise<void> {
el.status.textContent = 'Загрузка…';
el.status.innerHTML = '<span class="st-l">Поверхности</span><span class="st-v">…</span>';
loadStart();
try {
const f = currentFilters();
@ -338,7 +338,7 @@ async function loadData(fit: boolean): Promise<void> {
lastBoards = boards;
lastBookings = bookings;
view.render(boards, bookings, fit, flags());
el.status.textContent = `Поверхности: ${boards.length}`;
el.status.innerHTML = `<span class="st-l">Поверхности</span><span class="st-v">${boards.length}</span>`;
rebuildDecorMenu(); // status list may have changed
renderChips();
filtersToUrl();
@ -422,11 +422,22 @@ el.search.addEventListener('input', scheduleReload);
el.dateStart.addEventListener('change', scheduleReload);
el.dateEnd.addEventListener('change', scheduleReload);
// Fill the track up to the thumb and ride the value bubble above the thumb.
function updateZoomUi(): void {
const max = parseInt(el.zoomSlider.max, 10) || 1;
const frac = max ? parseInt(el.zoomSlider.value, 10) / max : 0;
el.zoomSlider.style.setProperty('--zoom-fill', frac * 100 + '%');
const thumb = 16;
const w = el.zoomSlider.offsetWidth || 120;
el.zoomLabel.style.left = thumb / 2 + frac * (w - thumb) + 'px';
}
el.zoomSlider.addEventListener('input', () => {
const step = zoomSteps[parseInt(el.zoomSlider.value, 10)]!;
el.zoomLabel.textContent = step.label;
view.setScale(step.days);
updateZoomUi();
});
window.addEventListener('resize', updateZoomUi);
// Clicking a surface in the timeline's left column flies the map to it: switch to
// the combined "Карта и график" view if only the timeline is open, then focus the
@ -458,6 +469,7 @@ async function init(): Promise<void> {
el.zoomSlider.value = String(defaultZoomIdx);
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label;
view.setScale(zoomSteps[defaultZoomIdx]!.days);
updateZoomUi();
// Apply the persisted view mode (inits the map if it starts visible).
void onModeChange(viewModes.getMode());
}

View File

@ -476,17 +476,19 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
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}`;
// Live legend counts (react to the current filter) — values only; the labels
// are static in the tile markup.
if (legendFree) legendFree.textContent = String(free);
if (legendBusy) legendBusy.textContent = String(busy);
// Total counts the whole inventory, including surfaces without coordinates
// (which can't be drawn but are still surfaces — listed under "без координат").
els.counter.textContent = `всего: ${surfaces.length}`;
els.counter.textContent = String(surfaces.length);
// Actionable "no coordinates" badge + list — manager-only.
// Actionable "no coordinates" tile + list — manager-only.
if (noCoordsBtn) {
noCoordsBtn.style.display = managerView && noCoordsList.length ? '' : 'none';
noCoordsBtn.textContent = `без координат: ${noCoordsList.length}`;
const ncVal = noCoordsBtn.querySelector<HTMLElement>('.st-v');
if (ncVal) ncVal.textContent = String(noCoordsList.length);
}
if (noCoordsPanel) {
noCoordsPanel.innerHTML = renderNoCoords(noCoordsList);

View File

@ -179,12 +179,7 @@ input::placeholder { color: var(--text-muted); }
}
/* Counters — compact inset inside the brand block. */
.brand #status {
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; font-variant-numeric: tabular-nums;
}
.brand #status { margin-top: auto; }
/* Scale group: stack the "Оформление" button under the zoom slider. */
.checkbox-field { flex-direction: row; align-items: center; gap: 6px; align-self: center; }
@ -193,11 +188,57 @@ input::placeholder { color: var(--text-muted); }
font-size: 13px; color: var(--text); text-transform: none; letter-spacing: normal;
font-weight: 400; cursor: pointer; white-space: nowrap;
}
.checkbox-field input[type=checkbox] { cursor: pointer; accent-color: var(--accent); }
/* Toggle switch: the checkbox keeps its semantics/JS, only the look changes.
Scoped to .checkbox-field so the dropdown option checkboxes stay normal. */
.checkbox-field input[type=checkbox] {
appearance: none; -webkit-appearance: none; margin: 0; flex: 0 0 auto;
width: 34px; height: 20px; border-radius: 20px; background: #cbd0d8;
position: relative; cursor: pointer; transition: background var(--transition);
}
.checkbox-field input[type=checkbox]::after {
content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px;
border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .25);
transition: left var(--transition);
}
.checkbox-field input[type=checkbox]:checked { background: var(--accent); }
.checkbox-field input[type=checkbox]:checked::after { left: 16px; }
.checkbox-field input[type=checkbox]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.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 .zoom-row { position: relative; width: 120px; padding-bottom: 24px; display: block; }
/* The scale control floats over the top-right of the timeline, just below the
time axis (JS sets `top` = axis bottom + 5px; this is only a fallback). */
#chart-wrap #zoom-field {
position: absolute; top: 52px; right: 16px; z-index: 20;
background: rgba(255, 255, 255, .92); border-radius: 8px;
padding: 3px 12px 4px; box-shadow: var(--shadow-md);
}
.zoom-field input[type=range] {
-webkit-appearance: none; appearance: none; width: 100%; height: 6px; margin: 0;
border-radius: 4px; cursor: pointer; outline: none;
background: linear-gradient(to right, var(--accent) var(--zoom-fill, 0%), var(--border-strong) var(--zoom-fill, 0%));
}
.zoom-field input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
background: #fff; border: 2px solid var(--accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .3); cursor: pointer;
}
.zoom-field input[type=range]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%; background: #fff;
border: 2px solid var(--accent); box-shadow: 0 1px 3px rgba(0, 0, 0, .3); cursor: pointer;
}
.zoom-field input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
/* Value bubble that rides below the slider thumb, pointing up (JS sets `left`). */
.zoom-bubble {
position: absolute; bottom: 0; top: auto; left: 0; transform: translateX(-50%);
background: var(--accent); color: #fff; font-size: 11px; font-weight: 600;
padding: 2px 8px; border-radius: 6px; white-space: nowrap; pointer-events: none;
font-variant-numeric: tabular-nums;
}
.zoom-bubble::after {
content: ""; position: absolute; top: -4px; bottom: auto; left: 50%; transform: translateX(-50%);
border: 4px solid transparent; border-bottom-color: var(--accent); border-top: 0;
}
/* checkbox dropdown filter */
.dropdown { position: relative; }
@ -233,6 +274,14 @@ input::placeholder { color: var(--text-muted); }
}
.dropdown-panel .dp-actions a { font-size: 11px; color: var(--accent); cursor: pointer; text-decoration: none; font-weight: 600; }
.dropdown-panel .dp-actions a:hover { text-decoration: underline; }
/* In-dropdown search that filters the option list. */
.dropdown-panel .dp-search {
display: block; width: 100%; box-sizing: border-box; padding: 8px 12px;
font-size: 13px; color: var(--text); background: var(--bg);
border: none; border-bottom: 1px solid var(--border); outline: none;
}
.dropdown-panel .dp-search::placeholder { color: var(--text-muted); }
.dropdown-panel .dp-search:focus { border-bottom-color: var(--accent); }
/* Оформление button + context menu */
/* Pinned to the header's bottom-right corner. */
@ -378,26 +427,34 @@ html.role-pending .manager-only { display: none !important; }
/* 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; }
/* ---- stat tiles (header counter + map legend) ---- */
.stat-tile {
display: inline-flex; flex-direction: column; align-items: flex-start; gap: 1px;
background: var(--surface); border-radius: 9px; padding: 5px 11px;
font-variant-numeric: tabular-nums; border: none;
}
.st-l { font-size: 11px; color: var(--text-muted); display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.st-v { font-size: 18px; font-weight: 600; line-height: 1.15; color: var(--text); }
.stat-accent { background: #eef0fe; }
.stat-accent .st-v { color: var(--accent); }
.st-v.st-free { color: #2f9e44; }
.st-v.st-busy { color: #d64545; }
#map-legend {
position: absolute; left: 10px; bottom: 44px; z-index: 5;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
padding: var(--size-2) var(--size-3); font-size: 12px; color: var(--text);
display: flex; flex-direction: column; align-items: flex-start; gap: var(--size-1);
box-shadow: var(--shadow-md); font-variant-numeric: tabular-nums;
display: flex; flex-direction: row; flex-wrap: wrap; gap: 6px;
}
#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 .stat-tile { box-shadow: var(--shadow-md); }
#map-legend .lg-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
#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); }
#map-legend .lg-total { background: var(--border-strong); }
/* Actionable "no coordinates" tile — dashed accent, clickable. */
.st-nc { cursor: pointer; background: #fbf6e9; border: 1px dashed #d7c08a; padding: 4px 10px; text-align: left;
transition: border-color var(--transition); }
.st-nc .lg-dot { background: #e8a317; }
.st-nc .st-v { color: #b07d16; }
.st-nc:hover { border-color: #b07d16; }
/* Panel listing surfaces missing coordinates. */
#map-nocoords-panel {
position: absolute; left: 10px; bottom: 150px; z-index: 6;

View File

@ -231,6 +231,18 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
timeline.on('changed', positionResizer);
window.addEventListener('resize', positionResizer);
// Keep the floating scale control just below the time axis (5px gap) so it
// never overlaps the axis; the axis height varies, so measure it each redraw.
function positionZoomField(): void {
const zf = document.getElementById('zoom-field');
const axis = el.timelineEl.querySelector<HTMLElement>('.vis-panel.vis-top');
if (!zf || !axis) return;
zf.style.top = axis.getBoundingClientRect().bottom - el.chartWrap.getBoundingClientRect().top + 5 + 'px';
}
timeline.on('changed', positionZoomField);
window.addEventListener('resize', positionZoomField);
positionZoomField();
let colDragging = false;
let colDragStartX = 0;
let colDragStartW = 0;