feat(ui): 360 panorama viewer by pano360 tag
- Detect 'pano360' tag and open the photo in a spherical viewer - Mini-WebGL equirect viewer (no deps): drag to look, wheel FOV, Space reset - Load original as origin-clean bitmap; fall back to flat image without WebGL - 360 indicator in viewer and badge on grid tiles
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-11
|
||||
@@ -0,0 +1,90 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
См. proposal.md — Why. См. сценарии в specs/ui-workspace/spec.md. Опорные факты по коду:
|
||||
|
||||
- `Viewer.svelte` — единая область просмотра: `scroller` c `<img>` (плоский зум/панорамирование,
|
||||
`Space`, колесо), топбар, лента миниатюр, контекстное меню, `f`/fullscreen. Используется и в
|
||||
маршруте фото, и на публичной share-странице.
|
||||
- Теги персональные (`tags.user_id`); `photo.tags` приходят и в списке, и в `/photos/:id`.
|
||||
На share-страницах `GetPublic` читает фото с пустым пользователем → тегов нет.
|
||||
- Варианты: `thumb` 200 и `preview` 1200 (webp); плюс `original_url` (подписанный, 1 час).
|
||||
- В проекте нет runtime-зависимостей; статика вшивается в Go-бинарник.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 360-обзор для фото с личным тегом `pano360`, без сторонних библиотек.
|
||||
- Сохранить плоский просмотр и все прочие функции Viewer без регрессий.
|
||||
- Деградация до плоского изображения, когда 360 невозможен.
|
||||
|
||||
**Non-Goals:**
|
||||
- Бэкенд, новый pano-вариант, автоопределение GPano/XMP.
|
||||
- 360 на share-страницах и у других участников (теги персональные).
|
||||
- Редактор панорам, горячие точки, стерео/VR.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Детект: личный тег `pano360`
|
||||
`isPanoTag(photo)` — `photo.tags?.some(t => t.name.trim().toLowerCase() === 'pano360')`.
|
||||
Источник — уже отдаваемые теги; бэкенд не трогаем. Следствие: на share и у других участников
|
||||
режим не включается (осознанное ограничение, покрыто сценарием спеки).
|
||||
|
||||
### 2. Рендер: мини-WebGL equirect без зависимостей
|
||||
Отдельный `PanoViewer.svelte`, владеющий `<canvas>`:
|
||||
|
||||
```
|
||||
экран (uv) --inv(fov,yaw,pitch)--> луч dir --atan2/asin--> lon/lat --> equirect uv --> sample
|
||||
```
|
||||
|
||||
- Вершинный шейдер: полноэкранный quad.
|
||||
- Фрагментный: направление луча из `uv` + `uFov/uYaw/uPitch/uRes`; выборка текстуры equirect.
|
||||
- WebGL1: NPOT-текстура → `CLAMP_TO_EDGE`/`LINEAR`, без мипмапов.
|
||||
- `ResizeObserver` + `devicePixelRatio` для ресайза канвы.
|
||||
|
||||
Альтернативы: three.js (быстрее писать, но +~150 КБ gz и первая runtime-зависимость) и готовая
|
||||
панорамная библиотека (чужая тема/UI). Отклонены в пользу 0 зависимостей.
|
||||
|
||||
### 3. Текстура: оригинал с даунскейлом, фолбэк preview
|
||||
`original_url` загружается через `fetch` того же origin (подписанный URL может быть абсолютным на
|
||||
хост API) → `blob` → `createImageBitmap`, чтобы пиксели были origin-clean (иначе `texImage2D`
|
||||
бросает «cross-origin data»). Далее даунскейл до `min(4096, gl.MAX_TEXTURE_SIZE)` и `texImage2D`.
|
||||
Если оригинал недоступен — `preview_url`. Новый pano-вариант не заводим (иначе бэкенд).
|
||||
|
||||
### 3a. Жизненный цикл WebGL
|
||||
GL инициализируется один раз на элемент канвы; повторные запуски эффекта (в т.ч. из-за
|
||||
`bind:this`) — no-op. Освобождение ресурсов (текстура/программа, `WEBGL_lose_context`) — только
|
||||
при уничтожении компонента. Раньше `teardown()` вызывался на перезапуске эффекта, терял контекст,
|
||||
и повторная инициализация компилировала шейдеры на «потерянном» контексте → ложный фолбэк.
|
||||
|
||||
### 4. Фолбэк на плоский `<img>`
|
||||
`PanoViewer` вызывает `onFallback()` при отсутствии WebGL-контекста или сбое загрузки/декодирования;
|
||||
`Viewer` тогда рендерит обычный `<img>` и плоские жесты.
|
||||
|
||||
### 5. Разделение ответственности и интеракции
|
||||
- `Viewer` владеет топбаром, лентой, клавишами и контекстным меню.
|
||||
- `PanoViewer` владеет канвой, указателем и колесом: drag/свайп — `yaw/pitch`, колесо — `fov`
|
||||
(клэмп, напр. 30°..100°).
|
||||
- `Space` в `Viewer` в 360-режиме вызывает `panoRef.reset()` (вместо плоского `toggleActual`).
|
||||
- В 360-режиме плоский зум/панорамирование и `wheel`/`pointer`-обработчики scroller не активны;
|
||||
свайп не листает фото (смена — стрелками и лентой); `f`/fullscreen и стрелки — как прежде.
|
||||
- Компонент создаётся с `key={current.id}`, ресурсы освобождаются при смене фото/размонтировании.
|
||||
|
||||
### 6. Индикация
|
||||
Признак «360» в топбаре при активном режиме; бейдж «360» на плитке сетки для фото с тегом
|
||||
(теги есть в списочном ответе).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Оригинал больше лимита текстуры/много VRAM] → даунскейл до `min(4096, MAX_TEXTURE_SIZE)`.
|
||||
- [WebGL недоступен] → `onFallback()` и плоский `<img>`.
|
||||
- [Конфликт жестов с плоским режимом] → режим-специфичные обработчики; свайп в 360 только осмотр.
|
||||
- [Утечки WebGL-контекста] → `deleteTexture/deleteProgram/loseContext` при размонтировании/смене фото.
|
||||
- [Личный тег: 360 не виден другим/на share] → покрыто сценарием спеки как ожидаемое поведение.
|
||||
- [Производительность на слабых GPU] → простая сцена (один quad), опционально ограничение DPR.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Только фронтенд; данных и API не трогаем. Откат — удаление нового режима. Проверка:
|
||||
`cd frontend && npm run check` и `npm run build`, плюс панo-сценарии в CDP‑QA.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Пользователь загружает сферические 360-панорамы, но просмотрщик показывает их как обычное
|
||||
плоское фото — искажённую equirect-развёртку. Нужен режим обзора панорамы по тегу `pano360`.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Детект по тегу.** Фото считается 360-панорамой, если среди его тегов есть `pano360`
|
||||
(без учёта регистра и пробелов по краям). Теги персональные, поэтому на публичных
|
||||
share-страницах и у других участников 360 не включается — фолбэк на плоский просмотр.
|
||||
- **Мини-WebGL вьюер.** В просмотре появляется equirect-обзор на `<canvas>`: перетаскивание —
|
||||
осмотр, колесо — угол обзора (FOV), `Space` — сброс вида. Стрелки и лента по-прежнему
|
||||
переключают фото, `f` — полный экран. Без сторонних библиотек.
|
||||
- **Источник изображения.** Грузится оригинал с даунскейлом до `min(4096, MAX_TEXTURE_SIZE)`;
|
||||
при неудаче — `preview`. Новый вариант на бэкенде не заводится.
|
||||
- **Фолбэк.** Нет WebGL или сбой загрузки/декодирования — показывается обычное плоское фото.
|
||||
- **Индикация.** В топбаре просмотра виден признак «360»; на плитке сетки — бейдж «360».
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `ui-workspace`: добавляется требование «Просмотр 360-панорамы» (детект по тегу, сферический
|
||||
обзор, управление, фолбэк, ограничение на share-страницах).
|
||||
|
||||
## Impact
|
||||
|
||||
- **Frontend:** новый `lib/pano.ts` (детект тега) и `lib/components/PanoViewer.svelte`
|
||||
(WebGL equirect); правки `lib/components/Viewer.svelte` (переключение режима, клавиши,
|
||||
фолбэк, индикатор) и `lib/components/PhotoGrid.svelte` (бейдж). Возможна новая иконка в
|
||||
`lib/icons/registry.ts`.
|
||||
- **Backend:** не затрагивается.
|
||||
- **Совместимость:** поведение плоского просмотра сохраняется; 360 — дополнительный режим.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Просмотр 360-панорамы
|
||||
Фото с тегом `pano360` SHALL открываться в режиме сферического обзора (equirect) вместо плоского
|
||||
изображения. Перетаскивание SHALL менять направление взгляда, колесо — угол обзора, `Space` —
|
||||
сбрасывать вид. Остальные фото SHALL просматриваться как прежде. При недоступности WebGL или
|
||||
сбое загрузки/декодирования SHALL показываться плоское изображение. На публичных share-страницах
|
||||
панорамный режим SHALL не активироваться.
|
||||
|
||||
#### Scenario: Обзор панорамы
|
||||
- **WHEN** пользователь открывает фото с тегом `pano360`
|
||||
- **THEN** показывается сферический обзор, и перетаскивание меняет направление взгляда
|
||||
|
||||
#### Scenario: Обычное фото
|
||||
- **WHEN** пользователь открывает фото без тега `pano360`
|
||||
- **THEN** показывается обычное плоское изображение
|
||||
|
||||
#### Scenario: Управление обзором
|
||||
- **WHEN** в режиме панорамы пользователь вращает колесо или нажимает `Space`
|
||||
- **THEN** колесо меняет угол обзора, а `Space` сбрасывает направление взгляда и угол обзора
|
||||
|
||||
#### Scenario: Навигация между фото
|
||||
- **WHEN** в режиме панорамы пользователь нажимает стрелки влево/вправо или выбирает миниатюру
|
||||
- **THEN** открывается соседнее фото, панорамный режим включается или выключается по его тегу
|
||||
|
||||
#### Scenario: Нет WebGL
|
||||
- **WHEN** WebGL недоступен или изображение не загружается
|
||||
- **THEN** показывается плоское изображение
|
||||
|
||||
#### Scenario: Публичная share-страница
|
||||
- **WHEN** фото открыто по публичной share-ссылке
|
||||
- **THEN** панорамный режим не активируется, показывается плоское изображение
|
||||
@@ -0,0 +1,22 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Детект и 360-вьюер
|
||||
|
||||
- [x] 1.1 Добавить `lib/pano.ts` с `isPanoTag(photo)` (тег `pano360`, без учёта регистра и пробелов). Проверка: `npm run check` проходит; функция истинна для тега `pano360`/`PANO360` и ложна без него.
|
||||
- [x] 1.2 Реализовать `lib/components/PanoViewer.svelte`: WebGL equirect (полноэкранный quad, луч из uv+fov+yaw/pitch, выборка equirect), текстура из `original_url` с даунскейлом до `min(4096, MAX_TEXTURE_SIZE)` и фолбэком `preview_url`, `ResizeObserver`+DPR, `reset()`, `onFallback()`. Проверка: на фото с тегом рисуется сцена; драг/колесо/сброс меняют вид; при отсутствии WebGL вызывается фолбэк.
|
||||
- [x] 1.3 Освобождать ресурсы (текстура/программа/буфер, контекст) при размонтировании и смене `src`. Проверка: многократное переключение фото не копит контексты (по `gl.getError()`/поведению без падений).
|
||||
|
||||
## 2. Интеграция в Viewer
|
||||
|
||||
- [x] 2.1 В `Viewer.svelte` вычислять `pano = isPanoTag(current) || isPanoTag(fresh)` и рендерить `PanoViewer` (с `key={current.id}`) вместо плоского `<img>`; при фолбэке — `<img>`. Проверка: tagged фото → канвас, нетэгнутое → `<img>`.
|
||||
- [x] 2.2 Развести интеракции: в 360 отключить плоский зум/панорамирование и скролл-жесты; `Space` → `panoRef.reset()`; drag/свайп — осмотр; колесо — FOV. Сохранить стрелки, ленту, `f`/fullscreen, контекстное меню. Проверка: смена фото стрелками/лентой в 360 работает; плоский режим не изменился.
|
||||
- [x] 2.3 Добавить индикатор «360» в топбаре при активном режиме. Проверка: на панораме индикатор виден, на обычном фото — нет.
|
||||
|
||||
## 3. Сетка
|
||||
|
||||
- [x] 3.1 Добавить бейдж «360» на плитку `PhotoGrid` для фото с тегом `pano360`. Проверка: на плитке панорамного фото виден бейдж, на остальных — нет.
|
||||
|
||||
## 4. Проверка
|
||||
|
||||
- [x] 4.1 `cd frontend && npm run check` без ошибок и предупреждений; `npm run build` проходит.
|
||||
- [x] 4.2 Расширить CDP‑QA панo-сценариями: тег `pano360` через API → канвас и индикатор «360»; без тега → `<img>`; drag меняет вид (`data-yaw`); смена фото сохраняет/снимает режим по тегу. Проверка: все проверки PASS.
|
||||
Reference in New Issue
Block a user