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"