Files
houseplan-card/docs/specs/396-camera-transition-fixes.md
2026-08-31 01:36:53 +03:00

17 KiB
Executable File
Raw Permalink Blame History

ТЗ #396 — Плавная камера: сохранение прерванного зума, честный якорь, заморозка feather

  • Issue: https://github.com/Matysh/houseplan-card/issues/396
  • Приоритет: P1, bug (регресс против v1.69.0); полный трек — класс A, три находки на одной поверхности (камера), плюс правка формулировок принятой спеки #82 (docs/specs/082-smooth-zoom.md §10, §13)
  • Ревизия: 3 (2026-08-31) — по SPEC-REVIEW-396-r2 (Medium-1: имена функций в таблице)

Сценарий

Хозяин дома смотрит план на планшете. Крутит колесо (или жмёт «+»), чтобы разглядеть кухню, и сразу тыкает в лампу — не дожидаясь, пока картинка доедет. Через минуту уходит на второй этаж и возвращается: масштаб откатился к тому, что был до зума. Второй случай — трекпад: быстрая серия нотчей уводит из-под пальца ровно ту точку, которую он держал.

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

До: (1) зум, прерванный любым касанием плана, не запоминается — при возврате на пространство карта показывает старый масштаб; (2) при быстром зуме трекпадом точка под курсором уезжает на 10–16 px; (3) во время плавного зума свечение пересобирает размытие на каждом кадре. После: показанный масштаб и есть сохранённый; точка под курсором стоит на месте при любой частоте событий; свечение во время движения камеры ведёт себя как при pinch/pan.

Проблема и контракты по пунктам

(1) B1 — прерванный зум не сохраняется

_saveZoom() на пути перехода вызывается ровно из _settleCameraTransition (src/houseplan-card.ts:1237). До перехода на анимацию запись делали сами команды: _onWheel, _stepZoom, _resetZoom (v1.69.0:6009,6016,6030). CameraTransitionController.cancel(false) (src/viewport-transition.ts:189) settled не вызывает, поэтому показанный масштаб остаётся незаписанным.

