Places: Add Cluster View (#2596)
* enable clustering on all zoom levels * add latmin, latmax, lngmin and lngmax to search api * open prefiltered search when clicking a cluster on the map * start moving places pictures to overlay * update scrollbar hide todo comment * add todo comments for cluster view * start implementing possitbility to close cluster view via back button * move selected cluster to single query param * improve back-navigation of cluster-view * remove outdated comment * start showing preview-images in clusters * cleanup some cluster-code in places * use rounded squares instead of circles so more from the image can be seen * try improving map visibility by adding borders * add counter bubble to places view * remove obsolete comment * remove console.log * add todo comment to fix search on cluster-view open * fix closing a cluster resetting the places-filter * prevent old cluster markers from being visible while zooming * prevent cluster-previews from being smaller than single-image-previews * make cluster-preview-images fill the whole available area and scale them to their bounding box * increase clusterRadius to reduce likelyhood of clusters colliding on the map * update obsolete todo comment * try making cluster view look less broken for small clusters. elements in photo-view use block-relative percentages based on viewport-relative media queries * remove seemingly unrequired code * fix cluster view after "pages"-components moved to "page" (singular)
This commit is contained in:
parent
ea8ee0938c
commit
60d280430e
7 changed files with 292 additions and 85 deletions
|
@ -1,6 +1,7 @@
|
|||
<template>
|
||||
<v-form ref="form" lazy-validation
|
||||
dense autocomplete="off" class="p-photo-toolbar" accept-charset="UTF-8"
|
||||
:class="{'sticky': sticky}"
|
||||
@submit.prevent="updateQuery()">
|
||||
<v-toolbar flat :dense="$vuetify.breakpoint.smAndDown" class="page-toolbar" color="secondary">
|
||||
<v-text-field :value="filter.q"
|
||||
|
@ -45,6 +46,10 @@
|
|||
@click.stop="searchExpanded = !searchExpanded">
|
||||
<v-icon>{{ searchExpanded ? 'keyboard_arrow_up' : 'keyboard_arrow_down' }}</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="onClose !== undefined" icon @click.stop="onClose">
|
||||
<v-icon>close</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<v-card v-show="searchExpanded"
|
||||
|
@ -207,6 +212,14 @@ export default {
|
|||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: undefined,
|
||||
},
|
||||
sticky: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const features = this.$config.settings().features;
|
||||
|
|
|
@ -17,8 +17,41 @@
|
|||
border-color: #fff;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
border-radius: 20%;
|
||||
cursor: pointer;
|
||||
border: 1px solid #7f7f7f;
|
||||
}
|
||||
|
||||
#photoprism #map .cluster-marker {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 1px;
|
||||
overflow: hidden;
|
||||
background-color: #7f7f7f;
|
||||
width: 100%;
|
||||
height: 100%
|
||||
}
|
||||
|
||||
#photoprism #map .counter-bubble {
|
||||
border-radius: 100%;
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
background-color: #bb3719;
|
||||
top: -12px;
|
||||
right: -12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0px 3px 1px -2px rgb(0 0 0 / 20%), 0px 2px 2px 0px rgb(0 0 0 / 14%), 0px 1px 5px 0px rgb(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
#photoprism #map .cluster-marker > div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
#photoprism .maplibregl-ctrl-attrib-inner a {
|
||||
|
|
|
@ -120,6 +120,12 @@ nav .v-list__tile__title.title {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
#photoprism .p-photo-toolbar.sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.p-user-box {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
|
|
|
@ -3,8 +3,16 @@
|
|||
:infinite-scroll-disabled="scrollDisabled" :infinite-scroll-distance="scrollDistance"
|
||||
:infinite-scroll-listen-for-event="'scrollRefresh'">
|
||||
|
||||
<p-photo-toolbar :context="context" :filter="filter" :settings="settings" :refresh="refresh"
|
||||
:update-filter="updateFilter" :update-query="updateQuery"></p-photo-toolbar>
|
||||
<p-photo-toolbar
|
||||
:context="context"
|
||||
:filter="filter"
|
||||
:settings="settings"
|
||||
:refresh="refresh"
|
||||
:update-filter="updateFilter"
|
||||
:update-query="updateQuery"
|
||||
:on-close="onClose"
|
||||
:sticky="stickyToolbar"
|
||||
/>
|
||||
|
||||
<v-container v-if="loading" fluid class="pa-4">
|
||||
<v-progress-linear color="secondary-dark" :indeterminate="true"></v-progress-linear>
|
||||
|
@ -58,6 +66,14 @@ export default {
|
|||
default: () => {
|
||||
},
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: undefined,
|
||||
},
|
||||
stickyToolbar: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const query = this.$route.query;
|
||||
|
|
|
@ -18,6 +18,16 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-dialog v-model="showClusterPictures" overflowed width="100%">
|
||||
<v-card min-height="80vh">
|
||||
<p-page-photos
|
||||
v-if="showClusterPictures"
|
||||
:static-filter="selectedClusterBounds"
|
||||
:on-close="unselectCluster"
|
||||
sticky-toolbar
|
||||
/>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
|
@ -25,9 +35,13 @@
|
|||
import maplibregl from "maplibre-gl";
|
||||
import Api from "common/api";
|
||||
import Thumb from "model/thumb";
|
||||
import PPagePhotos from 'page/photos.vue';
|
||||
|
||||
export default {
|
||||
name: 'PPagePlaces',
|
||||
components: {
|
||||
PPagePhotos,
|
||||
},
|
||||
props: {
|
||||
staticFilter: {
|
||||
type: Object,
|
||||
|
@ -58,23 +72,54 @@ export default {
|
|||
lastFilter: {},
|
||||
config: this.$config.values,
|
||||
settings: this.$config.values.settings.maps,
|
||||
selectedClusterBounds: undefined,
|
||||
showClusterPictures: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
'$route'() {
|
||||
|
||||
const clusterWasOpenBeforeRouterChange = this.selectedClusterBounds !== undefined;
|
||||
const clusterIsOpenAfterRouteChange = this.getSelectedClusterFromUrl() !== undefined;
|
||||
const lastRouteChangeWasClusterOpenOrClose = clusterWasOpenBeforeRouterChange !== clusterIsOpenAfterRouteChange;
|
||||
|
||||
if (lastRouteChangeWasClusterOpenOrClose) {
|
||||
this.updateSelectedClusterFromUrl();
|
||||
|
||||
/**
|
||||
* dont touch any filters or searches if the only action taken was
|
||||
* opening or closing a cluster.
|
||||
* This currently assumes that when a cluster was opened or closed,
|
||||
* nothing else changed. I currently can't think of a scenario, where
|
||||
* a route-change is triggered by the user wanting to open/close a cluster
|
||||
* AND for example update the filter at the same time.
|
||||
*
|
||||
* Without this, opening or closing a cluster triggers a search, even
|
||||
* though no search parameter changed. Also without this, closing a
|
||||
* cluster resets the filter, because closing a cluster is done via
|
||||
* backwards navigation.
|
||||
* (closing is cluster is done via backwards navigation so that it can
|
||||
* be closed using the back-button. This is especially useful on android
|
||||
* smartphones)
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
this.filter.q = this.query();
|
||||
this.filter.s = this.scope();
|
||||
this.lastFilter = {};
|
||||
|
||||
this.search();
|
||||
},
|
||||
showClusterPictures:function(newValue, old){
|
||||
if(!newValue){
|
||||
this.unselectCluster();
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$scrollbar.hide();
|
||||
this.configureMap().then(() => this.renderMap());
|
||||
},
|
||||
destroyed() {
|
||||
this.$scrollbar.show();
|
||||
this.updateSelectedClusterFromUrl();
|
||||
},
|
||||
methods: {
|
||||
configureMap() {
|
||||
|
@ -237,6 +282,59 @@ export default {
|
|||
this.options = mapOptions;
|
||||
});
|
||||
},
|
||||
getSelectedClusterFromUrl() {
|
||||
const clusterIsSelected = this.$route.query.selectedCluster !== undefined
|
||||
&& this.$route.query.selectedCluster !== '';
|
||||
if (!clusterIsSelected) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [latmin, latmax, lngmin, lngmax] = this.$route.query.selectedCluster.split(',');
|
||||
return {latmin, latmax, lngmin, lngmax};
|
||||
},
|
||||
updateSelectedClusterFromUrl: function() {
|
||||
this.selectedClusterBounds = this.getSelectedClusterFromUrl();
|
||||
this.showClusterPictures = this.selectedClusterBounds !== undefined;
|
||||
},
|
||||
selectClusterByCoords: function(latMin, latMax, lngMin, lngMax) {
|
||||
this.$router.push({
|
||||
query: {
|
||||
selectedCluster: [latMin, latMax, lngMin, lngMax].join(','),
|
||||
},
|
||||
params: this.filter,
|
||||
});
|
||||
},
|
||||
selectClusterById: function(clusterId) {
|
||||
this.getClusterFeatures(clusterId, (clusterFeatures) => {
|
||||
let latMin,latMax,lngMin,lngMax;
|
||||
for (const feature of clusterFeatures) {
|
||||
const [lng,lat] = feature.geometry.coordinates;
|
||||
if (latMin === undefined || lat < latMin) {
|
||||
latMin = lat;
|
||||
}
|
||||
if (latMax === undefined || lat > latMax) {
|
||||
latMax = lat;
|
||||
}
|
||||
if (lngMin === undefined || lng < lngMin) {
|
||||
lngMin = lng;
|
||||
}
|
||||
if (lngMax === undefined || lng > lngMax) {
|
||||
lngMax = lng;
|
||||
}
|
||||
}
|
||||
|
||||
this.selectClusterByCoords(latMin, latMax, lngMin, lngMax);
|
||||
});
|
||||
},
|
||||
unselectCluster: function() {
|
||||
const aClusterIsSelected = this.getSelectedClusterFromUrl() !== undefined;
|
||||
if (aClusterIsSelected) {
|
||||
// it shouldn't matter wether a cluster was closed by pressing the back
|
||||
// button on a browser or the x-button on the dialog. We therefore make
|
||||
// both actions do the exact same thing: navigate backwards
|
||||
this.$router.go(-1);
|
||||
}
|
||||
},
|
||||
query: function () {
|
||||
return this.$route.params.q ? this.$route.params.q : '';
|
||||
},
|
||||
|
@ -392,57 +490,123 @@ export default {
|
|||
|
||||
this.map.on("load", () => this.onMapLoad());
|
||||
},
|
||||
getClusterFeatures(clusterId, callback) {
|
||||
this.map.getSource('photos').getClusterLeaves(clusterId, -1, undefined, (error, clusterFeatures) => {
|
||||
callback(clusterFeatures);
|
||||
});;
|
||||
},
|
||||
getMultipleClusterFeatures(clusterIds, callback) {
|
||||
const result = {};
|
||||
let handledClusterLeaveResultCount = 0;
|
||||
for (const clusterId of clusterIds) {
|
||||
this.getClusterFeatures(clusterId, (clusterFeatures) => {
|
||||
result[clusterId] = clusterFeatures;
|
||||
handledClusterLeaveResultCount += 1;
|
||||
|
||||
if (handledClusterLeaveResultCount === clusterIds.length) {
|
||||
callback(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
getClusterRadiusFromItemCount(itemCount) {
|
||||
// see config of cluster-layer for these values
|
||||
if (itemCount >= 750) {
|
||||
return 50;
|
||||
}
|
||||
if (itemCount >= 100) {
|
||||
return 40;
|
||||
}
|
||||
|
||||
return 30;
|
||||
},
|
||||
updateMarkers() {
|
||||
if (this.loading) return;
|
||||
let newMarkers = {};
|
||||
let features = this.map.querySourceFeatures("photos");
|
||||
const clusterIds = features
|
||||
.filter(feature => feature.properties.cluster)
|
||||
.map(feature => feature.properties.cluster_id);
|
||||
|
||||
for (let i = 0; i < features.length; i++) {
|
||||
let coords = features[i].geometry.coordinates;
|
||||
let props = features[i].properties;
|
||||
if (props.cluster) continue;
|
||||
let id = features[i].id;
|
||||
this.getMultipleClusterFeatures(clusterIds, (clusterFeaturesById) => {
|
||||
for (let i = 0; i < features.length; i++) {
|
||||
let coords = features[i].geometry.coordinates;
|
||||
let props = features[i].properties;
|
||||
let id = features[i].id;
|
||||
|
||||
let marker = this.markers[id];
|
||||
let token = this.$config.previewToken;
|
||||
if (!marker) {
|
||||
let el = document.createElement('div');
|
||||
el.className = 'marker';
|
||||
el.title = props.Title;
|
||||
el.style.backgroundImage = `url(${this.$config.contentUri}/t/${props.Hash}/${token}/tile_50)`;
|
||||
el.style.width = '50px';
|
||||
el.style.height = '50px';
|
||||
let marker = this.markers[id];
|
||||
let token = this.$config.previewToken;
|
||||
if (!marker) {
|
||||
let el = document.createElement('div');
|
||||
if (props.cluster) {
|
||||
const radius = this.getClusterRadiusFromItemCount(props.point_count);
|
||||
el.style.width = `${radius * 2}px`;
|
||||
el.style.height = `${radius * 2}px`;
|
||||
|
||||
el.addEventListener('click', () => this.openPhoto(props.UID));
|
||||
marker = this.markers[id] = new maplibregl.Marker({
|
||||
element: el
|
||||
}).setLngLat(coords);
|
||||
} else {
|
||||
marker.setLngLat(coords);
|
||||
const imageContainer = document.createElement('div');
|
||||
imageContainer.className = 'marker cluster-marker';
|
||||
|
||||
const clusterFeatures = clusterFeaturesById[props.cluster_id];
|
||||
const previewImageCount = clusterFeatures.length > 3 ? 4 : 2;
|
||||
const images = clusterFeatures
|
||||
.slice(0, previewImageCount)
|
||||
.map((feature) => {
|
||||
const imageHash = feature.properties.Hash;
|
||||
const image = document.createElement('div');
|
||||
image.style.backgroundImage = `url(${this.$config.contentUri}/t/${imageHash}/${token}/tile_${50})`;
|
||||
return image;
|
||||
});
|
||||
imageContainer.append(...images);
|
||||
|
||||
const counterBubble = document.createElement('div');
|
||||
counterBubble.className = 'counter-bubble';
|
||||
counterBubble.innerText = clusterFeatures.length > 99 ? '99+' : clusterFeatures.length;
|
||||
|
||||
el.append(imageContainer);
|
||||
el.append(counterBubble);
|
||||
el.addEventListener('click', () => {
|
||||
this.selectClusterById(props.cluster_id);
|
||||
});
|
||||
} else {
|
||||
el.className = 'marker';
|
||||
el.title = props.Title;
|
||||
el.style.backgroundImage = `url(${this.$config.contentUri}/t/${props.Hash}/${token}/tile_50)`;
|
||||
el.style.width = '50px';
|
||||
el.style.height = '50px';
|
||||
|
||||
el.addEventListener('click', () => this.openPhoto(props.UID));
|
||||
}
|
||||
marker = this.markers[id] = new maplibregl.Marker({
|
||||
element: el
|
||||
}).setLngLat(coords);
|
||||
} else {
|
||||
marker.setLngLat(coords);
|
||||
}
|
||||
|
||||
newMarkers[id] = marker;
|
||||
|
||||
if (!this.markersOnScreen[id]) {
|
||||
marker.addTo(this.map);
|
||||
}
|
||||
}
|
||||
|
||||
newMarkers[id] = marker;
|
||||
|
||||
if (!this.markersOnScreen[id]) {
|
||||
marker.addTo(this.map);
|
||||
for (let id in this.markersOnScreen) {
|
||||
if (!newMarkers[id]) {
|
||||
this.markersOnScreen[id].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let id in this.markersOnScreen) {
|
||||
if (!newMarkers[id]) {
|
||||
this.markersOnScreen[id].remove();
|
||||
}
|
||||
}
|
||||
this.markersOnScreen = newMarkers;
|
||||
this.markersOnScreen = newMarkers;
|
||||
});
|
||||
},
|
||||
onMapLoad() {
|
||||
this.map.addSource('photos', {
|
||||
type: 'geojson',
|
||||
data: null,
|
||||
cluster: true,
|
||||
clusterMaxZoom: 14, // Max zoom to cluster points on
|
||||
clusterRadius: 50 // Radius of each cluster when clustering points (defaults to 50)
|
||||
clusterMaxZoom: 18, // Max zoom to cluster points on
|
||||
clusterRadius: 80 // Radius of each cluster when clustering points (defaults to 50)
|
||||
});
|
||||
|
||||
// TODO: can this rendering of empty colored circles be removed?
|
||||
this.map.addLayer({
|
||||
id: 'clusters',
|
||||
type: 'circle',
|
||||
|
@ -452,62 +616,26 @@ export default {
|
|||
'circle-color': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
'#2DC4B2',
|
||||
'transparent',
|
||||
100,
|
||||
'#3BB3C3',
|
||||
'transparent',
|
||||
750,
|
||||
'#669EC4'
|
||||
'transparent'
|
||||
],
|
||||
'circle-radius': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
20,
|
||||
100,
|
||||
30,
|
||||
100,
|
||||
40,
|
||||
750,
|
||||
40
|
||||
50
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
this.map.addLayer({
|
||||
id: 'cluster-count',
|
||||
type: 'symbol',
|
||||
source: 'photos',
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': '{point_count_abbreviated}',
|
||||
'text-font': this.mapFont,
|
||||
'text-size': 13
|
||||
}
|
||||
});
|
||||
|
||||
this.map.on('render', this.updateMarkers);
|
||||
|
||||
this.map.on('click', 'clusters', (e) => {
|
||||
const features = this.map.queryRenderedFeatures(e.point, {
|
||||
layers: ['clusters']
|
||||
});
|
||||
const clusterId = features[0].properties.cluster_id;
|
||||
this.map.getSource('photos').getClusterExpansionZoom(
|
||||
clusterId,
|
||||
(err, zoom) => {
|
||||
if (err) return;
|
||||
|
||||
this.map.easeTo({
|
||||
center: features[0].geometry.coordinates,
|
||||
zoom: zoom
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
this.map.on('mouseenter', 'clusters', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer';
|
||||
});
|
||||
this.map.on('mouseleave', 'clusters', () => {
|
||||
this.map.getCanvas().style.cursor = '';
|
||||
});
|
||||
|
||||
this.search();
|
||||
},
|
||||
|
|
|
@ -45,6 +45,10 @@ type SearchPhotos struct {
|
|||
Unsorted bool `form:"unsorted" notes:"Finds pictures not in an album"`
|
||||
Lat float32 `form:"lat" notes:"Latitude (GPS Position)"`
|
||||
Lng float32 `form:"lng" notes:"Longitude (GPS Position)"`
|
||||
Latmin float32 `form:"latmin" notes:"Minimum latitude (GPS Position)"`
|
||||
Latmax float32 `form:"latmax" notes:"Maximum latitude (GPS Position)"`
|
||||
Lngmin float32 `form:"lngmin" notes:"Minimum longitude (GPS Position)"`
|
||||
Lngmax float32 `form:"lngmax" notes:"Maximum longitude (GPS Position)"`
|
||||
Dist uint `form:"dist" example:"dist:5" notes:"Distance in km in combination with lat/lng"`
|
||||
Fmin float32 `form:"fmin" notes:"F-number (min)"`
|
||||
Fmax float32 `form:"fmax" notes:"F-number (max)"`
|
||||
|
|
|
@ -636,6 +636,13 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string)
|
|||
s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngMin, lngMax)
|
||||
}
|
||||
|
||||
if f.Latmin != 0 && f.Latmax != 0 {
|
||||
s = s.Where("photos.photo_lat BETWEEN ? AND ?", f.Latmin, f.Latmax)
|
||||
}
|
||||
if f.Lngmin != 0 && f.Lngmax != 0 {
|
||||
s = s.Where("photos.photo_lng BETWEEN ? AND ?", f.Lngmin, f.Lngmax)
|
||||
}
|
||||
|
||||
if !f.Before.IsZero() {
|
||||
s = s.Where("photos.taken_at <= ?", f.Before.Format("2006-01-02"))
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue