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
+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"