From 15870275151c14efd6d8ef1a4975634ff8cd7500 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Thu, 4 Jan 2024 22:38:16 +0100 Subject: [PATCH 01/16] wip zoomy zoom is back! --- ui/src/components/CollectionView.vue | 62 ++++++++- ui/src/components/PhotoFrame.vue | 88 +++++++++++++ ui/src/components/ScrollViewer.vue | 24 +++- ui/src/components/TileViewer.vue | 188 ++++++++++++++++++++++++++- 4 files changed, 351 insertions(+), 11 deletions(-) create mode 100644 ui/src/components/PhotoFrame.vue diff --git a/ui/src/components/CollectionView.vue b/ui/src/components/CollectionView.vue index b077aea..7df0d15 100644 --- a/ui/src/components/CollectionView.vue +++ b/ui/src/components/CollectionView.vue @@ -6,7 +6,7 @@ :response="collectionResponse" > - - + --> + + + + + + + + @@ -72,6 +98,8 @@ import { useRoute, useRouter } from 'vue-router'; import ResponseLoader from './ResponseLoader.vue'; import StripViewer from './StripViewer.vue'; +import PhotoFrame from './PhotoFrame.vue'; +import Controls from './Controls.vue'; import ScrollViewer from './ScrollViewer.vue'; import MapViewer from './MapViewer.vue'; import { useApi } from '../api'; @@ -102,6 +130,7 @@ const stripViewer = ref(null); const stripView = ref(null); const lastScrollRegion = ref(null); const lastStripRegion = ref(null); +const lastView = ref(null); const route = useRoute(); const router = useRouter(); @@ -111,6 +140,15 @@ const stripVisible = ref(initWithStrip); const lastRegionId = ref(null); const transitionRegionId = ref(null); +const navigate = computed(() => { + return scrollViewer.value?.navigate; +}); + +const exit = () => { + scrollViewer.value?.exit(); + lastView.value = null; +} + const scrollScene = ref(null); const mapScene = ref(null); const stripScene = ref(null); @@ -300,6 +338,18 @@ const onMapRegion = async (region) => { diff --git a/ui/src/components/ScrollViewer.vue b/ui/src/components/ScrollViewer.vue index 347b8de..1c31ccd 100644 --- a/ui/src/components/ScrollViewer.vue +++ b/ui/src/components/ScrollViewer.vue @@ -7,6 +7,7 @@ :style="{ transform: `translate(0, ${scrollY}px)` }" :scene="scene" :view="view" + :clipview="region?.bounds" :selectTagId="selectTagId" :debug="debug" :tileSize="512" @@ -99,6 +100,7 @@ const emit = defineEmits({ region: null, selectTagId: null, search: null, + elementView: null, }) const { @@ -141,7 +143,11 @@ useEventBus("recreate-scene").on(scene => { recreateScene(); }); -const { region } = useSeekableRegion({ +const { + region, + navigate, + exit, +} = useSeekableRegion({ scene, collectionId, regionId, @@ -301,6 +307,20 @@ const onView = (view) => { } } lastView.value = view; + // console.log("view", Object.assign({}, view)); + // console.log("region", Object.assign({}, region.value?.bounds)); + // console.log("element", viewer.value?.elementFromView(view)); + // console.log("screen", getScreenView(region.value?.bounds)); + // const corners = viewer.value?.pixelCornersFromView(region.value?.bounds); + // console.log( + // "x", corners?.tl[0], + // "y", corners?.tl[1], + // "w", corners?.br[0] - corners?.tl[0], + // "h", corners?.br[1] - corners?.tl[1], + // ); + if (region.value?.bounds) { + emit("elementView", getScreenView(region.value.bounds)); + } } const onBoxSelect = async (bounds, shift) => { @@ -367,6 +387,8 @@ defineExpose({ drawViewToCanvas, centerToBounds, getScreenView, + navigate, + exit, }) diff --git a/ui/src/components/TileViewer.vue b/ui/src/components/TileViewer.vue index fccaf3d..7a6f7ff 100644 --- a/ui/src/components/TileViewer.vue +++ b/ui/src/components/TileViewer.vue @@ -33,6 +33,13 @@ import "ol/ol.css"; import { getTileUrl } from '../api'; import Kinetic from 'ol/Kinetic'; import { toLonLat, get as getProjection, fromLonLat } from 'ol/proj'; +import { getBottomLeft, getTopLeft, getTopRight, getBottomRight } from 'ol/extent'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import { Polygon } from 'ol/geom'; +import Feature from 'ol/Feature'; +import Style from 'ol/style/Style'; +import Fill from 'ol/style/Fill'; function ctrlWithMaybeShift(mapBrowserEvent) { const originalEvent = /** @type {KeyboardEvent|MouseEvent|TouchEvent} */ ( @@ -44,7 +51,6 @@ function ctrlWithMaybeShift(mapBrowserEvent) { ); }; - export default { components: { @@ -61,6 +67,7 @@ export default { kinetic: Boolean, tileSize: Number, view: Object, + clipview: Object, backgroundColor: String, selectTagId: String, debug: Object, @@ -94,6 +101,8 @@ export default { }, async mounted() { this.latestView = null; + this.clipviewChangeTime = 0; + this.lastAnimationTime = 0; this.reset(); }, watch: { @@ -171,6 +180,10 @@ export default { this.setView(view); }, + clipview() { + this.clipviewChangeTime = Date.now(); + }, + selectTagId() { this.reload(); }, @@ -265,6 +278,99 @@ export default { ctx.globalCompositeOperation = "source-over"; }); } + + + main.on("prerender", event => { + // Clip to the top left square + const ctx = event.context; + const size = this.map.getSize(); + // const view = this.map.getView(); + // const extent = view.calculateExtent(size); + const view = this.clipview; + if (!view) return; + + // const corners = this.pixelCornersFromView(view); + // const pixelRatio = window.devicePixelRatio; + // corners.tl[0] *= pixelRatio; + // corners.tl[1] *= pixelRatio; + // corners.tr[0] *= pixelRatio; + // corners.tr[1] *= pixelRatio; + // corners.br[0] *= pixelRatio; + // corners.br[1] *= pixelRatio; + // corners.bl[0] *= pixelRatio; + // corners.bl[1] *= pixelRatio; + + // ctx.save(); + // ctx.rect( + // corners.tl[0], + // corners.tl[1], + // corners.tr[0] - corners.tl[0], + // corners.bl[1] - corners.tl[1], + // ); + // ctx.clip(); + + // ctx.strokeStyle = "green"; + // ctx.lineWidth = 20; + // // ctx.beginPath(); + + // ctx.strokeRect( + // corners.tl[0], + // corners.tl[1], + // corners.tr[0] - corners.tl[0], + // corners.bl[1] - corners.tl[1], + // ); + + // Highlight the view instead + }); + + main.on("postrender", event => { + const ctx = event.context; + const view = this.clipview; + if (!view) return; + + // ctx.restore(); + + const size = this.map.getSize(); + const corners = this.pixelCornersFromView(view); + + const pixelRatio = window.devicePixelRatio; + size[0] *= pixelRatio; + size[1] *= pixelRatio; + corners.tl[0] *= pixelRatio; + corners.tl[1] *= pixelRatio; + corners.tr[0] *= pixelRatio; + corners.tr[1] *= pixelRatio; + corners.br[0] *= pixelRatio; + corners.br[1] *= pixelRatio; + corners.bl[0] *= pixelRatio; + corners.bl[1] *= pixelRatio; + + const alpha = + this.lastAnimationTime ? + Math.max(0, Math.min(1, (Date.now() - this.clipviewChangeTime) / (this.lastAnimationTime*1000))) : + 1; + + // TODO: Fade based on distance instead of time + console.log(this.zoomFromView(this.latestView), this.zoomFromView(view)); + + const e = 1; + + ctx.fillStyle = `rgba(0, 0, 0, ${alpha})`; + ctx.beginPath(); + ctx.rect(0, 0, size[0], size[1]); + ctx.moveTo(corners.tl[0] + e, corners.tl[1] + e); + ctx.lineTo(corners.tr[0] - e, corners.tr[1] + e); + ctx.lineTo(corners.br[0] - e, corners.br[1] - e); + ctx.lineTo(corners.bl[0] + e, corners.bl[1] - e); + ctx.closePath(); + ctx.fill("evenodd"); + + if (alpha === 1 || alpha === 0) { + return; + } + + // this.map.render(); + }); return main; }, @@ -273,6 +379,29 @@ export default { const main = this.createMainLayer(); + // const clip = new VectorLayer({ + // source: new VectorSource({ + // features: [ + // new Feature({ + // geometry: new Polygon([ + // [ + // [0, 0], + // [0, 5.5e11], + // [2e11, 2e11], + // [2e11, 0], + // [0, 0], + // ], + // ]), + // }), + // ], + // }), + // style: new Style({ + // fill: new Fill({ + // color: "rgba(0, 0, 0, 0.5)", + // }), + // }), + // }); + if (this.geo) { const mask = new TileLayer({ @@ -317,6 +446,7 @@ export default { } else { return [ main, + // clip, ]; } }, @@ -393,6 +523,7 @@ export default { extent, smoothExtentConstraint: false, showFullExtent: true, + constrainOnlyCenter: true, }); } @@ -653,6 +784,37 @@ export default { } }, + elementFromView(view) { + if (!this.scene) return null; + const fullExtent = this.projection.getExtent(); + const [xa, ya, xb, yb] = fullExtent; + const sw = this.scene.bounds.w; + const sh = this.scene.bounds.h; + const extent = this.extentFromView(view); + return { + x: extent[0] * sw / (xb - xa) + xa, + y: -extent[1] * sh / (yb - ya) + ya, + w: extent[2] * sw / (xb - xa) - extent[0] * sw / (xb - xa), + h: -extent[3] * sh / (yb - ya) + extent[1] * sh / (yb - ya), + } + }, + + pixelCornersFromView(view) { + if (!this.map) return null; + const extent = this.extentFromView(view); + // Coordinate from extent + const tl = getTopLeft(extent); + const tr = getTopRight(extent); + const bl = getBottomLeft(extent); + const br = getBottomRight(extent); + return { + tl: this.map.getPixelFromCoordinate(tl), + tr: this.map.getPixelFromCoordinate(tr), + bl: this.map.getPixelFromCoordinate(bl), + br: this.map.getPixelFromCoordinate(br), + } + }, + setPendingAnimationTime(t) { this.pendingAnimationTime = t; }, @@ -734,7 +896,11 @@ export default { const zoom = this.zoomFromView(view); const zoomDiff = Math.abs(zoom - prevZoom); if (zoomDiff > 1e-4 && !options) { - const t = zoomDiff * 0.05; + console.log(zoomDiff) + // const t = zoomDiff * 0.05; + // const t = zoomDiff * 0.2; + // const t = zoomDiff * 1; + const t = Math.pow(zoomDiff, 0.5) * 0.08; options = { animationTime: t } } } @@ -750,12 +916,26 @@ export default { } const targetExtent = this.extentFromView(view); + + this.lastAnimationTime = options?.animationTime || 0; const fitOpts = options ? { duration: options.animationTime*1000, - easing: function(t) { - return 1 - Math.pow(1 - t, 10) + easing: function(x) { + return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2 }, + // easing: function(x) { + // return x * x * x * (x * (6.0 * x - 15.0) + 10.0); + // }, + // easing: function(t) { + // return Math.pow(t, 0.5) + 0.3; + // }, + // easing: function(t) { + // return Math.pow(2, -50 * t) * Math.sin(((t - 0.1) * (2 * Math.PI)) / 0.3) + 1 + // }, + // easing: function(t) { + // return Math.pow(2, -80 * t) * Math.sin(((t - 0.1) * (2 * Math.PI)) / 0.3) + 1 + // }, } : undefined; this.v.fit(targetExtent, fitOpts); From d78b460188a3c2280df2c42b12ac0ad2302579e3 Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sun, 4 Feb 2024 15:26:50 +0100 Subject: [PATCH 02/16] Mostly working dirty version --- ui/src/api.js | 2 +- ui/src/components/ScrollViewer.vue | 197 ++++++++++++--- ui/src/components/TileViewer.vue | 191 +++++++++++++-- ui/src/components/openlayers/CrossDragPan.js | 243 +++++++++++++++++++ 4 files changed, 585 insertions(+), 48 deletions(-) create mode 100644 ui/src/components/openlayers/CrossDragPan.js diff --git a/ui/src/api.js b/ui/src/api.js index 2bf2dfc..a13bcf3 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -252,7 +252,7 @@ export function useScene({ reset(); loadSpeed.value = 0; - if (newValue.stale && !newValue.loading && !oldValue?.loading) { + if (newValue?.stale && !newValue?.loading && !oldValue?.loading) { console.log("scene stale, recreating..."); await recreateScene(); return; diff --git a/ui/src/components/ScrollViewer.vue b/ui/src/components/ScrollViewer.vue index 1c31ccd..d6a8286 100644 --- a/ui/src/components/ScrollViewer.vue +++ b/ui/src/components/ScrollViewer.vue @@ -1,5 +1,5 @@ @@ -97,8 +64,6 @@ import { timeout, useTask } from 'vue-concurrency'; import { useRoute, useRouter } from 'vue-router'; import ResponseLoader from './ResponseLoader.vue'; -import StripViewer from './StripViewer.vue'; -import PhotoFrame from './PhotoFrame.vue'; import Controls from './Controls.vue'; import ScrollViewer from './ScrollViewer.vue'; import MapViewer from './MapViewer.vue'; @@ -126,9 +91,10 @@ const { } = toRefs(props); const scrollViewer = ref(null); +const mapViewer = ref(null); const stripViewer = ref(null); -const stripView = ref(null); const lastScrollRegion = ref(null); +const lastMapRegion = ref(null); const lastStripRegion = ref(null); const lastView = ref(null); @@ -141,11 +107,11 @@ const lastRegionId = ref(null); const transitionRegionId = ref(null); const navigate = computed(() => { - return scrollViewer.value?.navigate; + return (scrollViewer.value || mapViewer.value)?.navigate; }); const exit = () => { - scrollViewer.value?.exit(); + (scrollViewer.value || mapViewer.value)?.exit(); lastView.value = null; } @@ -320,16 +286,12 @@ const onScrollRegion = async (region) => { } const onMapRegion = async (region) => { - const stripRegion = await stripViewer.value?.getRegionIdFromFileId(region?.data?.id); - if (!stripRegion) { - console.error("No strip region found for", region); - return; - } + lastMapRegion.value = region; router.push({ name: "region", params: { collectionId: collectionId.value, - regionId: stripRegion?.id, + regionId: region?.id, }, query: route.query, }); diff --git a/ui/src/components/MapViewer.vue b/ui/src/components/MapViewer.vue index bda84ac..f1b1889 100644 --- a/ui/src/components/MapViewer.vue +++ b/ui/src/components/MapViewer.vue @@ -10,11 +10,14 @@ :interactive="interactive" :pannable="true" :zoomable="true" + :focus="!!region" + :crossNav="!!region" :geo="true" + :view="view" :zoom-transition="true" :viewport="viewport" - :geoview="geoview" - @geoview="onGeoview" + @nav="onNav" + @view="onView" @contextmenu.prevent="onContextMenu" @click="onClick" > @@ -54,11 +57,12 @@ import ContextMenu from '@overcoder/vue-context-menu'; import { computed, ref, toRefs, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { getRegions, useScene } from '../api'; -import { useContextMenu, useViewport } from '../use.js'; +import { useContextMenu, useSeekableRegion, useViewport } from '../use.js'; import RegionMenu from './RegionMenu.vue'; import Spinner from './Spinner.vue'; import TileViewer from './TileViewer.vue'; import { useEventBus } from '@vueuse/core'; +import Geoview from './openlayers/geoview.js'; const props = defineProps({ interactive: Boolean, @@ -88,6 +92,7 @@ const emit = defineEmits({ const { interactive, collectionId, + regionId, layout, sort, imageHeight, @@ -115,6 +120,16 @@ const { scene, recreate: recreateScene, loadSpeed } = useScene({ search, }); +const { + region, + navigate, + exit: regionExit, +} = useSeekableRegion({ + scene, + collectionId, + regionId, +}) + useEventBus("recreate-scene").on(scene => { if (scene?.name && scene?.name != "Map") return; recreateScene(); @@ -146,18 +161,29 @@ const geoview = computed(() => { if (zstr.endsWith("z")) { zstr = zstr.slice(0, -1); } - + const lat = parseFloat(latstr); const lon = parseFloat(lonstr); const z = parseFloat(zstr); if (isNaN(lat) || isNaN(lon) || isNaN(z)) return; const geoview = [lon, lat, z]; + return geoview; }); -const applyGeoview = (geoview) => { +const geoviewView = computed(() => { + const view = Geoview.toView(geoview.value, scene.value?.bounds); + return view; +}); + +const view = computed(() => { + if (region.value) return region.value.bounds; + return geoviewView.value; +}); + +const applyGeoview = async (geoview) => { const [lon, lat, z] = geoview; - router.replace({ + await router.replace({ query: { ...router.currentRoute.value.query, p: `${lat.toFixed(7)},${lon.toFixed(7)},${z.toFixed(2)}z`, @@ -165,10 +191,62 @@ const applyGeoview = (geoview) => { }); } -const debouncedApplyGeoview = debounce(1000, applyGeoview); +const applyView = (view) => { + const pg = geoview.value; + const g = Geoview.fromView(view, scene.value?.bounds); + + if (Geoview.equal(g, pg)) return; + applyGeoview(g); +} + +const debouncedApplyView = debounce(1000, applyView); + +const onView = (view) => { + debouncedApplyView(view); + lastView.value = view; +} + +const lastView = ref(null); -const onGeoview = (geoview) => { - debouncedApplyGeoview(geoview); +const exit = async () => { + if (!region.value) { + return; + } + const g = Geoview.fromView(lastView.value, scene.value?.bounds); + await applyGeoview(g); + await regionExit(); +} + +const externalExit = async () => { + if (!region.value) { + return; + } + const g = Geoview.fromView(view.value, scene.value?.bounds); + await applyGeoview([ + g[0], + g[1], + Math.max(1, g[2] - 3), + ]) + await regionExit(); +} + +const onNav = async (event) => { + if (event.x) { + const valid = await navigate(event.x); + if (!valid) { + viewer.value?.setPendingTransition({ + t: 0.5, + x: lastView.value?.x, + ease: "out", + }); + zoomOut(); + } + return; + } + if (event.zoom < 0) { + await exit(); + return; + } } const onClick = async (event) => { @@ -182,6 +260,11 @@ const onClick = async (event) => { return false; } +defineExpose({ + navigate, + exit: externalExit, +}) + From 41a56b4192f0e7d3140cbdd02f9536816eea7acf Mon Sep 17 00:00:00 2001 From: Miha Lunar Date: Sat, 17 Feb 2024 12:39:49 +0100 Subject: [PATCH 10/16] Video is now controllable (finally!) --- ui/src/components/CollectionView.vue | 8 +++-- ui/src/components/Overlays.vue | 5 ++- ui/src/components/ScrollViewer.vue | 10 +++--- ui/src/components/TileViewer.vue | 9 +---- ui/src/components/VideoPlayer.vue | 54 +++++----------------------- 5 files changed, 24 insertions(+), 62 deletions(-) diff --git a/ui/src/components/CollectionView.vue b/ui/src/components/CollectionView.vue index 030684e..72eb72d 100644 --- a/ui/src/components/CollectionView.vue +++ b/ui/src/components/CollectionView.vue @@ -10,7 +10,7 @@ { const scrollViewer = ref(null); const scrollTileViewer = ref(null); const mapTileViewer = ref(null); +const interactive = ref(true); const overlayViewer = computed(() => { if (layout.value === 'MAP') { @@ -234,6 +236,7 @@ const debug = computed(() => { }); const onScrollRegion = async (region) => { + if (region?.id === lastScrollRegion.value?.id) return; lastScrollRegion.value = region; if (!region) return; router.push({ @@ -247,6 +250,7 @@ const onScrollRegion = async (region) => { } const onMapRegion = async (region) => { + if (region?.id === lastMapRegion.value?.id) return; lastMapRegion.value = region; if (!region) return; router.push({ diff --git a/ui/src/components/Overlays.vue b/ui/src/components/Overlays.vue index 0d1e6a8..e59dfea 100644 --- a/ui/src/components/Overlays.vue +++ b/ui/src/components/Overlays.vue @@ -37,6 +37,8 @@ const { active } = toRefs(props); +defineEmits(["interactive"]); + const overlayRef = ref(null); const videoOverlay = computed(() => { @@ -134,7 +136,4 @@ function extentFromView(viewer, scene, view) { diff --git a/ui/src/components/ScrollViewer.vue b/ui/src/components/ScrollViewer.vue index 6645803..6860501 100644 --- a/ui/src/components/ScrollViewer.vue +++ b/ui/src/components/ScrollViewer.vue @@ -11,8 +11,8 @@ :debug="debug" :tileSize="512" :interactive="interactive" - :pannable="!nativeScroll" - :zoomable="!nativeScroll" + :pannable="!nativeScroll && interactive" + :zoomable="!nativeScroll && interactive" :zoom-transition="regionTransition" :focus="!!region" :crossNav="!!region" @@ -326,8 +326,10 @@ const onClick = async (event) => { } const regions = await getRegions(scene.value?.id, event.x, event.y, 0, 0); if (regions && regions.length > 0) { - const region = regions[0]; - emit("region", region); + if (regions[0].id == region.value?.id) { + return false; + } + emit("region", regions[0]); return true; } return false; diff --git a/ui/src/components/TileViewer.vue b/ui/src/components/TileViewer.vue index 5a21ad9..f5588cd 100644 --- a/ui/src/components/TileViewer.vue +++ b/ui/src/components/TileViewer.vue @@ -149,13 +149,6 @@ export default { this.setInteractive(interactive); }, - pannable: { - immediate: true, - handler(newValue) { - this.dragPan?.setActive(newValue); - } - }, - zoomable: { immediate: true, handler(newValue) { @@ -218,7 +211,7 @@ export default { return [-width*0.95, -height, width*1.95, height]; }, crossPanActive() { - return this.crossNav && this.focusZoom < 1.1; + return this.pannable && this.crossNav && this.focusZoom < 1.1; }, dragPanActive() { return this.pannable && !this.crossPanActive; diff --git a/ui/src/components/VideoPlayer.vue b/ui/src/components/VideoPlayer.vue index 6f6dcf1..cea8ff6 100644 --- a/ui/src/components/VideoPlayer.vue +++ b/ui/src/components/VideoPlayer.vue @@ -2,9 +2,6 @@