Files
houseplan-card/docs/specs/082-smooth-zoom.md
T
Codex 8f485c0b00 fix: the camera keeps the zoom you see and the point you hold (#396)
Three findings of the v1.70.0-beta.1 audit, all on the transition path
added by #82, all of the same shape — the new path did not inherit a
property the old one had.

B1: persisting the zoom moved into _settleCameraTransition only, and a
cancellation never settles. Touching the plan mid-flight — the literal
scenario of the issue — froze the shown frame and threw it away; before
which kind it is: the user one (_stagePointerDown) persists the frame
that stays on screen, the eleven structural ones keep writing nothing.
The distinction is now also written down in spec #82 §13, which had one
line for both.

B2: the anchor was read from the presented (lagging) frame while the
zoom accumulated from the target, so a six-notch trackpad series walked
the point under the cursor 17 px away — against §10's own promise. Both
now come from the same state. Spec §10 said to use the presented frame
and to keep the anchor within 0.5 px; those two are incompatible, and
the paragraph is corrected rather than left as a trap.

M2: the feather freeze keyed on the two gesture flags, which an
animated transition does not set, so every tween frame rebuilt the blur
region. It keys on 'the camera is still' now.

Guards: unit tests pin the anchor at 1e-9 across 8/16/33 ms series and
prove zoom accumulation is untouched; the smoke checks the shown zoom is
the stored one, that a structural cancellation stores nothing, and that
the anchor holds; three mutants (cancel-loses-zoom, anchor-from-
presented, feather-thaws) were run by hand and each reddens.

User-Visible: yes
Issue: #396
2026-08-31 01:36:59 +03:00

26 KiB
Raw Blame History

Issue #82 — плавное масштабирование плана

  • Issue: https://github.com/Matysh/houseplan-card/issues/82
  • Актуализировано: 2026-08-30 для dev / v1.69.0
  • Приоритет: P2
  • Тип: feature / polish, полный трек
  • Пользовательское изменение: да

1. Сценарий и персона

Житель или гость рассматривает дом в View либо kiosk и приближает нужный фрагмент колесом, кнопками или двойным tap. Администратор делает то же в одном из desktop-first редакторов, чтобы точнее разместить объект. В обоих случаях дискретное изменение масштаба сейчас происходит мгновенно, и глаз на короткое время теряет объект и направление движения камеры.

View и kiosk остаются полностью поддерживаемыми на touch. Редакторы остаются desktop-first по docs/TOUCH-SUPPORT.md; задача не расширяет обещание полного touch-редактирования.

2. Что человек увидит до и после

До: колесо, кнопки, «Вписать всё», стрелка возврата к потерянному плану и двойной tap скачком заменяют показанный фрагмент.

После: те же действия за 160–220 мс плавно ведут к тому же конечному фрагменту, а pan и pinch по-прежнему без задержки следуют за рукой.

3. Актуальность и проблема

Задача полностью актуальна на текущем dev:

  • _zoomAt() немедленно заменяет _zoom и _view;
  • _onWheel() и _stepZoom() сразу вызывают _zoomAt();
  • _resetZoom() сразу ставит zoom 1 и текущий content fit;
  • _fitAll(), _fitFar(), home-arrow и kiosk double-tap проходят через _resetZoom();
  • ModeTransitionController из #101 плавно меняет режимы, но намеренно не обслуживает zoom внутри одного режима;
  • интерактивная камера находится в основном houseplan-card, а lazy houseplan-editor-runtime лишь вызывает её контракты. Для View не требуется загружать editor chunk;
  • flat и isometric уже используют одну текущую _view; новый переход обязан работать в координатах активной проекции и не менять их преобразование.

Проблема относится к частой навигации J1/J2 из docs/SCOPE.md. Это не критическая ошибка, но заметный polish: короткое движение сохраняет пространственный контекст и делает дорогой визуально продукт спокойнее.

4. Цели

  1. Плавно анимировать только дискретные пользовательские zoom/fit/reset.
  2. Объединять rapid wheel в один retargetable переход без очереди.
  3. Сохранить точную текущую математику zoom, anchor, fit, clamp и projection.
  4. Оставить pinch и pan прямыми 1:1.
  5. Согласовать camera transition с #73, #101, warm remount, lazy editor и visual continuity.
  6. Не перезапускать Glow, device resolution или тяжёлую геометрию на кадрах камеры.

5. Не входит в задачу

  • inertia, kinetic pan, bounce и overscroll;
  • плавный pan как самостоятельная функция;
  • focus-on-room и другие новые команды камеры;
  • анимация пространства, flat ↔ isometric, View ↔ editor, initial mount, resume или reconnect;
  • изменение MIN_ZOOM, ZOOM_MAX, PAN_SLACK, content frame либо outlier policy;
  • изменение относительных размеров marker, furniture strokes или подписей;
  • настройка duration/easing пользователем;
  • новый storage/config/backend формат;
  • изменение визуальной семантики Glow или degradation изображения на время zoom;
  • интерактивный zoom в houseplan-space-card: у static card его нет.

6. Матрица поведения

Источник Результат Якорь Длительность
Кнопки − / + один плавный zoom step центр stage 180 мс
Mouse wheel retargetable zoom текущая позиция курсора до 160 мс после последнего input
Trackpad wheel stream один непрерывно retargetable tween текущая позиция курсора без очереди
«Вписать всё» / средняя кнопка к актуальному core frame центр target frame 220 мс
Far hint «Показать» к frame со всеми outliers центр target frame 220 мс
Home-arrow к актуальному frame центр target frame 220 мс
Kiosk double-tap к zoom 1 / fit центр target frame 220 мс
Pinch без tween midpoint пальцев direct
Pan / swipe без tween и inertia pointer direct

Duration — compile-time UI constants. После инструментального измерения их можно унифицировать внутри 160–220 мс без нового продуктового решения, если сохраняется ощущение короткого перехода.

7. Сценарии без анимации

Viewport применяется атомарно при:

  • initial mount, чтении сохранённого zoom и cold/warm restore;
  • visibility resume, reconnect и continuity recovery;
  • смене пространства;
  • flat ↔ isometric;
  • View ↔ editor и editor ↔ editor: ими владеет #101;
  • ResizeObserver, изменении toolbar/context-tray height и resize окна;
  • принятии config/layout revision и изменении content frame;
  • нулевом или нестабильном stage;
  • prefers-reduced-motion: reduce.

Эти пути не получают промежуточный fit flash, veil или отложенное проигрывание старого camera target.

8. Единый camera-only controller

Добавляется pure camera transition controller, отдельный от ModeTransitionController, но использующий ту же perceptual easing и тот же принцип одного RAF/token owner.

interface CameraState {
  zoom: number;
  viewBox: { x: number; y: number; w: number; h: number };
}

interface CameraTransition {
  from: CameraState;
  to: CameraState;
  startedAt: number;
  durationMs: number;
  reason: 'button' | 'wheel' | 'fit' | 'home' | 'double-tap';
}

Компонент остаётся единственным владельцем reactive _zoom и _view. Controller вычисляет представленный кадр и lifecycle, но не знает о Lit, localStorage, режимах или Home Assistant.

Одновременно действует не более одного владельца камеры:

  • camera transition не запускается во время подготовки/running перехода режима;
  • начало mode/space/projection/structural transition отменяет camera transition согласно §11;
  • camera transition не меняет _modeTransitionVisual, размеры chrome, background или opacity слоёв;
  • core View не импортирует lazy editor runtime ради анимации.

9. Target и интерполяция

Target строится до запуска перехода существующими fitView, _baseVb(), MIN_ZOOM, ZOOM_MAX, _clampView() и anchor-математикой _zoomAt(). Альтернативной формулы fit/clamp у #82 нет.

  • easing соответствует cubic-bezier(0.2, 0.7, 0.2, 1) из #101;
  • zoom интерполируется в log-space;
  • camera center интерполируется линейно с тем же eased progress;
  • width/height выводятся из интерполированного zoom и текущего aspect;
  • промежуточный кадр проходит текущий clamp;
  • последний кадр присваивает exact target, исключая накопленную ошибку;
  • NaN, infinity, вырожденный stage или отсутствие RAF ведут к безопасному immediate target, а не к сломанному DOM;
  • pure helpers принимают управляемый clock/progress для детерминированных unit-тестов.

Независимый CSS transform: scale() запрещён как конечный или временный источник истины: он иначе масштабирует SVG strokes, blur, HTML markers и hit targets и создаёт финальный commit jump.

10. Wheel retargeting и anchor

Каждое новое wheel-событие:

  1. берёт реально представленный camera state running tween как точку старта анимации;
  2. накапливает zoom от предыдущего target, а не от запаздывающего кадра;
  3. вычисляет world-point под актуальным pointer в целевом viewport running tween — из того же состояния, из которого взят zoom в п.2 (при отсутствии running tween целевое и представленное совпадают). Правка #396: чтение точки из представленного кадра уводило anchor на 14–16 CSS px при интервале между событиями 8–33 мс, то есть на обычном трекпаде, и противоречило порогу ниже;
  4. строит новый target так, чтобы этот point остался под pointer;
  5. заменяет transition без queue и второго RAF;
  6. допускает немедленный разворот направления.

Без clamp anchor остаётся на месте точно — расхождение мировой точки не превышает 1e-9 единиц плана (плавающая погрешность), а не «0.5 CSS px». При clamp смещение допускается только на ограниченной оси. deltaMode нормализуется так, чтобы line/page wheel не создавал многосекундную очередь; текущий один event = один factor остаётся совместимым.

11. Прерывания и конкуренция

  • Новый discrete zoom retarget-ит running camera transition.
  • Pointerdown, который начинает pan, pinch, draw, drag или selection, сначала фиксирует представленный кадр и отменяет tween без скачка.
  • Pinch всегда использует immediate _zoomAt() и не получает post-animation.
  • Mode/space/projection change, resize, config/layout adoption, continuity recovery и viewport restore отменяют camera transition и выполняют свой существующий атомарный контракт.
  • Если документ становится hidden, user target коммитится сразу; при возврате старый tween не продолжается.
  • disconnectedCallback() отменяет RAF и очищает transition state.
  • Изменение media query на reduced motion во время tween коммитит target сразу.
  • Повторное действие на точном min/max или exact fit — no-op: RAF и запись storage не создаются.
  • Escape не отменяет самостоятельный wheel/button zoom, если не запускает действие, которое само владеет viewport.

12. Интерактивность и слои

Во время camera tween stage не получает overlay или inert:

  • SVG, HTML markers, room labels, hit targets, Glow и editor overlays читают один представленный _zoom / _view;
  • zoom badge показывает текущий нарисованный процент и exact target после settle;
  • hover, tooltip и selection не отстают от пикселей;
  • pointerdown продолжает действие из реально показанной камеры;
  • opening/furniture/draw previews очищаются один раз при zoom-команде либо прямом жесте, а не на каждом RAF;
  • click-through по прежнему положению объекта невозможен.

Glow source resolution, opacity, blending, shadows и live fade не меняются. Camera frames не меняют structural fingerprint, config/layout epoch, device graph, wall model, projection cache или Glow source set. Нельзя временно скрывать backdrop, стены, decor, устройства либо эффекты ради скорости.

13. Persistence

  • View записывает итоговый target после settle, один раз на transition.
  • Retargetable wheel stream также даёт одну финальную запись.
  • Editor zoom не записывается в View intent.
  • Формат LS_ZOOM и warm viewport memo не меняется.
  • Отмена перехода бывает двух видов, и они пишут по-разному (уточнено #396; прежняя единственная строка про «stale target» их не различала, из-за чего прерванный зум терялся):
    • пользовательская — _stagePointerDown, то есть касание плана поверх собственного зума. Представленный кадр замораживается и остаётся на экране, значит он и есть текущее намерение: сохраняется;
    • структурная — смена пространства/режима/проекции, _applyView, resize, adoption конфига или layout, _restoreZoom, continuity recovery, disconnect. Вид заменяется целиком другим контрактом: не сохраняется ничего, цель устарела вместе с видом.
  • Immediate pinch/pan используют существующий gesture persistence contract; его отдельная оптимизация не входит в #82.
  • No-op не переписывает localStorage.

14. Модель данных, миграция и compatibility

Server config, layout, backend API, normalized model и файлы плана не меняются. Новых сохраняемых полей нет, миграция отсутствует. Старые и новые карточки читают один и тот же LS_ZOOM; отличие только presentation-time.

15. Touch и accessibility

  • Pinch остаётся без lag и строго следует midpoint.
  • Kiosk double-tap анимируется только после подтверждённого single-pointer gesture и не конкурирует с pinch/swipe lock.
  • prefers-reduced-motion: reduce, включая его изменение во время перехода, всегда ведёт к immediate exact target.
  • Keyboard activation кнопок использует тот же transition path.
  • Анимация не переносит DOM focus и не меняет accessible names.
  • View/kiosk touch smoke release-blocking; touch editors — safety regression check по docs/TOUCH-SUPPORT.md.

16. Edge cases

  • точные MIN_ZOOM = 1/3 и ZOOM_MAX = 8;
  • zoom 1 со смещённым center;
  • content полностью вне viewport и home-arrow;
  • core frame против all/outlier frame;
  • wide, tall, diagonal, line-only и degenerate content;
  • пустое пространство с fallback view_box;
  • rapid wheel в обе стороны и меняющийся anchor;
  • wheel + pointerdown в одном frame;
  • pinch во время tween;
  • resize toolbar/stage, mode/space switch и projection switch;
  • visibility hidden сразу после старта;
  • flat/isometric, light/dark, kiosk, View и три editor mode;
  • отсутствующий RAF/performance clock в тестовом окружении.

17. Acceptance criteria и доказательства

AC Критерий Обязательное доказательство
AC1 −/+, wheel, fit, far-fit, home и kiosk double-tap имеют хотя бы один промежуточный camera frame и прежний exact target Unit + browser smoke
AC2 Pinch и pan остаются direct, без lag и post-animation Browser smoke
AC3 Все SVG/HTML/effect/hit слои используют один camera state; видимый объект кликается во время tween Browser smoke + code review
AC4 Rapid wheel использует один RAF, retarget без queue, сохраняет pointer anchor и допускает reversal Unit + browser smoke
AC5 Exact min/max/fit no-op не запускает RAF и не пишет storage Unit
AC6 Pointer, mode, space, projection, resize, adoption, hidden и disconnect завершают/отменяют transition по матрице §11 без jump Unit + browser smoke
AC7 Mount/resume/reconnect/warm restore остаются атомарными и не проигрывают старый target Browser smoke continuity regression
AC8 Reduced motion до и во время tween даёт immediate exact target Unit + browser smoke
AC9 View пишет один итоговый zoom; editor и cancelled structural target не пишут View intent Unit + browser smoke
AC10 Flat и isometric приходят к тем же конечным viewBox/zoom, что до #82 Unit + browser smoke
AC11 Glow source count, opacity/blend, hover, backdrop и device state не меняются из-за camera frame Browser smoke + performance counters
AC12 Disconnect не оставляет RAF, timer или listener Unit
AC13 Heavy flat/isometric/Glow fixtures завершают transition без blank, black, stale frame и без structural rebuild на RAF Performance profile + screencast smoke
AC14 Основной View не запрашивает lazy editor chunk только из-за zoom Browser network assertion

18. План автотестов

Unit

Новый test/viewport-transition.test.mjs проверяет:

  • easing/interpolation start, mid, end и exact target;
  • monotonic log zoom и linear center;
  • anchor preservation, clamp exception и aspect;
  • retarget from presented state, accumulated target и reversal;
  • единственный RAF/no queue;
  • min/max/fit no-op;
  • reduced-motion/immediate fallback;
  • cancel, commit-target, hidden и dispose;
  • invalid/degenerate inputs.

Существующие mode-transition.test.mjs остаются зелёными; общая easing helper не может изменить их согласованные кадры.

Browser smoke

Новый demo/smoke_smooth_zoom.mjs на production component проверяет AC1–AC12 и AC14. Clock управляется через controller/harness, поэтому assertions не зависят от попадания в случайную wall-clock миллисекунду.

Регрессии выбираются через npm run smokes:select; обязательный минимум:

  • zoom-out / kiosk pan-lock;
  • #73 visual continuity / warm remount;
  • #101 View/editor mode transition;
  • flat/isometric projection switch;
  • heavy Glow camera path;
  • opening/furniture preview interruption.

Golden и performance

Final pixels не меняются, поэтому новые golden baselines не принимаются. Плавность доказывается deterministic DOM/screencast smoke с промежуточным кадром. На canonical heavy Glow и large-house flat/isometric profiles:

  • один camera RAF;
  • ноль device/registry/wall/projection/Glow-source rebuild на frame;
  • settle не позже duration + двух реально доступных кадров после main-thread stall;
  • нет blank/black frame и continuity overlay;
  • действующие bundle и runtime budgets не ухудшаются сверх их допуска.

19. Затронутые модули

Планируемые точки изменения:

  • src/viewport-transition.ts — pure camera controller/helpers;
  • src/mode-transition.ts — только общий экспорт easing primitive, если это позволит избежать расхождения двух одинаковых curves;
  • src/houseplan-card.ts — target calculation, lifecycle, handlers и interruption boundary;
  • src/houseplan-editor-runtime.ts — только host-port/cancellation call, если этого потребует mode switch; бизнес-логика zoom остаётся в core;
  • unit и browser smoke из §18;
  • документация и changelog из §21.

20. i18n

Новых подписей, кнопок и сообщений нет. Новые i18n keys не требуются. Текущие RU/EN/DE/FR accessible names и tooltips остаются неизменными.

21. Release-артефакты

  • docs/CHANGELOG.md и docs/CHANGELOG.ru.md: короткий user-visible пункт со ссылкой на #82;
  • docs/USER-GUIDE.md и docs/USER-GUIDE.ru.md: таблица zoom/gesture и reduced-motion;
  • docs/CANVAS.md: camera-only animator, ownership и interruption contract;
  • docs/TESTING.md: deterministic transition и continuity regressions;
  • docs/ARCHITECTURE.md: один абзац о разделении mode и camera controllers;
  • docs/STATUS.md: обновить feature surface, если текущая политика релизной ветки требует;
  • docs screenshot fingerprint обновляется штатным CI/accept flow; новые пользовательские screenshots не нужны, поскольку финальный кадр тот же;
  • перед beta выполняются штатные Full Performance, browser/golden и touch release gates. Golden baseline не переакцептуется.

22. Риски и меры

Риск Мера
Два controller одновременно пишут _view строгая взаимная отмена mode/camera ownership
SVG и HTML расходятся только reactive _zoom/_view, без CSS scale
Wheel ощущается медленным retarget от presented state и target accumulation
Pointer выбирает старую позицию cancel/freeze at presented frame на pointerdown
Glow/geometry rebuild на каждом RAF counters в smoke/performance и стабильные fingerprints
Resume проигрывает старый tween explicit hidden/disconnect/continuity cancellation
Lazy editor попадает в View bundle controller живёт в core, network assertion AC14
Isometric camera drift exact old target и projection regression AC10

23. Откат

Откат возвращает handlers к immediate _zoomAt() / _resetZoom() и удаляет camera controller. Config, layout, localStorage schema и backend не требуют обратной миграции. Если performance gate выявляет недопустимую стоимость, feature можно целиком откатить одним frontend commit без потери данных.

24. Принятые технические предположения

Эти решения не являются отдельным продуктовым контрактом и могут быть изменены по ревью:

  • camera controller отделён от ModeTransitionController, но curve helper общий;
  • _zoom отражает представленный кадр, отдельное поле controller хранит target для wheel accumulation;
  • структурная навигация имеет приоритет над camera tween;
  • current stage aspect authoritative на каждом frame;
  • финальный target вычисляется существующей камерой до запуска tween;
  • #152 либо будущий focus-on-room сможет переиспользовать target API, но не входит в реализацию #82.

Продуктовых вопросов к владельцу нет.