Плашка масштаба графика — сворачиваемая с анимацией

По умолчанию плашка свёрнута (65×35) — белая плашка с фиолетовым пилюлем текущего
масштаба («12М» и т.п.). При наведении разворачивается (145×80) в полный контрол
(лейбл + слайдер + баббл) с плавной анимацией размеров (overflow:hidden + фикс.
размеры) и кросс-фейдом содержимого. Сворачивается обратно через 1с после ухода
курсора (JS-таймер в main.ts, класс .expanded; повторное наведение отменяет). В
развёрнутом виде содержимое смещено на 4px вниз для вертикальной центровки.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
aaverbitskiy 2026-08-17 12:34:51 +00:00
parent 43bb72de61
commit 194e740ec0
4 changed files with 51 additions and 10 deletions

View File

@ -165,12 +165,15 @@
<div id="chart-wrap"> <div id="chart-wrap">
<div id="timeline"></div> <div id="timeline"></div>
<div class="field zoom-field" id="zoom-field"> <div class="field zoom-field" id="zoom-field">
<span id="zoom-collapsed" class="zoom-collapsed">12М</span>
<div class="zoom-full">
<label for="zoom-slider">Масштаб графика</label> <label for="zoom-slider">Масштаб графика</label>
<div class="zoom-row"> <div class="zoom-row">
<span id="zoom-label" class="zoom-bubble">12М</span> <span id="zoom-label" class="zoom-bubble">12М</span>
<input type="range" id="zoom-slider" min="0" max="4" step="1" value="1" /> <input type="range" id="zoom-slider" min="0" max="4" step="1" value="1" />
</div> </div>
</div> </div>
</div>
<div id="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div> <div id="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div>
<div id="empty" class="empty-state" style="display:none"> <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> <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>

View File

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

View File

