map: animate cluster joins and splits
parent
ea5648eb16
commit
22af8a5615
|
@ -51,6 +51,7 @@ class MapController extends ApiBase
|
||||||
}
|
}
|
||||||
|
|
||||||
// A tweakable parameter to determine the number of boxes in the map
|
// A tweakable parameter to determine the number of boxes in the map
|
||||||
|
// Note: these parameters need to be changed in MapSplitMatter.vue as well
|
||||||
$clusterDensity = 1;
|
$clusterDensity = 1;
|
||||||
$gridLen = 180.0 / (2 ** $zoomLevel * $clusterDensity);
|
$gridLen = 180.0 / (2 ** $zoomLevel * $clusterDensity);
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
:lat-lng="cluster.center"
|
:lat-lng="cluster.center"
|
||||||
@click="zoomTo(cluster)"
|
@click="zoomTo(cluster)"
|
||||||
>
|
>
|
||||||
<LIcon :icon-anchor="[24, 24]">
|
<LIcon :icon-anchor="[24, 24]" :className="clusterIconClass(cluster)">
|
||||||
<div class="preview">
|
<div class="preview">
|
||||||
<div class="count" v-if="cluster.count > 1">
|
<div class="count" v-if="cluster.count > 1">
|
||||||
{{ cluster.count }}
|
{{ cluster.count }}
|
||||||
|
@ -58,11 +58,15 @@ const OSM_ATTRIBUTION =
|
||||||
const STAMEN_URL = `https://stamen-tiles-{s}.a.ssl.fastly.net/terrain-background/{z}/{x}/{y}{r}.png`;
|
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>.`;
|
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>.`;
|
||||||
|
|
||||||
|
// CSS transition time for zooming in/out cluster animation
|
||||||
|
const CLUSTER_TRANSITION_TIME = 300;
|
||||||
|
|
||||||
type IMarkerCluster = {
|
type IMarkerCluster = {
|
||||||
id?: number;
|
id?: number;
|
||||||
center: [number, number];
|
center: [number, number];
|
||||||
count: number;
|
count: number;
|
||||||
preview?: IPhoto;
|
preview?: IPhoto;
|
||||||
|
dummy?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
@ -135,18 +139,27 @@ export default defineComponent({
|
||||||
// Make API call
|
// Make API call
|
||||||
const url = API.Q(API.MAP_CLUSTERS(), query);
|
const url = API.Q(API.MAP_CLUSTERS(), query);
|
||||||
const res = await axios.get(url);
|
const res = await axios.get(url);
|
||||||
this.clusters = res.data;
|
|
||||||
|
|
||||||
// Animate markers if zoom level changed
|
if (this.zoom > oldZoom) {
|
||||||
if (oldZoom !== this.zoom) {
|
this.setClustersZoomIn(res.data, oldZoom);
|
||||||
this.animateMarkers();
|
} else if (this.zoom < oldZoom) {
|
||||||
|
this.setClustersZoomOut(res.data);
|
||||||
|
} else {
|
||||||
|
this.clusters = res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Animate markers
|
||||||
|
this.animateMarkers();
|
||||||
},
|
},
|
||||||
|
|
||||||
clusterPreviewUrl(cluster: IMarkerCluster) {
|
clusterPreviewUrl(cluster: IMarkerCluster) {
|
||||||
return getPreviewUrl(cluster.preview, false, 256);
|
return getPreviewUrl(cluster.preview, false, 256);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clusterIconClass(cluster: IMarkerCluster) {
|
||||||
|
return cluster.dummy ? "dummy" : "";
|
||||||
|
},
|
||||||
|
|
||||||
zoomTo(cluster: IMarkerCluster) {
|
zoomTo(cluster: IMarkerCluster) {
|
||||||
// At high zoom levels, open the photo
|
// At high zoom levels, open the photo
|
||||||
if (this.zoom >= 12 && cluster.preview) {
|
if (this.zoom >= 12 && cluster.preview) {
|
||||||
|
@ -162,9 +175,96 @@ export default defineComponent({
|
||||||
map.mapObject.setView(cluster.center, zoom, { animate: true });
|
map.mapObject.setView(cluster.center, zoom, { animate: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
|
||||||
async animateMarkers() {
|
async animateMarkers() {
|
||||||
this.animMarkers = true;
|
this.animMarkers = true;
|
||||||
await new Promise((r) => setTimeout(r, 200)); // wait for animation
|
await new Promise((r) => setTimeout(r, CLUSTER_TRANSITION_TIME)); // wait for animation
|
||||||
this.animMarkers = false;
|
this.animMarkers = false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -219,17 +319,18 @@ export default defineComponent({
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.leaflet-marker-icon {
|
.leaflet-marker-icon {
|
||||||
animation: fade-in 0.2s;
|
|
||||||
|
|
||||||
.anim-markers & {
|
.anim-markers & {
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show leaflet marker on top on hover
|
&.dummy {
|
||||||
.leaflet-marker-icon:hover {
|
z-index: -100000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
z-index: 100000 !important;
|
z-index: 100000 !important;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dark mode
|
// Dark mode
|
||||||
$darkFilter: invert(1) grayscale(1) brightness(1.3) contrast(1.3);
|
$darkFilter: invert(1) grayscale(1) brightness(1.3) contrast(1.3);
|
||||||
|
@ -245,13 +346,4 @@ $darkFilter: invert(1) grayscale(1) brightness(1.3) contrast(1.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
Loading…
Reference in New Issue