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,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">
|
<script lang="ts">
|
||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
import { density } from '$lib/stores/density.svelte';
|
import { density } from '$lib/stores/density.svelte';
|
||||||
|
import { isPanoTag } from '$lib/pano';
|
||||||
import type { Photo } from '$lib/types';
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -74,6 +75,14 @@
|
|||||||
обработка…
|
обработка…
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/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
|
<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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import Icon from './Icon.svelte';
|
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 { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
||||||
import { transfer } from '$lib/stores/transfer.svelte';
|
import { transfer } from '$lib/stores/transfer.svelte';
|
||||||
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
||||||
@@ -35,6 +37,9 @@
|
|||||||
let scale = $state(1);
|
let scale = $state(1);
|
||||||
let hiResLoaded = $state(false);
|
let hiResLoaded = $state(false);
|
||||||
|
|
||||||
|
let panoFailed = $state(false);
|
||||||
|
let panoRef: { reset: () => void } | undefined = $state();
|
||||||
|
|
||||||
let showTop = $state(false);
|
let showTop = $state(false);
|
||||||
let showBottom = $state(false);
|
let showBottom = $state(false);
|
||||||
let showLeft = $state(false);
|
let showLeft = $state(false);
|
||||||
@@ -52,6 +57,9 @@
|
|||||||
);
|
);
|
||||||
const fullSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
|
const fullSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
|
||||||
const displaySrc = $derived(zoomed && hiResLoaded && fullSrc ? fullSrc : previewSrc);
|
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(() => {
|
$effect(() => {
|
||||||
const id = current?.id;
|
const id = current?.id;
|
||||||
@@ -59,6 +67,7 @@
|
|||||||
zoomed = false;
|
zoomed = false;
|
||||||
scale = 1;
|
scale = 1;
|
||||||
hiResLoaded = false;
|
hiResLoaded = false;
|
||||||
|
panoFailed = false;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
api<{ photo: Photo }>(`/photos/${id}`)
|
api<{ photo: Photo }>(`/photos/${id}`)
|
||||||
.then((data) => (fresh = data.photo))
|
.then((data) => (fresh = data.photo))
|
||||||
@@ -265,7 +274,8 @@
|
|||||||
}
|
}
|
||||||
if (ev.code === 'Space') {
|
if (ev.code === 'Space') {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
toggleActual();
|
if (pano) panoRef?.reset();
|
||||||
|
else toggleActual();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.key === 'f' || ev.key === 'F' || ev.key === 'а' || ev.key === 'А') {
|
if (ev.key === 'f' || ev.key === 'F' || ev.key === 'а' || ev.key === 'А') {
|
||||||
@@ -329,13 +339,20 @@
|
|||||||
|
|
||||||
<svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
|
<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 bind:this={topEl} class={topClass}>
|
||||||
<div class="pointer-events-auto flex items-center gap-1">
|
<div class="pointer-events-auto flex items-center gap-1">
|
||||||
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
|
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
|
||||||
<Icon name="arrow_back" class="size-4" />
|
<Icon name="arrow_back" class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
<span class="text-text-muted text-xs">{index + 1} / {photos.length}</span>
|
<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>
|
||||||
<div class="pointer-events-auto flex items-center gap-1">
|
<div class="pointer-events-auto flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
@@ -365,34 +382,50 @@
|
|||||||
<Icon name="chevron_left" class="size-8" />
|
<Icon name="chevron_left" class="size-8" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
{#if pano}
|
||||||
bind:this={scroller}
|
<div
|
||||||
class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
|
class="bg-canvas flex min-h-0 flex-1 overflow-hidden"
|
||||||
? 'cursor-grab overflow-auto'
|
role="group"
|
||||||
: 'overflow-hidden'}"
|
aria-label="360-панорама"
|
||||||
role="group"
|
oncontextmenu={onContextMenu}
|
||||||
aria-label="Просмотр фото"
|
>
|
||||||
onpointerdown={onPointerDown}
|
<PanoViewer
|
||||||
onpointermove={onPointerMove}
|
bind:this={panoRef}
|
||||||
onpointerup={onPointerUp}
|
src={panoSrc}
|
||||||
onpointercancel={onPointerUp}
|
fallbackSrc={previewSrc}
|
||||||
oncontextmenu={onContextMenu}
|
onFallback={() => (panoFailed = true)}
|
||||||
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>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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