@ -35,6 +35,8 @@ const el = {
showAllSurfaces: document.getElementById('show-all-surfaces') as HTMLInputElement, showAllSurfaces: document.getElementById('show-all-surfaces') as HTMLInputElement,
zoomSlider: document.getElementById('zoom-slider') as HTMLInputElement, zoomSlider: document.getElementById('zoom-slider') as HTMLInputElement,
zoomLabel: document.getElementById('zoom-label') as HTMLElement, zoomLabel: document.getElementById('zoom-label') as HTMLElement,
zoomCollapsed: document.getElementById('zoom-collapsed') as HTMLElement,
zoomField: document.getElementById('zoom-field') as HTMLElement,
chartWrap: document.getElementById('chart-wrap') as HTMLElement, chartWrap: document.getElementById('chart-wrap') as HTMLElement,
colResizer: document.getElementById('col-resizer') as HTMLElement, colResizer: document.getElementById('col-resizer') as HTMLElement,
decorBtn: document.getElementById('decor-btn') as HTMLButtonElement, decorBtn: document.getElementById('decor-btn') as HTMLButtonElement,
@ -460,11 +462,26 @@ function updateZoomUi(): void {
el.zoomSlider.addEventListener('input', () => { el.zoomSlider.addEventListener('input', () => {
const step = zoomSteps[parseInt(el.zoomSlider.value, 10)]!; const step = zoomSteps[parseInt(el.zoomSlider.value, 10)]!;
el.zoomLabel.textContent = step.label; el.zoomLabel.textContent = step.label;
el.zoomCollapsed.textContent = step.label;
view.setScale(step.days); view.setScale(step.days);
updateZoomUi(); updateZoomUi();
}); });
window.addEventListener('resize', updateZoomUi); window.addEventListener('resize', updateZoomUi);
// The scale plate is collapsed to a small value pill by default; expand on hover
// and collapse again 1s after the pointer leaves (so it doesn't snap shut while
// you're reaching back toward it).
let zoomCollapseTimer = 0;
el.zoomField.addEventListener('mouseenter', () => {
window.clearTimeout(zoomCollapseTimer);
el.zoomField.classList.add('expanded');
updateZoomUi(); // realign the bubble now that the slider has a real width
});
el.zoomField.addEventListener('mouseleave', () => {
window.clearTimeout(zoomCollapseTimer);
zoomCollapseTimer = window.setTimeout(() => el.zoomField.classList.remove('expanded'), 1000);
});
// Clicking a surface in the timeline's left column flies the map to it: switch to // 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 // the combined "Карта и график" view if only the timeline is open, then focus the
// surface's cluster. Works for anonymous and manager users alike. // surface's cluster. Works for anonymous and manager users alike.
@ -494,6 +511,7 @@ async function init(): Promise<void> {
const defaultZoomIdx = 1; // 12М const defaultZoomIdx = 1; // 12М
el.zoomSlider.value = String(defaultZoomIdx); el.zoomSlider.value = String(defaultZoomIdx);
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label; el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label;
el.zoomCollapsed.textContent = zoomSteps[defaultZoomIdx]!.label;
view.setScale(zoomSteps[defaultZoomIdx]!.days); view.setScale(zoomSteps[defaultZoomIdx]!.days);
updateZoomUi(); updateZoomUi();
// Apply the persisted view mode (inits the map if it starts visible). // Apply the persisted view mode (inits the map if it starts visible).

View File

@ -262,16 +262,36 @@ input::placeholder { color: var(--text-muted); }
.checkbox-field input[type=checkbox]:checked::after { left: 16px; } .checkbox-field input[type=checkbox]:checked::after { left: 16px; }
.checkbox-field input[type=checkbox]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .checkbox-field input[type=checkbox]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.zoom-field { gap: 4px; }
.zoom-field label { white-space: nowrap; } .zoom-field label { white-space: nowrap; }
.zoom-field .zoom-row { position: relative; width: 120px; padding-bottom: 24px; display: block; } .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 /* Scale control floats over the top-right of the timeline (JS sets `top` = axis
time axis (JS sets `top` = axis bottom + 5px; this is only a fallback). */ bottom + 5px). Collapsed by default: a small plate showing only the value pill.
JS toggles `.expanded` (on hover; collapse is delayed ~1s after mouseleave).
Fixed sizes + overflow:hidden let width/height animate; the two faces cross-fade. */
#chart-wrap #zoom-field { #chart-wrap #zoom-field {
position: absolute; top: 52px; right: 16px; z-index: 20; position: absolute; top: 52px; right: 16px; z-index: 20;
background: rgba(255, 255, 255, .92); border-radius: 8px; width: 65px; height: 35px; overflow: hidden;
padding: 3px 12px 4px; box-shadow: var(--shadow-md); background: rgba(255, 255, 255, .95); border-radius: 8px; box-shadow: var(--shadow-md);
transition: width .24s var(--ease-3), height .24s var(--ease-3);
} }
#chart-wrap #zoom-field.expanded { width: 145px; height: 80px; }
/* Collapsed face: the accent value pill, centred in the small plate. */
.zoom-collapsed {
position: absolute; inset: 0; margin: auto; width: fit-content; height: fit-content;
display: inline-flex; align-items: center; justify-content: center;
background: var(--accent); color: #fff; font-size: 11px; font-weight: 600;
padding: 3px 9px; border-radius: 6px; white-space: nowrap; cursor: pointer;
font-variant-numeric: tabular-nums;
opacity: 1; transition: opacity .14s ease .06s;
}
#chart-wrap #zoom-field.expanded .zoom-collapsed { opacity: 0; transition-delay: 0s; pointer-events: none; }
/* Expanded face: label + slider + bubble. */
.zoom-full {
position: absolute; top: 4px; left: 0; padding: 3px 12px 4px;
display: flex; flex-direction: column; gap: 4px;
opacity: 0; pointer-events: none; transition: opacity .14s ease;
}
#chart-wrap #zoom-field.expanded .zoom-full { opacity: 1; pointer-events: auto; transition-delay: .08s; }
.zoom-field input[type=range] { .zoom-field input[type=range] {
-webkit-appearance: none; appearance: none; width: 100%; height: 6px; margin: 0; -webkit-appearance: none; appearance: none; width: 100%; height: 6px; margin: 0;
border-radius: 4px; cursor: pointer; outline: none; border-radius: 4px; cursor: pointer; outline: none;