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:
iu
2026-10-11 17:41:28 +03:00
parent 3311de57c5
commit fc4233cd57
9 changed files with 644 additions and 29 deletions
@@ -0,0 +1,378 @@
<script lang="ts">
import { onDestroy } from 'svelte';
interface Props {
src: string;
fallbackSrc?: string;
onFallback?: () => void;
}
let { src, fallbackSrc = '', onFallback }: Props = $props();
let canvas: HTMLCanvasElement | undefined = $state();
let ready = $state(false);
let failed = $state(false);
let gl: WebGLRenderingContext | null = null;
let program: WebGLProgram | null = null;
let texture: WebGLTexture | null = null;
let uniforms: {
uRes: WebGLUniformLocation | null;
uFov: WebGLUniformLocation | null;
uYaw: WebGLUniformLocation | null;
uPitch: WebGLUniformLocation | null;
uTex: WebGLUniformLocation | null;
} = { uRes: null, uFov: null, uYaw: null, uPitch: null, uTex: null };
let initedCanvas: HTMLCanvasElement | null = null;
let ro: ResizeObserver | null = null;
let disposed = false;
let raf = 0;
let dirty = true;
let loadToken = 0;
const DEFAULT_FOV = (75 * Math.PI) / 180;
const MIN_FOV = (30 * Math.PI) / 180;
const MAX_FOV = (100 * Math.PI) / 180;
const PITCH_LIMIT = (89 * Math.PI) / 180;
let yaw = 0;
let pitch = 0;
let fov = DEFAULT_FOV;
let dragging = false;
let lastX = 0;
let lastY = 0;
const VERT = `
attribute vec2 aPos;
varying vec2 vUv;
void main() {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
const FRAG = `
precision highp float;
varying vec2 vUv;
uniform sampler2D uTex;
uniform vec2 uRes;
uniform float uFov;
uniform float uYaw;
uniform float uPitch;
const float PI = 3.141592653589793;
void main() {
vec2 ndc = vUv * 2.0 - 1.0;
float aspect = uRes.x / uRes.y;
float tanHalf = tan(uFov * 0.5);
vec3 dir = normalize(vec3(ndc.x * tanHalf * aspect, ndc.y * tanHalf, -1.0));
float cp = cos(uPitch), sp = sin(uPitch);
vec3 d1 = vec3(dir.x, dir.y * cp - dir.z * sp, dir.y * sp + dir.z * cp);
float cy = cos(uYaw), sy = sin(uYaw);
vec3 d2 = vec3(d1.x * cy + d1.z * sy, d1.y, -d1.x * sy + d1.z * cy);
float lon = atan(d2.x, -d2.z);
float lat = asin(clamp(d2.y, -1.0, 1.0));
vec2 uv = vec2(lon / (2.0 * PI) + 0.5, 0.5 - lat / PI);
gl_FragColor = texture2D(uTex, uv);
}`;
function fail() {
failed = true;
}
$effect(() => {
if (failed) onFallback?.();
});
function compile(type: number, source: string): WebGLShader | null {
if (!gl) return null;
const sh = gl.createShader(type);
if (!sh) return null;
gl.shaderSource(sh, source);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
gl.deleteShader(sh);
return null;
}
return sh;
}
function initGL(cv: HTMLCanvasElement) {
gl = (cv.getContext('webgl', { antialias: true, alpha: false }) ||
cv.getContext('experimental-webgl')) as WebGLRenderingContext | null;
if (!gl) {
fail();
return;
}
const vs = compile(gl.VERTEX_SHADER, VERT);
const fs = compile(gl.FRAGMENT_SHADER, FRAG);
if (!vs || !fs) {
fail();
return;
}
program = gl.createProgram();
if (!program) {
fail();
return;
}
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
fail();
return;
}
gl.useProgram(program);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(program, 'aPos');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
uniforms = {
uRes: gl.getUniformLocation(program, 'uRes'),
uFov: gl.getUniformLocation(program, 'uFov'),
uYaw: gl.getUniformLocation(program, 'uYaw'),
uPitch: gl.getUniformLocation(program, 'uPitch'),
uTex: gl.getUniformLocation(program, 'uTex')
};
ready = true;
resize();
}
function resize() {
if (!canvas || !gl) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.round(canvas.clientWidth * dpr));
const h = Math.max(1, Math.round(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
gl.viewport(0, 0, w, h);
dirty = true;
requestRender();
}
function requestRender() {
if (raf || !ready) return;
raf = requestAnimationFrame(() => {
raf = 0;
if (dirty) render();
});
}
function render() {
if (!gl || !program || !texture) return;
dirty = false;
const w = canvas?.width || 1;
const h = canvas?.height || 1;
gl.uniform2f(uniforms.uRes, w, h);
gl.uniform1f(uniforms.uFov, fov);
gl.uniform1f(uniforms.uYaw, yaw);
gl.uniform1f(uniforms.uPitch, pitch);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(uniforms.uTex, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
if (canvas) {
canvas.dataset.yaw = yaw.toFixed(4);
canvas.dataset.pitch = pitch.toFixed(4);
canvas.dataset.fov = fov.toFixed(4);
}
}
// WebGL requires origin-clean pixels. Signed URLs may be absolute to the API
// host (different origin in dev); fetch same-origin and decode to a bitmap.
function sameOrigin(url: string): string {
try {
const p = new URL(url, location.href);
return p.origin === location.origin ? p.href : p.pathname + p.search;
} catch {
return url;
}
}
async function decode(url: string): Promise<ImageBitmap> {
const resp = await fetch(sameOrigin(url), { credentials: 'omit' });
if (!resp.ok) throw new Error(`http ${resp.status}`);
return await createImageBitmap(await resp.blob());
}
function upload(source: ImageBitmap | HTMLImageElement | HTMLCanvasElement) {
if (!gl || !texture) return;
const maxTex = Math.min(4096, gl.getParameter(gl.MAX_TEXTURE_SIZE) as number);
const iw =
(source as ImageBitmap).width || (source as HTMLImageElement).naturalWidth || 0;
const ih =
(source as ImageBitmap).height || (source as HTMLImageElement).naturalHeight || 0;
let src: TexImageSource = source;
if (iw > 0 && ih > 0 && Math.max(iw, ih) > maxTex) {
const scale = maxTex / Math.max(iw, ih);
const c = document.createElement('canvas');
c.width = Math.max(1, Math.round(iw * scale));
c.height = Math.max(1, Math.round(ih * scale));
const ctx = c.getContext('2d');
if (ctx) {
ctx.drawImage(source as CanvasImageSource, 0, 0, c.width, c.height);
src = c;
}
}
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
try {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src);
} catch {
fail();
return;
}
dirty = true;
requestRender();
}
function loadTexture(s: string, f: string) {
if (!gl || !texture) return;
const token = ++loadToken;
const attempt = async (url: string, isFallback: boolean): Promise<void> => {
if (!url) {
if (!isFallback && f && f !== url) return attempt(f, true);
fail();
return;
}
try {
const bitmap = await decode(url);
if (disposed || token !== loadToken || !gl) {
bitmap.close?.();
return;
}
upload(bitmap);
bitmap.close?.();
} catch {
if (disposed || token !== loadToken) return;
if (!isFallback && f && f !== url) return attempt(f, true);
fail();
}
};
void attempt(s, false);
}
function teardown() {
if (raf) {
cancelAnimationFrame(raf);
raf = 0;
}
if (gl) {
if (texture) gl.deleteTexture(texture);
if (program) gl.deleteProgram(program);
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
gl = null;
program = null;
texture = null;
ready = false;
}
function onPointerDown(e: PointerEvent) {
if (!ready) return;
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
try {
canvas?.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
}
function onPointerMove(e: PointerEvent) {
if (!dragging || !canvas) return;
e.preventDefault();
const k = fov / Math.max(1, canvas.clientHeight);
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
yaw -= dx * k;
pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, pitch + dy * k));
dirty = true;
requestRender();
}
function onPointerUp(e: PointerEvent) {
dragging = false;
try {
canvas?.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
}
function onWheel(e: WheelEvent) {
e.preventDefault();
e.stopPropagation();
const factor = e.deltaY < 0 ? 0.9 : 1.1;
fov = Math.max(MIN_FOV, Math.min(MAX_FOV, fov * factor));
dirty = true;
requestRender();
}
export function reset() {
yaw = 0;
pitch = 0;
fov = DEFAULT_FOV;
dirty = true;
requestRender();
}
// Initialize once per canvas element; effect re-runs (from bind:this) are no-ops.
$effect(() => {
const cv = canvas;
if (!cv || initedCanvas === cv) return;
initedCanvas = cv;
disposed = false;
initGL(cv);
ro?.disconnect();
ro = new ResizeObserver(() => resize());
ro.observe(cv);
cv.addEventListener('wheel', onWheel, { passive: false });
});
$effect(() => {
const s = src;
const f = fallbackSrc;
if (!ready) return;
loadTexture(s, f);
return () => {
loadToken++;
};
});
onDestroy(() => {
disposed = true;
loadToken++;
initedCanvas?.removeEventListener('wheel', onWheel);
ro?.disconnect();
teardown();
});
</script>
<canvas
bind:this={canvas}
class="block h-full w-full cursor-grab bg-black select-none"
style="touch-action: none;"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
></canvas>
@@ -1,6 +1,7 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { density } from '$lib/stores/density.svelte';
import { isPanoTag } from '$lib/pano';
import type { Photo } from '$lib/types';
interface Props {
@@ -74,6 +75,14 @@
обработка…
</span>
{/if}
{#if isPanoTag(photo)}
<span
class="bg-bg/70 text-text absolute top-1 left-1 rounded px-1.5 py-0.5 text-[10px] font-semibold"
aria-label="360-панорама"
>
360
</span>
{/if}
<span
class="bg-bg/70 text-text absolute right-1 bottom-1 flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs"
>
+62 -29
View File
@@ -3,6 +3,8 @@
import { api } from '$lib/api';
import { page } from '$app/stores';
import Icon from './Icon.svelte';
import PanoViewer from './PanoViewer.svelte';
import { isPanoTag } from '$lib/pano';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
@@ -35,6 +37,9 @@
let scale = $state(1);
let hiResLoaded = $state(false);
let panoFailed = $state(false);
let panoRef: { reset: () => void } | undefined = $state();
let showTop = $state(false);
let showBottom = $state(false);
let showLeft = $state(false);
@@ -52,6 +57,9 @@
);
const fullSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
const displaySrc = $derived(zoomed && hiResLoaded && fullSrc ? fullSrc : previewSrc);
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
const pano = $derived(panoTag && !panoFailed);
const panoSrc = $derived(fullSrc || previewSrc);
$effect(() => {
const id = current?.id;
@@ -59,6 +67,7 @@
zoomed = false;
scale = 1;
hiResLoaded = false;
panoFailed = false;
if (!id) return;
api<{ photo: Photo }>(`/photos/${id}`)
.then((data) => (fresh = data.photo))
@@ -265,7 +274,8 @@
}
if (ev.code === 'Space') {
ev.preventDefault();
toggleActual();
if (pano) panoRef?.reset();
else toggleActual();
return;
}
if (ev.key === 'f' || ev.key === 'F' || ev.key === 'а' || ev.key === 'А') {
@@ -329,13 +339,20 @@
<svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
<div bind:this={viewerEl} class="bg-canvas relative flex h-full flex-col overflow-hidden">
<div
bind:this={viewerEl}
class="bg-canvas relative flex h-full flex-col overflow-hidden"
data-pano={panoTag ? '1' : '0'}
>
<div bind:this={topEl} class={topClass}>
<div class="pointer-events-auto flex items-center gap-1">
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
<Icon name="arrow_back" class="size-4" />
</button>
<span class="text-text-muted text-xs">{index + 1} / {photos.length}</span>
{#if pano}
<span class="text-primary text-xs font-semibold" title="360-панорама">360</span>
{/if}
</div>
<div class="pointer-events-auto flex items-center gap-1">
<button
@@ -365,34 +382,50 @@
<Icon name="chevron_left" class="size-8" />
</button>
<div
bind:this={scroller}
class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
? 'cursor-grab overflow-auto'
: 'overflow-hidden'}"
role="group"
aria-label="Просмотр фото"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
oncontextmenu={onContextMenu}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
{#if current}
<img
bind:this={imgEl}
src={displaySrc}
alt={current.original_filename}
draggable="false"
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
class="m-auto block {zoomed
? 'max-w-none shrink-0 select-none'
: 'max-h-full max-w-full object-contain'}"
{#if pano}
<div
class="bg-canvas flex min-h-0 flex-1 overflow-hidden"
role="group"
aria-label="360-панорама"
oncontextmenu={onContextMenu}
>
<PanoViewer
bind:this={panoRef}
src={panoSrc}
fallbackSrc={previewSrc}
onFallback={() => (panoFailed = true)}
/>
{/if}
</div>
</div>
{:else}
<div
bind:this={scroller}
class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
? 'cursor-grab overflow-auto'
: 'overflow-hidden'}"
role="group"
aria-label="Просмотр фото"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
oncontextmenu={onContextMenu}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
{#if current}
<img
bind:this={imgEl}
src={displaySrc}
alt={current.original_filename}
draggable="false"
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
class="m-auto block {zoomed
? 'max-w-none shrink-0 select-none'
: 'max-h-full max-w-full object-contain'}"
/>
{/if}
</div>
{/if}
<button
type="button"
+8
View File
@@ -0,0 +1,8 @@
import type { Photo } from './types';
export const PANO_TAG = 'pano360';
export function isPanoTag(photo: Photo | null | undefined): boolean {
if (!photo?.tags?.length) return false;
return photo.tags.some((t) => t.name.trim().toLowerCase() === PANO_TAG);
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
+90
View File
@@ -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** панорамный режим не активируется, показывается плоское изображение
+22
View File
@@ -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.