mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
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
419 lines
26 KiB
Markdown
419 lines
26 KiB
Markdown
# 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.
|
||
|
||
```ts
|
||
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.
|
||
|
||
Продуктовых вопросов к владельцу нет.
|