-
свободна
-
занята
-
-
+
свободно0
+
занято0
+
всего0
+
diff --git a/frontend/package.json b/frontend/package.json
index ef699d9..c0e7f1d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "mapdash-frontend",
"private": true,
- "version": "0.2.11",
+ "version": "0.2.12",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/dropdown.ts b/frontend/src/dropdown.ts
index 69a1b9a..9044795 100644
--- a/frontend/src/dropdown.ts
+++ b/frontend/src/dropdown.ts
@@ -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('.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');
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 6c112be..64f81eb 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -324,7 +324,7 @@ el.emptyResetTimeline.addEventListener('click', resetFilters);
el.emptyResetMap.addEventListener('click', resetFilters);
async function loadData(fit: boolean): Promise {
- el.status.textContent = 'Загрузка…';
+ el.status.innerHTML = 'Поверхности…';
loadStart();
try {
const f = currentFilters();
@@ -338,7 +338,7 @@ async function loadData(fit: boolean): Promise {
lastBoards = boards;
lastBookings = bookings;
view.render(boards, bookings, fit, flags());
- el.status.textContent = `Поверхности: ${boards.length}`;
+ el.status.innerHTML = `Поверхности${boards.length}`;
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 {
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());
}
diff --git a/frontend/src/map.ts b/frontend/src/map.ts
index 2130bf9..4299576 100644
--- a/frontend/src/map.ts
+++ b/frontend/src/map.ts
@@ -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('.st-v');
+ if (ncVal) ncVal.textContent = String(noCoordsList.length);
}
if (noCoordsPanel) {
noCoordsPanel.innerHTML = renderNoCoords(noCoordsList);
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 20d78d0..a9c6dba 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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;
diff --git a/frontend/src/timeline.ts b/frontend/src/timeline.ts
index 402e855..be64cad 100644
--- a/frontend/src/timeline.ts
+++ b/frontend/src/timeline.ts
@@ -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('.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;