From 2cf82d91564e8aa6fc975e20dd75542aed98850c Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sat, 15 Aug 2026 03:41:15 +0300 Subject: [PATCH] docs: specify smooth viewport zoom Issue: #82 User-Visible: no --- docs/specs/082-smooth-zoom.md | 282 ++++++++++++++++++++++++++++++++++ docs/specs/README.md | 1 + 2 files changed, 283 insertions(+) create mode 100644 docs/specs/082-smooth-zoom.md diff --git a/docs/specs/082-smooth-zoom.md b/docs/specs/082-smooth-zoom.md new file mode 100644 index 00000000..2fcdab35 --- /dev/null +++ b/docs/specs/082-smooth-zoom.md @@ -0,0 +1,282 @@ +# Issue #82 — плавное масштабирование zoom/fit/reset + +- **Issue:** https://github.com/Matysh/houseplan-card/issues/82 +- **Статус документа:** готово к будущей реализации; issue остаётся на `S3-spec` +- **Приоритет:** P2 +- **Тип:** feature/polish, обычный трек +- **Пользовательское изменение:** да + +## 1. Проблема + +`_zoomAt()` и `_resetZoom()` сейчас атомарно заменяют `_zoom` и SVG `viewBox`. +Конечная камера корректна, но при частых действиях — кнопка, wheel, Fit, home и +double tap — глаз теряет связь между старым и новым фрагментом плана. + +#82 вводит короткую анимацию единой камеры. Она не может быть CSS scale одного +слоя: SVG, HTML markers, room labels, hit targets, Glow и editor overlays должны +видеть один viewport на каждом кадре. + +## 2. Цели + +1. Анимировать дискретные user zoom/fit/reset без изменения конечной математики. +2. Объединять rapid wheel в один retargetable transition. +3. Оставить pinch и pan прямыми 1:1. +4. Сохранить reduced-motion, interruptions, persistence и lifecycle contracts. +5. Не перезапускать live layers и не создавать structural rebuild на каждом RAF. + +## 3. Не входит в задачу + +- inertia, kinetic pan, bounce/overscroll; +- animation space/mode change или initial mount; +- изменение zoom limits, pan slack и content frame; +- пользовательская настройка duration/easing; +- изменение relative icon size; +- новый storage format; +- ухудшение Glow/plan pixels во время tween; +- замена mode transition controller #101. + +## 4. Матрица поведения + +| Источник | Результат | Anchor | Duration | +| --- | --- | --- | --- | +| `−` / `+` | один плавный zoom step | центр stage | 180 ms | +| discrete wheel | retargetable zoom | pointer | до 160 ms после последнего input | +| trackpad wheel stream | один tween без queue | актуальный pointer | retarget | +| Fit / средняя кнопка | к fit viewport | target content center | 220 ms | +| home arrow | к fit viewport | target content center | 220 ms | +| double tap reset | к zoom 1 / fit | target center | 220 ms | +| pinch | без tween | finger midpoint | direct | +| pan | без tween/inertia | pointer | direct | + +Duration — compile-time UI constants. После замеров допустима унификация в +диапазоне 160–220 ms без изменения продукта. + +## 5. Сценарии без анимации + +Viewport применяется атомарно при: + +- initial mount и чтении сохранённого zoom; +- warm remount, visibility resume и reconnect; +- смене пространства; +- View ↔ editor и editor ↔ editor; +- ResizeObserver/window resize и изменении toolbar height; +- adoption config/layout revision и изменении content frame; +- continuity recovery; +- нулевом/нестабильном stage; +- `prefers-reduced-motion: reduce`. + +Эти пути не должны получить промежуточный fit flash, veil или старый target. + +## 6. Camera state + +Pure модуль, условно `src/viewport-animation.ts`, оперирует: + +```ts +interface CameraState { + zoom: number; + view: { x: number; y: number; w: number; h: number }; +} + +interface ViewportTransition { + from: CameraState; + to: CameraState; + startedAt: number; + durationMs: number; + reason: 'button' | 'wheel' | 'fit' | 'home' | 'double-tap'; +} +``` + +Компонент остаётся единственным владельцем reactive `_zoom`/`_view`; animator +вычисляет next state и lifecycle. В один момент существует максимум один RAF. + +Target строится существующими `fitView`, `ZOOM_MIN/MAX`, `_clampView` и текущей +anchor-математикой. #82 не имеет альтернативных формул clamp. + +## 7. Интерполяция + +- easing — короткий ease-out, эквивалент `cubic-bezier(0.2, 0.7, 0.2, 1)`; +- zoom интерполируется в log-space; +- camera center интерполируется линейно с тем же eased progress; +- width/height выводятся из интерполированного zoom и stage aspect; +- каждый frame проходит clamp; +- финальный frame присваивает exact target, исключая accumulated error; +- invalid numbers немедленно завершаются safe target/fallback без NaN DOM. + +Pure helpers принимают управляемый progress/clock, чтобы unit и smoke не +зависели от случайной wall-clock миллисекунды. + +## 8. Wheel retargeting + +Новый wheel event: + +1. берёт текущее представленное состояние running tween; +2. накапливает zoom от предыдущего target, а не от запаздывающего start; +3. пересчитывает target вокруг актуального pointer anchor; +4. заменяет transition без queue и без лишнего RAF; +5. позволяет мгновенно развернуть направление. + +При отсутствии clamp точка под курсором остаётся под курсором с ошибкой не более +0.5 CSS px. При clamp смещение допустимо только по ограниченной оси. + +## 9. Прерывания + +- новый discrete zoom retarget-ит running tween; +- pointerdown для pan/pinch/draw/drag фиксирует текущий visual frame, отменяет + tween и начинает жест без jump; +- mode/space change, resize, config/layout adoption и continuity recovery + отменяют tween и передают управление соответствующему atomic flow; +- `visibilitychange → hidden` коммитит user target немедленно, чтобы при возврате + animation не продолжалась; +- `disconnectedCallback()` отменяет RAF и очищает state; +- min/max no-op не создаёт RAF и не пишет storage; +- Escape не отменяет zoom сам по себе, кроме закрытия owning interaction. + +## 10. Интерактивность и слои + +Во время tween нет overlay и `inert`: + +- SVG и HTML consumers читают один `_zoom`/`_view` frame; +- pointer hit-test использует реально показанную камеру; +- pointerdown сначала завершает/cancel-ит animation boundary, затем выполняет + действие; +- hover, tooltip и selection не отстают от пикселей; +- editor snap после pointerdown получает тот же viewport; +- click-through по прежнему положению объекта невозможен. + +Glow source resolution, opacity, blending и 500-ms live fade не меняются. +Camera frames не меняют structural fingerprint, `_cfgEpoch`, registry/device +graph или iso geometry cache. Нельзя временно скрывать backdrop, walls, decor, +devices или effects ради скорости. + +## 11. Persistence + +- View сохраняет один итоговый target после settle; +- direct gesture сохраняет фактический viewport по нынешнему gesture-end path; +- editor zoom не попадает в View intent; +- `LS_ZOOM` не меняет schema; +- cancelled structural transition не сохраняет stale target; +- no-op не переписывает localStorage. + +Zoom badge во время tween показывает текущий нарисованный процент, после settle +— exact target. Accessible names кнопок не меняются. + +## 12. Touch и accessibility + +- pinch остаётся без lag и post-animation; +- double tap reset анимируется только после подтверждённого single-pointer + gesture и не конкурирует с pinch; +- reduced motion всегда идёт immediate path; +- animation не переносит DOM focus; +- keyboard-operated zoom buttons используют ту же transition систему; +- View/kiosk touch smoke является release-blocking. + +## 13. Edge cases + +- точные `ZOOM_MIN` и `ZOOM_MAX`; +- zoom 1 со смещённым center; +- потерянный план и home arrow; +- wide/tall/diagonal/degenerate content frame; +- пустое пространство и fallback `view_box`; +- rapid wheel в обе стороны и меняющийся anchor; +- wheel + pointerdown в одном frame; +- pinch во время tween; +- resize toolbar/stage и mode/space switch; +- visibility hidden сразу после старта; +- Flat/Iso, light/dark, kiosk и три editor mode; +- отсутствие RAF в test/non-browser environment → immediate fallback. + +## 14. Acceptance criteria + +1. Buttons, wheel, Fit, home и double tap имеют промежуточный viewport. +2. Pinch и pan остаются direct и не получают post-animation. +3. Все visible layers и hit targets используют один camera state на кадр. +4. Wheel сохраняет anchor и объединяется в один retargetable tween. +5. Reversal не создаёт queue или jump. +6. Финальная камера совпадает с прежней zoom/fit/clamp математикой. +7. Min/max no-op не запускает loop. +8. Pointer, navigation, resize и structural reload прерывают безопасно. +9. Mount/resume/reconnect остаются atomic. +10. Reduced motion не имеет промежуточного кадра. +11. View persistence выполняет одну запись; editor не пишет View intent. +12. Glow/hover/backdrop/device state не мигают и не пересчитываются структурно. +13. Disconnect не оставляет RAF/listener/timer. +14. Heavy fixture заканчивает transition без blank/black/stale frame. + +## 15. План тестирования + +### Unit + +- interpolation start/mid/end, monotonic log zoom, exact target; +- anchor preservation и clamp exception; +- retarget from presented state, reversal и no queue; +- min/max no-op и reduced-motion immediate path; +- cancellation matrix; +- persistence count и editor no-write; +- invalid/degenerate inputs. + +### Browser smoke + +- zoom button с промежуточным и final frame; +- edge wheel anchor и rapid stream; +- Fit/home/double tap; +- pinch/pan во время tween; +- click device/room в движущемся viewport; +- View/Plan/Devices/Backdrop parity; +- reduced motion; +- visibility/mode/space/resize/disconnect; +- #73/#101 continuity regression; +- Glow source/opacity/blend parity до/во время/после. + +### Performance + +На canonical heavy Glow fixture: + +- один RAF loop; +- ноль registry/device/geometry rebuild на camera frame; +- duration не превышается более чем на два реально доступных кадра после + main-thread stall; +- нет long blank/black frame; +- обычный `large-house-v1` и isometric profile остаются в текущих budgets. + +Golden baseline не переакцептуется: final pixels должны совпасть. Для доказательства +движения используется deterministic frame/screencast smoke, а не новый final PNG. + +## 16. План реализации + +1. Вынести pure camera interpolation/retarget helpers. +2. Добавить один animator lifecycle в card. +3. Перевести button/wheel/fit/home/double tap на target API. +4. Подключить cancellation к direct gestures и structural flows. +5. Отложить persistence до settle. +6. Добавить reduced-motion и lifecycle cleanup. +7. Прогнать unit, browser smoke, build и pre-beta performance. + +## 17. Документация и release-артефакты + +- оба changelog получают user-visible пункт; +- `docs/USER-GUIDE.ru.md` обновляет zoom/gesture table и reduced-motion; +- `docs/CANVAS.md` описывает единого camera animator и interruption contract; +- `docs/TESTING.md` получает deterministic transition checks; +- новые i18n keys не требуются, если button text не меняется; +- final golden не меняется; mid-transition evidence проверяется smoke; +- Full Performance обязателен перед beta по обычному release process. + +## 18. Риски и откат + +| Риск | Мера | +| --- | --- | +| SVG/HTML расходятся | один reactive CameraState | +| Wheel ощущается медленным | retarget, не queue | +| Pointer выбирает старую позицию | cancel at presented frame before hit-test | +| Glow rebuild на каждом RAF | cache/rebuild counters в smoke/perf | +| Resume проигрывает старый tween | explicit structural cancellation matrix | + +Откат возвращает discrete handlers к `_zoomAt()`/`_resetZoom()`. Storage и model +не мигрируют. + +## 19. Принятые технические предположения + +- pure helper может жить отдельно от mode transition #101; +- 160–220 ms допустимо настраивать после измерения без product review; +- current stage aspect остаётся authoritative на каждом frame; +- #152 при более поздней реализации переиспользует этот animator. diff --git a/docs/specs/README.md b/docs/specs/README.md index 9e9d58b4..5e01ac54 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -112,6 +112,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#68](https://github.com/Matysh/houseplan-card/issues/68) Подсказки к настройкам | [068-help-affordance.md](068-help-affordance.md) | | [#74](https://github.com/Matysh/houseplan-card/issues/74) Position-only Undo/Redo устройств | [074-device-position-undo.md](074-device-position-undo.md) | | [#75](https://github.com/Matysh/houseplan-card/issues/75) + [#76](https://github.com/Matysh/houseplan-card/issues/76) Единый поток размещения проёмов | [075-076-opening-placement-flow.md](075-076-opening-placement-flow.md) | +| [#82](https://github.com/Matysh/houseplan-card/issues/82) Плавное масштабирование zoom/fit/reset | [082-smooth-zoom.md](082-smooth-zoom.md) | | [#84](https://github.com/Matysh/houseplan-card/issues/84) Источник «Всегда» без сущности HA + [#88](https://github.com/Matysh/houseplan-card/issues/88) ведущая сущность | [084-passive-forced-light-sources.md](084-passive-forced-light-sources.md) | | [#86](https://github.com/Matysh/houseplan-card/issues/86) Подсказки к настройкам, партия 1 | [086-settings-help-content-party1.md](086-settings-help-content-party1.md) | | [#90](https://github.com/Matysh/houseplan-card/issues/90) Управляемый бейдж со значением | [090-device-value-badge.md](090-device-value-badge.md) |