2024-02-09 17:17:51 +01:00
|
|
|
<script lang="ts">
|
2025-11-28 18:50:16 +01:00
|
|
|
import { shortcuts } from '$lib/actions/shortcut';
|
2025-11-21 16:11:47 +01:00
|
|
|
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
2024-09-12 21:30:21 +02:00
|
|
|
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
2025-11-28 18:50:16 +01:00
|
|
|
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
2024-04-21 12:14:54 -07:00
|
|
|
import {
|
2024-09-12 21:30:21 +02:00
|
|
|
EquirectangularAdapter,
|
2024-04-21 12:14:54 -07:00
|
|
|
Viewer,
|
2024-09-02 12:39:55 -07:00
|
|
|
events,
|
2024-04-21 12:14:54 -07:00
|
|
|
type AdapterConstructor,
|
2024-09-12 21:30:21 +02:00
|
|
|
type PluginConstructor,
|
2024-04-21 12:14:54 -07:00
|
|
|
} from '@photo-sphere-viewer/core';
|
2024-02-09 17:17:51 +01:00
|
|
|
import '@photo-sphere-viewer/core/index.css';
|
2025-11-28 10:59:39 +01:00
|
|
|
import { EquirectangularTilesAdapter } from '@photo-sphere-viewer/equirectangular-tiles-adapter';
|
2025-11-21 16:11:47 +01:00
|
|
|
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
|
|
|
|
|
import '@photo-sphere-viewer/markers-plugin/index.css';
|
2025-06-16 11:03:23 -04:00
|
|
|
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
|
|
|
|
|
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
|
2025-01-28 23:09:40 +08:00
|
|
|
import '@photo-sphere-viewer/settings-plugin/index.css';
|
2024-02-09 17:17:51 +01:00
|
|
|
import { onDestroy, onMount } from 'svelte';
|
|
|
|
|
|
2025-11-21 16:11:47 +01:00
|
|
|
// Adapted as well as possible from classlist 'border-solid border-white border-3 rounded-lg'
|
|
|
|
|
const FACE_BOX_SVG_STYLE = {
|
|
|
|
|
fill: 'rgba(0, 0, 0, 0)',
|
|
|
|
|
stroke: '#ffffff',
|
|
|
|
|
strokeWidth: '3px',
|
|
|
|
|
strokeLinejoin: 'round',
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-28 10:59:39 +01:00
|
|
|
const SHARED_VIEWER_CONFIG = {
|
|
|
|
|
touchmoveTwoFingers: false,
|
|
|
|
|
mousewheelCtrlKey: false,
|
|
|
|
|
navbar: false,
|
|
|
|
|
minFov: 15,
|
|
|
|
|
maxFov: 90,
|
|
|
|
|
zoomSpeed: 0.5,
|
|
|
|
|
fisheye: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type TileConfig = {
|
|
|
|
|
width: number;
|
|
|
|
|
cols: number;
|
|
|
|
|
rows: number;
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-28 18:50:16 +01:00
|
|
|
type Props = {
|
2025-12-19 12:09:21 +01:00
|
|
|
baseUrl: string | { source: string };
|
|
|
|
|
tileUrl?: (col: number, row: number, level: number) => string | null;
|
|
|
|
|
tileconfig?: TileConfig;
|
2025-01-28 23:09:40 +08:00
|
|
|
originalPanorama?: string | { source: string };
|
2024-11-14 08:43:25 -06:00
|
|
|
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
|
|
|
|
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
|
2025-11-28 18:50:16 +01:00
|
|
|
zoomToggle?: (() => void) | null;
|
|
|
|
|
};
|
2024-04-21 12:14:54 -07:00
|
|
|
|
2025-11-28 18:50:16 +01:00
|
|
|
let {
|
2025-12-19 12:09:21 +01:00
|
|
|
baseUrl,
|
|
|
|
|
tileUrl,
|
|
|
|
|
tileconfig,
|
2025-11-28 18:50:16 +01:00
|
|
|
originalPanorama,
|
|
|
|
|
adapter = EquirectangularAdapter,
|
|
|
|
|
plugins = [],
|
|
|
|
|
zoomToggle = $bindable(),
|
|
|
|
|
}: Props = $props();
|
2024-11-14 08:43:25 -06:00
|
|
|
|
|
|
|
|
let container: HTMLDivElement | undefined = $state();
|
2024-02-09 17:17:51 +01:00
|
|
|
let viewer: Viewer;
|
|
|
|
|
|
2025-11-21 16:11:47 +01:00
|
|
|
let animationInProgress: { cancel: () => void } | undefined;
|
|
|
|
|
let previousFaces: Faces[] = [];
|
|
|
|
|
|
|
|
|
|
const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => {
|
|
|
|
|
// Debounce; don't do anything when the data didn't actually change.
|
|
|
|
|
if (faces === previousFaces) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
previousFaces = faces;
|
|
|
|
|
|
|
|
|
|
if (animationInProgress) {
|
|
|
|
|
animationInProgress.cancel();
|
|
|
|
|
animationInProgress = undefined;
|
|
|
|
|
}
|
|
|
|
|
if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin);
|
|
|
|
|
|
|
|
|
|
// croppedWidth is the size of the texture, which might be cropped to be less than 360/180 degrees.
|
|
|
|
|
// This is what we want because the facial recognition is done on the image, not the sphere.
|
|
|
|
|
const currentTextureWidth = viewer.state.textureData.panoData.croppedWidth;
|
|
|
|
|
|
|
|
|
|
markersPlugin.clearMarkers();
|
|
|
|
|
for (const [index, face] of faces.entries()) {
|
|
|
|
|
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2 } = face;
|
|
|
|
|
const ratio = currentTextureWidth / face.imageWidth;
|
|
|
|
|
// Pixel values are translated to spherical coordinates and only then added to the panorama;
|
|
|
|
|
// no need to recalculate when the texture image changes to the original size.
|
|
|
|
|
markersPlugin.addMarker({
|
|
|
|
|
id: `face_${index}`,
|
|
|
|
|
polygonPixels: [
|
|
|
|
|
[x1 * ratio, y1 * ratio],
|
|
|
|
|
[x2 * ratio, y1 * ratio],
|
|
|
|
|
[x2 * ratio, y2 * ratio],
|
|
|
|
|
[x1 * ratio, y2 * ratio],
|
|
|
|
|
],
|
|
|
|
|
svgStyle: FACE_BOX_SVG_STYLE,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Smoothly pan to the highlighted (hovered-over) face.
|
|
|
|
|
if (faces.length === 1) {
|
|
|
|
|
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2, imageWidth: w } = faces[0];
|
|
|
|
|
const ratio = currentTextureWidth / w;
|
|
|
|
|
const x = ((x1 + x2) * ratio) / 2;
|
|
|
|
|
const y = ((y1 + y2) * ratio) / 2;
|
|
|
|
|
animationInProgress = viewer.animate({
|
|
|
|
|
textureX: x,
|
|
|
|
|
textureY: y,
|
|
|
|
|
zoom: Math.min(viewer.getZoomLevel(), 75),
|
|
|
|
|
speed: 500, // duration in ms
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-28 18:50:16 +01:00
|
|
|
zoomToggle = () => {
|
|
|
|
|
if (!viewer) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
viewer.animate({ zoom: $photoZoomState.currentZoom > 1 ? 50 : 83.3, speed: 250 });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let hasChangedResolution: boolean = false;
|
2024-02-09 17:17:51 +01:00
|
|
|
onMount(() => {
|
2024-11-14 08:43:25 -06:00
|
|
|
if (!container) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-19 12:09:21 +01:00
|
|
|
if (tileconfig) {
|
|
|
|
|
viewer = new Viewer({
|
2025-11-28 10:59:39 +01:00
|
|
|
adapter: EquirectangularTilesAdapter,
|
|
|
|
|
panorama: {
|
|
|
|
|
...tileconfig,
|
|
|
|
|
baseUrl,
|
|
|
|
|
tileUrl,
|
|
|
|
|
},
|
2025-12-19 12:09:21 +01:00
|
|
|
plugins: [MarkersPlugin, ...plugins],
|
2025-11-28 10:59:39 +01:00
|
|
|
container,
|
|
|
|
|
...SHARED_VIEWER_CONFIG,
|
2025-12-19 12:09:21 +01:00
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
viewer = new Viewer({
|
|
|
|
|
adapter,
|
2025-11-28 10:59:39 +01:00
|
|
|
panorama: baseUrl,
|
|
|
|
|
plugins: [
|
|
|
|
|
MarkersPlugin,
|
2025-12-19 12:09:21 +01:00
|
|
|
SettingsPlugin,
|
|
|
|
|
ResolutionPlugin.withConfig({
|
2025-01-28 23:09:40 +08:00
|
|
|
defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default',
|
|
|
|
|
resolutions: [
|
|
|
|
|
{
|
|
|
|
|
id: 'default',
|
|
|
|
|
label: 'Default',
|
2025-12-19 12:09:21 +01:00
|
|
|
panorama: baseUrl,
|
2025-01-28 23:09:40 +08:00
|
|
|
},
|
|
|
|
|
...(originalPanorama
|
|
|
|
|
? [
|
|
|
|
|
{
|
|
|
|
|
id: 'original',
|
|
|
|
|
label: 'Original',
|
|
|
|
|
panorama: originalPanorama,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
: []),
|
|
|
|
|
],
|
2025-12-19 12:09:21 +01:00
|
|
|
}),
|
2025-11-28 10:59:39 +01:00
|
|
|
...plugins,
|
2025-01-28 23:09:40 +08:00
|
|
|
],
|
2025-11-28 10:59:39 +01:00
|
|
|
container,
|
|
|
|
|
...SHARED_VIEWER_CONFIG,
|
2025-11-28 18:50:16 +01:00
|
|
|
});
|
|
|
|
|
|
2025-12-19 12:09:21 +01:00
|
|
|
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
|
|
|
|
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
|
|
|
|
// zoomLevel range: [0, 100]
|
|
|
|
|
photoZoomState.set({
|
|
|
|
|
...$photoZoomState,
|
|
|
|
|
currentZoom: zoomLevel / 50,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
|
|
|
|
|
// Replace the preview with the original
|
|
|
|
|
void resolutionPlugin.setResolution('original');
|
|
|
|
|
hasChangedResolution = true;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (originalPanorama && !$alwaysLoadOriginalFile) {
|
|
|
|
|
viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true });
|
2025-06-16 11:03:23 -04:00
|
|
|
}
|
2024-09-02 12:39:55 -07:00
|
|
|
|
2025-12-19 12:09:21 +01:00
|
|
|
return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
|
2024-09-02 12:39:55 -07:00
|
|
|
}
|
2024-02-09 17:17:51 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
onDestroy(() => {
|
|
|
|
|
if (viewer) {
|
|
|
|
|
viewer.destroy();
|
|
|
|
|
}
|
2025-11-21 16:11:47 +01:00
|
|
|
boundingBoxesUnsubscribe();
|
2025-11-28 18:50:16 +01:00
|
|
|
// zoomHandler is not called on initial load. Viewer initial zoom is 1, but photoZoomState could be != 1.
|
|
|
|
|
photoZoomState.set({
|
|
|
|
|
...$photoZoomState,
|
|
|
|
|
currentZoom: 1,
|
|
|
|
|
});
|
2024-02-09 17:17:51 +01:00
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
2025-11-28 18:50:16 +01:00
|
|
|
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: true }]} />
|
2024-11-02 16:49:07 +01:00
|
|
|
<div class="h-full w-full mb-0" bind:this={container}></div>
|