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:
Heiko Mathes 2023-07-24 10:12:22 +02:00 committed by GitHub
parent ea8ee0938c
commit 60d280430e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 292 additions and 85 deletions

View file

@ -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;

View file

@ -17,10 +17,43 @@
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 {
color: #333 !important;
}
}

View file

@ -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;
@ -307,4 +313,4 @@ nav .v-list__tile__title.title {
#photoprism #mobile-menu .menu-action:hover a i{
fill: #ffffff;
color: #ffffff;
}
}

View file

@ -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;

View file

@ -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();
},

View file

@ -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)"`

View file

@ -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"))
}