Полный перечень мест, где переход отменяется, и их классификация (сверено grep'ом по src/houseplan-card.ts на HEAD, каждое прочитано):

Место Что это Класс
:6387 _stagePointerDown буквальный сценарий issue: касание плана поверх собственного зума пользовательская — сохранять
:6283 _zoomAt немедленный pinch правки не требует: оба вызывающих контекста сразу зовут _saveZoom()
:1267 _startCameraTransition цель совпала с текущим состоянием (no-op) сохранять нечего — зум уже равен сохранённому
:1159 _onMotionChange, :2363 _pageVisibility cancel(true), цель коммитится уже сохраняется через settled
:1401 _cancelModeTransition смена режима структурная
:1551 _commitSpace смена пространства структурная
:4188, :4216 _adoptStructuralResponses adoption конфига и layout структурная
:6089 _applyView программная установка вида структурная
:6206 resize-обработчик новая геометрия сцены структурная
:6360 _restoreZoom восстановление сохранённого зума структурная (пишет то, что и читает)
:2745 _cameraTransition.dispose() disconnectedCallback, минуя обёртку структурная

Правка нужна ровно в одном месте пользовательского класса (:6387) плюс сам механизм различения; остальные строки перечислены, чтобы граница была явной и следующая правка не расширила её молча.

Воспроизведение (исполнением, фейковый клок): переход 1.0 → 1.15 за 220 мс, обрыв на 120 мс → settled вызван 0 раз, presented.zoom = 1.1413.

Корень — в спеке #82, а не только в коде. §13 говорит «structural cancellation не сохраняет stale camera target», §11 — «pointerdown … фиксирует представленный кадр». Спека не различает два случая отмены, а они разные:

Вид отмены Что на экране Что сохранять
Пользовательская: pointerdown по сцене, начало pan/pinch/draw/drag/selection поверх собственного зума представленный кадр фиксируется и остаётся видимым сохранять представленное — оно и есть текущее намерение
Структурная: смена пространства/режима/проекции, resize, adoption конфига или layout, continuity recovery, viewport restore, disconnect вид заменяется целиком другим контрактом не сохранять ничего: цель устарела вместе с видом

Контракт: сохранённый зум пространства равен показанному после любой пользовательской отмены. Структурная отмена по-прежнему не пишет ничего.

Требуется правка docs/specs/082-smooth-zoom.md §13: разделить два вида отмены явно, вместо одной строки про «stale target».

(2) B2 — якорь считается от отстающего кадра

_cameraTargetAt (src/houseplan-card.ts:6272) передаёт в cameraTargetAtAnchor состояние this._cameraState() — представленный кадр. Пока tween не доиграл, он отстаёт от цели, и мировая точка под курсором вычисляется в другом viewport, чем тот, к которому строится новая цель.

Замер (6 нотчей шагом 1.15, сравнение «якорь от presented» против «якорь от целевого состояния»):

интервал смещение точки под курсором
8 мс 14,5 px
16 мс 16,1 px
33 мс 10,3 px
220 мс 0,0 px

Спека #82 §10 при этом требует обе несовместимые вещи сразу: п.3 — «world-point … в представленном viewport», абзац ниже — «anchor остаётся на месте с ошибкой не более 0.5 CSS px». Реализация выполнила п.3 и нарушила AC.

Контракт: масштаб и мировая точка берутся из одного и того же состояния — цели текущего перехода (при её отсутствии — из представленного, оно же текущее). Это ровно принцип §10 п.2, распространённый на якорь. Ошибка якоря без clamp — 0 px (точное равенство мировой точки до плавающей погрешности 1e-9), а не «не более 0.5».

Требуется правка docs/specs/082-smooth-zoom.md §10: п.3 переформулировать на «в целевом viewport running tween», порог заменить на точное равенство.

Немедленный _zoomAt (pinch) продолжает работать от представленного состояния: там tween заведомо отменён, представленное и есть текущее.

(3) M2 — feather не заморожен во время перехода

src/houseplan-card.ts:10842-10847: resolveGlowFeather(…, !this._pinchStart && !this._panStart). Признак «камера движется» подменён признаками жеста, а новый tween ни одного из них не выставляет: perUnit меняется каждый кадр, регион размытия пересобирается на каждом.

Контракт: feather заморожен, пока камера движется любым способом — pinch, pan или анимированный переход.

Скоуп / не-скоуп

В скоупе: src/viewport-transition.ts, камера-путь src/houseplan-card.ts (_startCameraTransition, _cameraTargetAt, _onWheel, _stepZoom, _cancelCameraTransition, _stagePointerDown, glow-гейт), правка §10 и §13 в docs/specs/082-smooth-zoom.md, тесты и мутанты.

Не в скоупе: поведение pinch/pan persistence (свой контракт, §13 #82), длительности и кривые анимации, kiosk double-tap, изменение формата LS_ZOOM, editor zoom (не персистится по решению #82).

UX

Видимых изменений в оформлении нет. Меняется только то, что показанный масштаб сохраняется, а точка под курсором не уезжает.

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

Формат LS_ZOOM и warm viewport memo не меняются. Миграции нет.

i18n

Новых строк нет.

Критерии приёмки

  • AC1. После пользовательской отмены перехода (_stagePointerDown — касание плана, с которого начинается pan/pinch/draw/drag/selection) сохранённый зум пространства равен показанному на момент отмены. Доказательство: юнит на контроллер + карту, сравнение сохранённого значения с presented.
  • AC2. Структурная отмена (смена пространства/режима, _applyView, resize, adoption, _restoreZoom, disconnect — строки таблицы выше) не записывает зум вовсе. Доказательство: тот же юнит, обратный случай — счётчик записей не растёт. Отдельно: _zoomAt (pinch) остаётся без изменений и продолжает сохранять через вызывающий контекст.
  • AC3. Серия из шести wheel-событий с интервалом 8 мс оставляет мировую точку под курсором на месте с точностью 1e-9 единиц плана (без clamp). Доказательство: юнит с фейковым клоком; при clamp смещение допускается только по ограниченной оси.
  • AC4. Накопление масштаба в серии не меняется: шесть нотчей дают тот же итоговый зум, что и сегодня (1.15⁶ с точностью 1e-12) — фикс якоря не трогает арифметику масштаба.
  • AC5. Во время анимированного перехода resolveGlowFeather вызывается с замороженным признаком: число пересборок региона размытия за один зум не больше, чем на pinch-пути.
  • AC6. docs/specs/082-smooth-zoom.md §10 и §13 приведены в соответствие с реализуемым поведением; ни один другой параграф не меняется.
  • AC7. Регресс не возвращается: reduced-motion по-прежнему даёт ноль запросов rAF и точный таргет, dispose() снимает rAF, анимационное состояние не попадает в конфиг.

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

Unit (test/viewport-transition.test.mjs, test/camera-persistence.test.mjs):

  1. cancel(false) после частичного перехода → хук сохранения вызван с представленным состоянием (AC1); структурная отмена → не вызван (AC2).
  2. Серия из шести нотчей с интервалом 8/16/33 мс → мировая точка под курсором неизменна до 1e-9 (AC3), итоговый зум равен 1.15⁶ (AC4).
  3. Clamp у границы: смещение только по ограниченной оси, вторая ось точна.
  4. reducedMotion → 0 rAF, один кадр, точный таргет (AC7).

Browser smoke (demo/smoke_smooth_zoom.mjs, дополнение):

  1. Колесо → pointerdown по плану до окончания перехода → перезагрузка карты → восстановленный зум равен показанному до клика (AC1 на реальном DOM).
  2. Смена пространства во время перехода → в localStorage для исходного пространства осталось прежнее значение (AC2).

Perf-контракт (demo/performance/*):

  1. Число вызовов пересборки feather за один анимированный зум не превышает значение pinch-пути (AC5).

Мутанты (scripts/mutation-gate.mjs):

  • camera-anchor-from-presented: вернуть якорь на представленный кадр → юнит AC3 красный.
  • camera-cancel-loses-zoom: убрать сохранение при пользовательской отмене → юнит AC1 красный.
  • glow-feather-thaws-during-camera: снять признак движения камеры из гейта feather → перф-контракт AC5 красный.

Риски

  • Двойная запись зума. Сохранение при отмене плюс сохранение в settle может дать две записи на один жест. Смягчение: запись идемпотентна по значению (тот же зум не переписывает storage — §11 #82 «no-op не переписывает localStorage»), контракт проверяется счётчиком записей в юните.
  • Тонкая грань «пользовательская против структурной». Ошибка классификации перевернёт поведение. Смягчение: классификация задаётся одним аргументом на вызове отмены (а не выводится из состояния), оба случая покрыты юнитами, список мест перечислен в спеке #82 §13.
  • Изменение якоря затрагивает pinch. Смягчение: _zoomAt явно оставлен на представленном состоянии, отдельный юнит фиксирует, что pinch-путь не изменился численно.

Откат

Точечный: три изменения независимы. Откат якоря — вернуть _cameraState() в _cameraTargetAt; откат персиста — убрать сохранение из ветки отмены; откат feather — вернуть гейт на признаки жеста. Формат хранения не меняется, поэтому откат не оставляет следов в данных пользователя.

Release-артефакты

  • docs/CHANGELOG.md / docs/CHANGELOG.ru.md: один пункт про то, что прерванный зум запоминается и точка под курсором не уезжает (User-Visible).
  • docs/specs/082-smooth-zoom.md: §10 и §13 в новой редакции.
  • Скриншоты не меняются: визуал в статике идентичен.