memories/src/components/top-matter/MapSplitMatter.vue

403 lines
11 KiB
Vue
Raw Normal View History

<template>
2023-02-10 18:31:57 +00:00
<div
:class="{
'map-matter': true,
'anim-markers': animMarkers,
}"
>
2023-02-09 05:55:12 +00:00
<LMap
2023-02-08 21:35:42 +00:00
class="map"
2023-01-26 04:51:42 +00:00
ref="map"
2023-02-09 20:02:11 +00:00
:crossOrigin="true"
2023-02-08 21:35:42 +00:00
:zoom="zoom"
:minZoom="2"
@moveend="refresh"
@zoomend="refresh"
2023-02-10 19:58:54 +00:00
:options="mapOptions"
2023-01-26 04:51:42 +00:00
>
2023-02-10 19:58:54 +00:00
<LTileLayer :url="tileurl" :attribution="attribution" :noWrap="true" />
2023-02-09 05:55:12 +00:00
<LMarker
2023-03-08 22:45:29 +00:00
v-for="cluster of clusters"
2023-02-09 07:55:54 +00:00
:key="cluster.id"
:lat-lng="cluster.center"
2023-02-10 01:27:03 +00:00
@click="zoomTo(cluster)"
>
2023-03-08 23:24:25 +00:00
<LIcon :icon-anchor="[24, 24]" :className="clusterIconClass(cluster)">
2023-02-09 05:55:12 +00:00
<div class="preview">
2023-02-09 09:01:15 +00:00
<div class="count" v-if="cluster.count > 1">
{{ cluster.count }}
</div>
2023-02-26 00:35:08 +00:00
<XImg
2023-03-08 23:24:25 +00:00
v-once
2023-02-10 01:41:00 +00:00
:src="clusterPreviewUrl(cluster)"
:class="[
'thumb-important',
`memories-thumb-${cluster.preview.fileid}`,
]"
/>
2023-02-09 05:55:12 +00:00
</div>
</LIcon>
</LMarker>
</LMap>
2023-01-26 04:51:42 +00:00
</div>
</template>
<script lang="ts">
2023-02-08 21:35:42 +00:00
import { defineComponent } from "vue";
2023-02-09 05:55:12 +00:00
import { LMap, LTileLayer, LMarker, LPopup, LIcon } from "vue2-leaflet";
import { latLngBounds, Icon } from "leaflet";
2023-02-10 01:27:03 +00:00
import { IPhoto } from "../../types";
2023-02-08 21:35:42 +00:00
import { API } from "../../services/API";
2023-02-10 01:27:03 +00:00
import { getPreviewUrl } from "../../services/FileUtils";
2023-02-09 16:29:53 +00:00
import axios from "@nextcloud/axios";
2023-02-10 01:27:03 +00:00
import * as utils from "../../services/Utils";
2023-02-08 21:35:42 +00:00
import "leaflet/dist/leaflet.css";
2023-02-10 20:52:54 +00:00
import "leaflet-edgebuffer";
2023-02-09 17:51:26 +00:00
const OSM_TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTRIBUTION =
2023-02-08 21:35:42 +00:00
'&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
2023-02-09 17:51:26 +00:00
const STAMEN_URL = `https://stamen-tiles-{s}.a.ssl.fastly.net/terrain-background/{z}/{x}/{y}{r}.png`;
const STAMEN_ATTRIBUTION = `Map tiles by <a href="http://stamen.com">Stamen Design</a>, under <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a>. Data by <a href="http://openstreetmap.org">OpenStreetMap</a>, under <a href="http://www.openstreetmap.org/copyright">ODbL</a>.`;
2023-02-10 19:42:16 +00:00
// CSS transition time for zooming in/out cluster animation
const CLUSTER_TRANSITION_TIME = 300;
2023-02-08 21:53:38 +00:00
type IMarkerCluster = {
2023-02-09 05:55:12 +00:00
id?: number;
2023-02-08 21:53:38 +00:00
center: [number, number];
count: number;
2023-02-10 01:27:03 +00:00
preview?: IPhoto;
2023-02-10 19:42:16 +00:00
dummy?: boolean;
2023-02-08 21:53:38 +00:00
};
delete (<any>Icon.Default.prototype)._getIconUrl;
Icon.Default.mergeOptions({
iconRetinaUrl: require("leaflet/dist/images/marker-icon-2x.png"),
iconUrl: require("leaflet/dist/images/marker-icon.png"),
shadowUrl: require("leaflet/dist/images/marker-shadow.png"),
});
export default defineComponent({
2023-02-08 22:13:13 +00:00
name: "MapSplitMatter",
2023-01-26 04:51:42 +00:00
components: {
LMap,
LTileLayer,
LMarker,
LPopup,
2023-02-09 05:55:12 +00:00
LIcon,
2023-01-26 04:51:42 +00:00
},
data: () => ({
2023-02-08 21:35:42 +00:00
zoom: 2,
2023-02-10 19:58:54 +00:00
mapOptions: {
maxBounds: latLngBounds([-90, -180], [90, 180]),
maxBoundsViscosity: 0.9,
},
2023-02-08 21:35:42 +00:00
clusters: [] as IMarkerCluster[],
2023-02-10 18:31:57 +00:00
animMarkers: false,
2023-02-10 20:22:08 +00:00
isDark: false,
2023-01-26 04:51:42 +00:00
}),
mounted() {
2023-02-08 21:35:42 +00:00
const map = this.$refs.map as LMap;
2023-02-08 21:35:42 +00:00
// Make sure the zoom control doesn't overlap with the navbar
map.mapObject.zoomControl.setPosition("topright");
// Initialize
this.refresh();
2023-02-10 20:22:08 +00:00
// If currently dark mode, set isDark
const pane = document.querySelector(".leaflet-tile-pane");
this.isDark =
!pane || window.getComputedStyle(pane)?.["filter"]?.includes("invert");
2023-01-26 04:51:42 +00:00
},
2023-02-09 17:51:26 +00:00
computed: {
tileurl() {
2023-02-10 20:22:08 +00:00
return this.zoom >= 5 || this.isDark ? OSM_TILE_URL : STAMEN_URL;
2023-02-09 17:51:26 +00:00
},
attribution() {
2023-02-10 20:22:08 +00:00
return this.zoom >= 5 || this.isDark
? OSM_ATTRIBUTION
: STAMEN_ATTRIBUTION;
2023-02-09 17:51:26 +00:00
},
},
2023-01-26 04:51:42 +00:00
methods: {
2023-02-08 21:35:42 +00:00
async refresh() {
const map = this.$refs.map as LMap;
// Get boundaries of the map
const boundary = map.mapObject.getBounds();
2023-02-10 19:59:11 +00:00
let minLat = boundary.getSouth();
let maxLat = boundary.getNorth();
let minLon = boundary.getWest();
let maxLon = boundary.getEast();
2023-02-08 21:35:42 +00:00
// Set query parameters to route if required
const s = (x: number) => x.toFixed(6);
2023-02-10 19:59:11 +00:00
const bounds = () =>
`${s(minLat)},${s(maxLat)},${s(minLon)},${s(maxLon)}`;
2023-02-10 18:31:57 +00:00
// Zoom level
const oldZoom = this.zoom;
2023-02-10 19:59:11 +00:00
const newZoom = Math.round(map.mapObject.getZoom());
const zoomStr = newZoom.toString();
this.zoom = newZoom;
2023-02-10 18:31:57 +00:00
// Check if we already have the data
2023-02-10 19:59:11 +00:00
if (this.$route.query.b === bounds() && this.$route.query.z === zoomStr) {
2023-02-08 21:35:42 +00:00
return;
}
2023-02-10 19:59:11 +00:00
this.$router.replace({
query: {
b: bounds(),
z: zoomStr,
},
2023-02-13 00:54:11 +00:00
hash: this.$route.hash,
2023-02-10 19:59:11 +00:00
});
// Extend bounds by 25% beyond the map
const latDiff = Math.abs(maxLat - minLat);
const lonDiff = Math.abs(maxLon - minLon);
minLat -= latDiff * 0.25;
maxLat += latDiff * 0.25;
minLon -= lonDiff * 0.25;
maxLon += lonDiff * 0.25;
2023-02-08 21:35:42 +00:00
// Make API call
2023-03-10 20:16:56 +00:00
const url = API.Q(API.MAP_CLUSTERS(), {
bounds: bounds(),
zoom: zoomStr,
});
const res = await axios.get(url);
2023-02-10 18:31:57 +00:00
2023-02-10 19:42:16 +00:00
if (this.zoom > oldZoom) {
this.setClustersZoomIn(res.data, oldZoom);
} else if (this.zoom < oldZoom) {
this.setClustersZoomOut(res.data);
} else {
this.clusters = res.data;
2023-02-10 18:31:57 +00:00
}
2023-02-10 19:42:16 +00:00
// Animate markers
this.animateMarkers();
},
2023-02-09 05:55:12 +00:00
clusterPreviewUrl(cluster: IMarkerCluster) {
2023-02-10 02:14:12 +00:00
return getPreviewUrl(cluster.preview, false, 256);
2023-02-09 05:55:12 +00:00
},
2023-02-09 07:15:48 +00:00
2023-02-10 19:42:16 +00:00
clusterIconClass(cluster: IMarkerCluster) {
return cluster.dummy ? "dummy" : "";
},
2023-02-10 01:27:03 +00:00
zoomTo(cluster: IMarkerCluster) {
// At high zoom levels, open the photo
2023-02-10 01:41:00 +00:00
if (this.zoom >= 12 && cluster.preview) {
2023-02-10 01:27:03 +00:00
cluster.preview.key = cluster.preview.fileid.toString();
this.$router.push(utils.getViewerRoute(cluster.preview));
return;
}
// Zoom in
2023-02-09 07:15:48 +00:00
const map = this.$refs.map as LMap;
2023-02-10 00:25:00 +00:00
const factor = globalThis.innerWidth >= 768 ? 2 : 1;
const zoom = map.mapObject.getZoom() + factor;
2023-02-10 01:27:03 +00:00
map.mapObject.setView(cluster.center, zoom, { animate: true });
2023-02-09 07:15:48 +00:00
},
2023-02-10 18:31:57 +00:00
2023-02-10 19:42:16 +00:00
getGridKey(center: [number, number], zoom: number) {
// Calcluate grid length
const clusterDensity = 1;
const oldGridLen = 180.0 / (2 ** zoom * clusterDensity);
// Get map key
const latGid = Math.floor(center[0] / oldGridLen);
const lonGid = Math.floor(center[1] / oldGridLen);
return `${latGid}-${lonGid}`;
},
getGridMap(clusters: IMarkerCluster[], zoom: number) {
const gridMap = new Map<string, IMarkerCluster>();
for (const cluster of clusters) {
const key = this.getGridKey(cluster.center, zoom);
gridMap.set(key, cluster);
}
return gridMap;
},
async setClustersZoomIn(clusters: IMarkerCluster[], oldZoom: number) {
// Create GID-map for old clusters
const oldClusters = this.getGridMap(this.clusters, oldZoom);
// Dummy clusters to animate markers
const dummyClusters: IMarkerCluster[] = [];
// Iterate new clusters
for (const cluster of clusters) {
// Check if cluster already exists
const key = this.getGridKey(cluster.center, oldZoom);
const oldCluster = oldClusters.get(key);
if (oldCluster) {
// Copy cluster and set location to old cluster
dummyClusters.push({
...cluster,
center: oldCluster.center,
});
} else {
// Just show it
dummyClusters.push(cluster);
}
}
// Set clusters
this.clusters = dummyClusters;
await this.$nextTick();
await new Promise((r) => setTimeout(r, 0));
this.clusters = clusters;
},
async setClustersZoomOut(clusters: IMarkerCluster[]) {
// Get GID-map for new clusters
const newClustersGid = this.getGridMap(clusters, this.zoom);
// Get ID-map for new clusters
const newClustersId = new Map<number, IMarkerCluster>();
for (const cluster of clusters) {
newClustersId.set(cluster.id, cluster);
}
// Dummy clusters to animate markers
const dummyClusters: IMarkerCluster[] = [...clusters];
// Iterate old clusters
for (const oldCluster of this.clusters) {
// Process only clusters that are not in the new clusters
const newCluster = newClustersId.get(oldCluster.id);
if (!newCluster) {
// Get the new cluster at the same GID
const key = this.getGridKey(oldCluster.center, this.zoom);
const newCluster = newClustersGid.get(key);
if (newCluster) {
// No need to copy; it is gone anyway
oldCluster.center = newCluster.center;
oldCluster.dummy = true;
dummyClusters.push(oldCluster);
}
}
}
// Set clusters
this.clusters = dummyClusters;
await new Promise((r) => setTimeout(r, CLUSTER_TRANSITION_TIME)); // wait for animation
this.clusters = clusters;
},
2023-02-10 18:31:57 +00:00
async animateMarkers() {
this.animMarkers = true;
2023-02-10 19:42:16 +00:00
await new Promise((r) => setTimeout(r, CLUSTER_TRANSITION_TIME)); // wait for animation
2023-02-10 18:31:57 +00:00
this.animMarkers = false;
},
2023-01-26 04:51:42 +00:00
},
});
</script>
<style lang="scss" scoped>
2023-02-08 22:13:13 +00:00
.map-matter {
2023-02-08 21:35:42 +00:00
height: 100%;
width: 100%;
}
.map {
height: 100%;
width: 100%;
margin: 0;
z-index: 0;
2023-02-10 20:22:08 +00:00
background-color: var(--color-background-dark);
:deep .leaflet-control-attribution {
background-color: var(--color-background-dark);
color: var(--color-text-light);
}
:deep .leaflet-bar a {
background-color: var(--color-main-background);
color: var(--color-main-text);
&.leaflet-disabled {
opacity: 0.6;
}
}
}
2023-02-09 05:55:12 +00:00
.preview {
width: 48px;
height: 48px;
2023-02-09 16:25:37 +00:00
background-color: rgba(0, 0, 0, 0.3);
2023-02-09 05:55:12 +00:00
border-radius: 5px;
position: relative;
transition: transform 0.2s;
&:hover {
transform: scale(1.8);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 5px;
2023-02-09 07:15:48 +00:00
cursor: pointer;
2023-02-09 05:55:12 +00:00
}
.count {
position: absolute;
top: 0;
right: 0;
2023-02-10 04:13:22 +00:00
background-color: var(--color-primary);
2023-02-09 05:55:12 +00:00
color: var(--color-primary-text);
padding: 0 4px;
border-radius: 5px;
font-size: 0.8em;
}
}
</style>
<style lang="scss">
2023-02-09 07:55:54 +00:00
.leaflet-marker-icon {
2023-02-10 18:31:57 +00:00
.anim-markers & {
2023-02-10 19:42:16 +00:00
transition: transform 0.3s ease;
2023-02-10 18:31:57 +00:00
}
2023-02-09 07:55:54 +00:00
2023-02-10 19:42:16 +00:00
&.dummy {
z-index: -100000 !important;
}
&:hover {
z-index: 100000 !important;
}
2023-02-09 05:55:12 +00:00
}
2023-02-09 07:55:54 +00:00
2023-02-09 17:17:24 +00:00
// Dark mode
$darkFilter: invert(1) grayscale(1) brightness(1.3) contrast(1.3);
.leaflet-tile-pane {
body[data-theme-dark] &,
body[data-theme-dark-highcontrast] & {
filter: $darkFilter;
}
@media (prefers-color-scheme: dark) {
body[data-theme-default] & {
filter: $darkFilter;
}
}
}
2023-01-26 04:51:42 +00:00
</style>