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">
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user