Places: Refactor cluster view overlay and backend API #1187 #3657

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-09-19 22:03:40 +02:00
parent 405d56237e
commit a92653d4f2
17 changed files with 470 additions and 214 deletions

View file

@ -263,15 +263,15 @@ export default [
meta: { title: $gettext("Places"), auth: true },
},
{
name: "places_query",
path: "/places/:q",
name: "places_scope",
path: "/places/:s",
component: Places,
meta: { title: $gettext("Places"), auth: true },
},
{
name: "places_scope",
path: "/places/:s/:q",
component: Places,
name: "places_location",
path: "/location",
component: Photos,
meta: { title: $gettext("Places"), auth: true },
},
{

View file

@ -1,55 +1,68 @@
<template>
<v-form ref="form" lazy-validation
dense autocomplete="off" class="p-photo-toolbar" accept-charset="UTF-8"
:class="{'sticky': sticky}"
:class="{'embedded': embedded}"
@submit.prevent="updateQuery()">
<v-toolbar flat :dense="$vuetify.breakpoint.smAndDown" class="page-toolbar" color="secondary">
<v-text-field :value="filter.q"
class="input-search background-inherit elevation-0"
solo hide-details clearable overflow single-line
validate-on-blur
autocorrect="off"
autocapitalize="none"
browser-autocomplete="off"
:label="$gettext('Search')"
prepend-inner-icon="search"
color="secondary-dark"
@change="(v) => {updateFilter({'q': v})}"
@keyup.enter.native="(e) => updateQuery({'q': e.target.value})"
@click:clear="() => {updateQuery({'q': ''})}"
></v-text-field>
<v-toolbar flat :dense="$vuetify.breakpoint.smAndDown" :height="embedded ? 45 : undefined"
class="page-toolbar" color="secondary">
<template v-if="!embedded">
<v-text-field :value="filter.q"
class="input-search background-inherit elevation-0"
solo hide-details clearable overflow single-line validate-on-blur
autocorrect="off"
autocapitalize="none"
browser-autocomplete="off"
:label="$gettext('Search')"
prepend-inner-icon="search"
color="secondary-dark"
@change="(v) => {updateFilter({'q': v})}"
@keyup.enter.native="(e) => updateQuery({'q': e.target.value})"
@click:clear="() => {updateQuery({'q': ''})}"
></v-text-field>
<v-btn icon class="hidden-xs-only action-reload" :title="$gettext('Reload')" @click.stop="refresh()">
<v-icon>refresh</v-icon>
</v-btn>
<v-btn v-if="filter.latlng" icon class="action-clear-location" :title="$gettext('Clear Location')" @click.stop="clearLocation()">
<v-icon>location_off</v-icon>
</v-btn>
<v-btn v-if="settings.view === 'cards'" icon :title="$gettext('Toggle View')" @click.stop="setView('list')">
<v-icon>view_list</v-icon>
</v-btn>
<v-btn v-else-if="settings.view === 'list'" icon :title="$gettext('Toggle View')" @click.stop="setView('mosaic')">
<v-icon>view_comfy</v-icon>
</v-btn>
<v-btn v-else icon :title="$gettext('Toggle View')" @click.stop="setView('cards')">
<v-icon>view_column</v-icon>
</v-btn>
<v-btn icon class="hidden-xs-only action-reload" :title="$gettext('Reload')" @click.stop="refresh()">
<v-icon>refresh</v-icon>
</v-btn>
<v-btn v-if="canDelete && context === 'archive' && config.count.archived > 0" icon class="hidden-sm-and-down action-sweep"
:title="$gettext('Delete')" @click.stop="sweepArchive()">
<v-icon>delete_sweep</v-icon>
</v-btn>
<v-btn v-else-if="canUpload" icon class="hidden-sm-and-down action-upload"
:title="$gettext('Upload')" @click.stop="showUpload()">
<v-icon>cloud_upload</v-icon>
</v-btn>
<v-btn v-if="settings.view === 'cards'" icon :title="$gettext('Toggle View')" @click.stop="setView('list')">
<v-icon>view_list</v-icon>
</v-btn>
<v-btn v-else-if="settings.view === 'list'" icon :title="$gettext('Toggle View')"
@click.stop="setView('mosaic')">
<v-icon>view_comfy</v-icon>
</v-btn>
<v-btn v-else icon :title="$gettext('Toggle View')" @click.stop="setView('cards')">
<v-icon>view_column</v-icon>
</v-btn>
<v-btn icon class="p-expand-search" :title="$gettext('Expand Search')"
@click.stop="searchExpanded = !searchExpanded">
<v-icon>{{ searchExpanded ? 'keyboard_arrow_up' : 'keyboard_arrow_down' }}</v-icon>
</v-btn>
<v-btn v-if="canDelete && context === 'archive' && config.count.archived > 0" icon
class="hidden-sm-and-down action-sweep"
:title="$gettext('Delete')" @click.stop="sweepArchive()">
<v-icon>delete_sweep</v-icon>
</v-btn>
<v-btn v-else-if="canUpload" icon class="hidden-sm-and-down action-upload"
:title="$gettext('Upload')" @click.stop="showUpload()">
<v-icon>cloud_upload</v-icon>
</v-btn>
<v-btn v-if="onClose !== undefined" icon class="action-close" @click.stop="onClose">
<v-icon>close</v-icon>
</v-btn>
<v-btn icon class="p-expand-search" :title="$gettext('Expand Search')"
@click.stop="searchExpanded = !searchExpanded">
<v-icon>{{ searchExpanded ? 'keyboard_arrow_up' : 'keyboard_arrow_down' }}</v-icon>
</v-btn>
</template>
<template v-else>
<v-spacer></v-spacer>
<v-btn v-if="canAccessLibrary" icon class="action-open-tab" @click.stop="openInTab">
<v-icon size="19">open_in_new</v-icon>
</v-btn>
<v-btn v-if="onClose !== undefined" icon class="action-close" @click.stop="onClose">
<v-icon>close</v-icon>
</v-btn>
</template>
</v-toolbar>
<v-card v-show="searchExpanded"
@ -197,29 +210,39 @@ export default {
},
filter: {
type: Object,
default: () => {},
default: () => {
},
},
staticFilter: {
type: Object,
default: () => {
},
},
updateFilter: {
type: Function,
default: () => {},
default: () => {
},
},
updateQuery: {
type: Function,
default: () => {},
default: () => {
},
},
settings: {
type: Object,
default: () => {},
default: () => {
},
},
refresh: {
type: Function,
default: () => {},
default: () => {
},
},
onClose: {
type: Function,
default: undefined,
},
sticky: {
embedded: {
type: Boolean,
default: false
},
@ -232,8 +255,9 @@ export default {
isFullScreen: !!document.fullscreenElement,
config: this.$config.values,
readonly: readonly,
canUpload: !readonly && this.$config.allow("files", "upload") && features.upload,
canDelete: !readonly && this.$config.allow("photos", "delete") && features.delete,
canUpload: !readonly && !this.embedded && this.$config.allow("files", "upload") && features.upload,
canDelete: !readonly && !this.embedded && this.$config.allow("photos", "delete") && features.delete,
canAccessLibrary: this.$config.allow("photos", "access_library"),
searchExpanded: false,
all: {
countries: [{ID: "", Name: this.$gettext("All Countries")}],
@ -306,6 +330,15 @@ export default {
this.dialog.delete = true;
},
clearLocation() {
this.$router.push({ name: "browse" });
},
openInTab() {
const url = this.$router.resolve({name: 'places_location', query: this.staticFilter}).href;
if (url) {
window.open(url, '_blank');
}
},
batchDelete() {
if (!this.canDelete) {
return;

View file

@ -20,7 +20,7 @@
#photoprism #map .marker {
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12) !important;
background-color: rgba(0, 0, 0, 0.3);
background-color: rgba(23, 23, 23, 0.23);
color: rgba(0, 0, 0, 0.87);
display: block;
border-radius: 50%;
@ -33,7 +33,7 @@
grid-template-columns: 1fr 1fr;
grid-gap: 1px;
overflow: hidden;
background-color: #ffffff99;
/* background-color: #ffffff99; */
width: 100%;
height: 100%
}
@ -97,4 +97,52 @@
background-position: center;
background-repeat: no-repeat;
background-size: 70%;
}
#photoprism .cluster-control {
background: #2f3031;
position: absolute;
top: auto;
z-index: 2;
bottom: 0;
margin: 0;
padding: 0;
left: 4px;
right: 4px;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
max-height: 90vh;
overflow: hidden;
box-shadow: rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;
transition: all 1450ms cubic-bezier(0.32,1,0.23,1) 0ms;
}
#photoprism .cluster-control-container {
min-height: 40vh;
}
#photoprism .cluster-control .p-photos {
position: absolute;
top: 45px;
bottom: 0;
left: 0;
right: 0;
overflow: auto;
}
@media only screen and (min-height: 1500px) {
#photoprism .cluster-control-container {
min-height: 45vh;
}
}
@media (orientation: portrait) {
#photoprism .cluster-control {
left: 2px;
right: 2px;
}
#photoprism .cluster-control-container {
min-height: 70vh;
}
}

View file

@ -122,7 +122,7 @@ nav .v-list__tile__title.title {
overflow: hidden;
}
#photoprism .p-photo-toolbar.sticky {
#photoprism .p-photo-toolbar.embedded {
position: sticky;
top: 0;
z-index: 1;

View file

@ -196,9 +196,9 @@ export default {
}
if (this.canAccessLibrary && photo.CellID && photo.CellID !== "zz") {
this.$router.push({name: "places_query", params: {q: photo.CellID}});
this.$router.push({name: "places", query: {q: photo.CellID}});
} else if (this.uid) {
this.$router.push({name: "places_scope", params: {s: this.uid, q: photo.CellID}});
this.$router.push({name: "places_scope", params: {s: this.uid}, query: {q: photo.CellID}});
}
},
editPhoto(index) {

View file

@ -6,12 +6,13 @@
<p-photo-toolbar
:context="context"
:filter="filter"
:static-filter="staticFilter"
:settings="settings"
:refresh="refresh"
:update-filter="updateFilter"
:update-query="updateQuery"
:on-close="onClose"
:sticky="stickyToolbar"
:embedded="embedded"
/>
<v-container v-if="loading" fluid class="pa-4">
@ -70,7 +71,7 @@ export default {
type: Function,
default: undefined,
},
stickyToolbar: {
embedded: {
type: Boolean,
default: false
},
@ -87,12 +88,14 @@ export default {
const month = query['month'] ? parseInt(query['month']) : 0;
const color = query['color'] ? query['color'] : '';
const label = query['label'] ? query['label'] : '';
const latlng = query['latlng'] ? query['latlng'] : '';
const view = this.viewType();
const filter = {
country: country,
camera: camera,
lens: lens,
label: label,
latlng: latlng,
year: year,
month: month,
color: color,
@ -192,6 +195,7 @@ export default {
this.filter.month = query['month'] ? parseInt(query['month']) : 0;
this.filter.color = query['color'] ? query['color'] : '';
this.filter.label = query['label'] ? query['label'] : '';
this.filter.latlng = query['latlng'] ? query['latlng'] : '';
this.filter.order = this.sortOrder();
this.settings.view = this.viewType();
@ -253,8 +257,12 @@ export default {
window.localStorage.setItem("photos_offset", offset);
},
viewType() {
let queryParam = this.$route.query['view'] ? this.$route.query['view'] : "";
let storedType = window.localStorage.getItem("photos_view");
if (this.embedded) {
return 'mosaic';
}
let queryParam = this.$route.query['view'] ? this.$route.query['view'] : '';
let storedType = window.localStorage.getItem('photos_view');
if (queryParam) {
window.localStorage.setItem("photos_view", queryParam);
@ -268,17 +276,21 @@ export default {
return 'cards';
},
sortOrder() {
let queryParam = this.$route.query["order"];
let storedType = window.localStorage.getItem("photos_order");
if (this.embedded) {
return 'newest';
}
let queryParam = this.$route.query['order'];
let storedType = window.localStorage.getItem('photos_order');
if (queryParam) {
window.localStorage.setItem("photos_order", queryParam);
window.localStorage.setItem('photos_order', queryParam);
return queryParam;
} else if (storedType) {
return storedType;
}
return "newest";
return 'newest';
},
openLocation(index) {
if (!this.hasPlaces || !this.canSearchPlaces) {
@ -292,9 +304,9 @@ export default {
}
if (photo.CellID && photo.CellID !== "zz") {
this.$router.push({name: "places_query", params: {q: photo.CellID}});
this.$router.push({name: "places", query: {q: photo.CellID}});
} else if (photo.Country && photo.Country !== "zz") {
this.$router.push({name: "places_query", params: {q: "country:" + photo.Country}});
this.$router.push({name: "places", query: {q: "country:" + photo.Country}});
} else {
this.$notify.warn("unknown location");
}
@ -384,7 +396,7 @@ export default {
if (this.complete) {
this.setOffset(response.offset);
if (this.results.length > 1) {
if (!this.embedded && this.results.length > 1) {
this.$notify.info(this.$gettextInterpolate(this.$gettext("%{n} pictures found"), {n: this.results.length}));
}
} else if (this.results.length >= Photo.limit()) {
@ -547,9 +559,9 @@ export default {
if (this.complete) {
if (!this.results.length) {
this.$notify.warn(this.$gettext("No pictures found"));
} else if (this.results.length === 1) {
} else if (!this.embedded && this.results.length === 1) {
this.$notify.info(this.$gettext("One picture found"));
} else {
} else if (!this.embedded) {
this.$notify.info(this.$gettextInterpolate(this.$gettext("%{n} pictures found"), {n: this.results.length}));
}
} else {

View file

@ -18,17 +18,17 @@
</div>
</div>
<div id="map" ref="map" style="width: 100%; height: 100%;"></div>
<div v-if="showCluster" class="map-control cluster-control">
<v-card class="cluster-control-container">
<p-page-photos
ref="cluster"
:static-filter="cluster"
:on-close="closeCluster"
:embedded="true"
/>
</v-card>
</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>
@ -75,57 +75,25 @@ export default {
result: {},
filter: {q: this.query(), s: this.scope()},
lastFilter: {},
cluster: {},
showCluster: false,
config: this.$config.values,
settings: settings,
animate: settings.animate,
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.initialized = false;
this.search();
},
showClusterPictures: function (newValue, old) {
if (!newValue) {
this.unselectCluster();
}
}
},
mounted() {
this.initMap().then(() => this.renderMap());
this.updateSelectedClusterFromUrl();
this.openClusterFromUrl();
},
methods: {
initMap() {
@ -341,61 +309,71 @@ export default {
this.filter = filter;
this.options = mapOptions;
},
getSelectedClusterFromUrl() {
const clusterIsSelected = this.$route.query.selectedCluster !== undefined
&& this.$route.query.selectedCluster !== '';
if (!clusterIsSelected) {
getClusterFromUrl() {
const hasLatLng = this.$route.query.latlng !== undefined && this.$route.query.latlng !== '';
if (!hasLatLng) {
return undefined;
}
const [latmin, latmax, lngmin, lngmax] = this.$route.query.selectedCluster.split(',');
return {latmin, latmax, lngmin, lngmax};
return {
q: this.filter.q,
s: this.filter.s,
latlng: this.$route.query.latlng,
};
},
updateSelectedClusterFromUrl: function () {
this.selectedClusterBounds = this.getSelectedClusterFromUrl();
this.showClusterPictures = this.selectedClusterBounds !== undefined;
openCluster: function (cluster) {
this.cluster = cluster;
this.showCluster = true;
},
selectClusterByCoords: function (latMin, latMax, lngMin, lngMax) {
this.$router.push({
query: {
selectedCluster: [latMin, latMax, lngMin, lngMax].join(','),
},
params: this.filter,
openClusterFromUrl: function () {
const cluster = this.getClusterFromUrl();
if (!cluster) {
return;
}
this.openCluster(cluster);
},
selectClusterByCoords: function (latNorth, lngEast, latSouth, lngWest) {
this.openCluster({
q: this.filter.q,
s: this.filter.s,
latlng: [latNorth, lngEast, latSouth, lngWest].join(','),
});
},
selectClusterById: function (clusterId) {
if(this.showCluster) {
this.showCluster = false;
}
this.getClusterFeatures(clusterId, -1, (clusterFeatures) => {
let latMin, latMax, lngMin, lngMax;
let latNorth, lngEast, latSouth, lngWest;
for (const feature of clusterFeatures) {
const [lng, lat] = feature.geometry.coordinates;
if (latMin === undefined || lat < latMin) {
latMin = lat;
if (latNorth === undefined || lat < latNorth) {
latNorth = lat;
}
if (latMax === undefined || lat > latMax) {
latMax = lat;
if (lngEast === undefined || lng < lngEast) {
lngEast = lng;
}
if (lngMin === undefined || lng < lngMin) {
lngMin = lng;
if (latSouth === undefined || lat > latSouth) {
latSouth = lat;
}
if (lngMax === undefined || lng > lngMax) {
lngMax = lng;
if (lngWest === undefined || lng > lngWest) {
lngWest = lng;
}
}
this.selectClusterByCoords(latMin, latMax, lngMin, lngMax);
this.selectClusterByCoords(latNorth, lngEast, latSouth, lngWest);
});
},
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);
}
closeCluster: function () {
this.cluster = {};
this.showCluster = false;
},
query: function () {
return this.$route.params.q ? this.$route.params.q : '';
return this.$route.query.q ? this.$route.query.q : '';
},
scope: function () {
return this.$route.params.s ? this.$route.params.s : '';
@ -437,11 +415,21 @@ export default {
if (this.loading) {
return;
}
this.search();
this.$router.push({
query: {
q: this.filter.q,
},
});
},
clearQuery() {
this.filter.q = '';
this.search();
if (this.loading) {
return;
}
this.$router.push({
query: {},
});
},
updateQuery() {
if (this.loading) {
@ -450,9 +438,9 @@ export default {
if (this.query() !== this.filter.q) {
if (this.filter.s) {
this.$router.replace({name: "places_scope", params: {s: this.filter.s, q: this.filter.q}});
this.$router.replace({name: "places_scope", params: {s: this.filter.s}, query: {q: this.filter.q}});
} else if (this.filter.q) {
this.$router.replace({name: "places_query", params: {q: this.filter.q}});
this.$router.replace({name: "places", query: {q: this.filter.q}});
} else {
this.$router.replace({name: "places"});
}
@ -477,10 +465,12 @@ export default {
return;
}
// Don't query the same data more than once
if (JSON.stringify(this.lastFilter) === JSON.stringify(this.filter)) return;
// Do not query the same data more than once unless search results need to be updated.
if (this.initialized && JSON.stringify(this.lastFilter) === JSON.stringify(this.filter)) return;
this.loading = true;
this.closeCluster();
Object.assign(this.lastFilter, this.filter);
this.updateQuery();
@ -614,7 +604,10 @@ export default {
// Is it a cluster?
if (props.cluster) {
// Update cluster marker.
let id = -1*props.cluster_id;
// Attention: Do not confuse with photo feature IDs.
// Clusters have their own ID number range!
let id = -1 * props.cluster_id;
let marker = this.markers[id];
@ -636,7 +629,7 @@ export default {
const previewImageCount = clusterFeatures.length >= 4 ? 4 : clusterFeatures.length > 1 ? 2 : 1;
const images = Array(previewImageCount)
.fill(null)
.map((a,i) => {
.map((a, i) => {
const feature = clusterFeatures[Math.floor(clusterFeatures.length * i / previewImageCount)];
const image = document.createElement('div');
image.style.backgroundImage = `url(${this.$config.contentUri}/t/${feature.properties.Hash}/${token}/tile_${50})`;
@ -724,6 +717,11 @@ export default {
type: 'circle',
source: 'photos',
filter: ['has', 'point_count'],
paint: {
'circle-color': '#FFFFFF',
'circle-opacity': 0,
'circle-radius': 0,
},
});
// Example of dynamic map cluster rendering:

View file

@ -43,13 +43,12 @@ type SearchPhotos struct {
Private bool `form:"private" notes:"Finds private pictures"`
Favorite string `form:"favorite" example:"favorite:yes" notes:"Finds favorites only"`
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"`
Lat float32 `form:"lat" notes:"GPS Position (Latitude)"`
Lng float32 `form:"lng" notes:"GPS Position (Longitude)"`
Dist uint `form:"dist" example:"dist:5" notes:"Distance to GPS Position (km)"`
LatLng string `form:"latlng" notes:"GPS Bounding Box (Lat N, Lng E, Lat S, Lng W)"`
S2 string `form:"s2" notes:"S2 Position (Cell ID)"`
OLC string `form:"olc" notes:"Open Location Code (OLC)"`
Fmin float32 `form:"fmin" notes:"F-number (min)"`
Fmax float32 `form:"fmax" notes:"F-number (max)"`
Chroma int16 `form:"chroma" example:"chroma:70" notes:"Chroma (0-100)"`

View file

@ -42,11 +42,12 @@ type SearchPhotosGeo struct {
Face string `form:"face" notes:"Face ID, yes, no, new, or kind"`
Faces string `form:"faces"` // Find or exclude faces if detected.
Subject string `form:"subject"`
Lat float32 `form:"lat"`
Lng float32 `form:"lng"`
S2 string `form:"s2"`
Olc string `form:"olc"`
Dist uint `form:"dist"`
Lat float32 `form:"lat" notes:"GPS Position (Latitude)"`
Lng float32 `form:"lng" notes:"GPS Position (Longitude)"`
Dist uint `form:"dist" example:"dist:5" notes:"Distance to GPS Position (km)"`
LatLng string `form:"latlng" notes:"GPS Bounding Box (Lat N, Lng E, Lat S, Lng W)"`
S2 string `form:"s2" notes:"S2 Position (Cell ID)"`
OLC string `form:"olc" notes:"Open Location Code (OLC)"`
Person string `form:"person"` // Alias for Subject
Subjects string `form:"subjects"` // Text
People string `form:"people"` // Alias for Subjects

View file

@ -13,8 +13,11 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/pluscode"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/s2"
"github.com/photoprism/photoprism/pkg/sortby"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -55,6 +58,9 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string)
return PhotoResults{}, 0, ErrBadRequest
}
// Size of S2 Cells.
S2Levels := 7
// Specify table names and joins.
s := UnscopedDb().Table(entity.File{}.TableName()).Select(resultCols).
Joins("JOIN photos ON files.photo_id = photos.id AND files.media_id IS NOT NULL").
@ -622,35 +628,46 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string)
s = s.Where("photos.photo_f_number <= ?", f.Fmax)
}
// Filter by location.
if f.S2 != "" {
// S2 Cell ID.
s2Min, s2Max := s2.PrefixedRange(f.S2, S2Levels)
s = s.Where("photos.cell_id BETWEEN ? AND ?", s2Min, s2Max)
} else if f.OLC != "" {
// Open Location Code (OLC).
s2Min, s2Max := s2.PrefixedRange(pluscode.S2(f.OLC), S2Levels)
s = s.Where("photos.cell_id BETWEEN ? AND ?", s2Min, s2Max)
} else if latNorth, lngEast, latSouth, lngWest, parseErr := clean.GPSBounds(f.LatLng); parseErr == nil {
// GPS Bounds (Lat N, Lng E, Lat S, Lng W).
s = s.Where("photos.photo_lat BETWEEN ? AND ?", latNorth, latSouth)
s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngEast, lngWest)
}
// Filter by approx distance to coordinates.
if f.Dist == 0 {
f.Dist = 20
} else if f.Dist > 5000 {
f.Dist = 5000
}
// Filter by approx distance to co-ordinates:
if f.Lat != 0 {
latMin := f.Lat - Radius*float32(f.Dist)
latMax := f.Lat + Radius*float32(f.Dist)
s = s.Where("photos.photo_lat BETWEEN ? AND ?", latMin, latMax)
latNorth := f.Lat - Radius*float32(f.Dist)
latSouth := f.Lat + Radius*float32(f.Dist)
s = s.Where("photos.photo_lat BETWEEN ? AND ?", latNorth, latSouth)
}
if f.Lng != 0 {
lngMin := f.Lng - Radius*float32(f.Dist)
lngMax := f.Lng + Radius*float32(f.Dist)
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)
lngEast := f.Lng - Radius*float32(f.Dist)
lngWest := f.Lng + Radius*float32(f.Dist)
s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngEast, lngWest)
}
// Find photos taken before date.
if !f.Before.IsZero() {
s = s.Where("photos.taken_at <= ?", f.Before.Format("2006-01-02"))
}
// Find photos taken after date.
if !f.After.IsZero() {
s = s.Where("photos.taken_at >= ?", f.After.Format("2006-01-02"))
}

View file

@ -13,6 +13,7 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/pluscode"
"github.com/photoprism/photoprism/pkg/rnd"
@ -38,6 +39,7 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes
return GeoResults{}, ErrBadRequest
}
// Size of S2 Cells.
S2Levels := 7
// Search for nearby photos.
@ -50,8 +52,6 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes
}
f.S2 = photo.CellID
f.Lat = photo.PhotoLat
f.Lng = photo.PhotoLng
S2Levels = 12
}
@ -509,24 +509,38 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes
s = s.Where("files.file_chroma > 0 AND files.file_chroma <= ?", f.Chroma)
}
// Filter by location.
if f.S2 != "" {
// S2 Cell ID.
s2Min, s2Max := s2.PrefixedRange(f.S2, S2Levels)
s = s.Where("photos.cell_id BETWEEN ? AND ?", s2Min, s2Max)
} else if f.Olc != "" {
s2Min, s2Max := s2.PrefixedRange(pluscode.S2(f.Olc), S2Levels)
} else if f.OLC != "" {
// Open Location Code (OLC).
s2Min, s2Max := s2.PrefixedRange(pluscode.S2(f.OLC), S2Levels)
s = s.Where("photos.cell_id BETWEEN ? AND ?", s2Min, s2Max)
} else {
// Filter by approx distance to coordinate:
if f.Lat != 0 {
latMin := f.Lat - Radius*float32(f.Dist)
latMax := f.Lat + Radius*float32(f.Dist)
s = s.Where("photos.photo_lat BETWEEN ? AND ?", latMin, latMax)
}
if f.Lng != 0 {
lngMin := f.Lng - Radius*float32(f.Dist)
lngMax := f.Lng + Radius*float32(f.Dist)
s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngMin, lngMax)
}
} else if latNorth, lngEast, latSouth, lngWest, parseErr := clean.GPSBounds(f.LatLng); parseErr == nil {
// GPS Bounds (Lat N, Lng E, Lat S, Lng W).
s = s.Where("photos.photo_lat BETWEEN ? AND ?", latNorth, latSouth)
s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngEast, lngWest)
}
// Filter by approx distance to coordinates.
if f.Dist == 0 {
f.Dist = 20
} else if f.Dist > 5000 {
f.Dist = 5000
}
if f.Lat != 0 {
latNorth := f.Lat - Radius*float32(f.Dist)
latSouth := f.Lat + Radius*float32(f.Dist)
s = s.Where("photos.photo_lat BETWEEN ? AND ?", latNorth, latSouth)
}
if f.Lng != 0 {
lngEast := f.Lng - Radius*float32(f.Dist)
lngWest := f.Lng + Radius*float32(f.Dist)
s = s.Where("photos.photo_lng BETWEEN ? AND ?", lngEast, lngWest)
}
// Find photos taken before date.

View file

@ -258,6 +258,7 @@ func TestPhotosGeoQueryNear(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), 8)
})
t.Run("pr2xu7myk7wrbk30", func(t *testing.T) {

View file

@ -130,7 +130,7 @@ func TestGeo(t *testing.T) {
Lat: 1.234,
Lng: 4.321,
S2: "",
Olc: "",
OLC: "",
Dist: 0,
Quality: 0,
Review: true,
@ -158,7 +158,7 @@ func TestGeo(t *testing.T) {
Lat: 0,
Lng: 0,
S2: "",
Olc: "",
OLC: "",
Dist: 0,
Quality: 3,
Review: false,
@ -181,7 +181,7 @@ func TestGeo(t *testing.T) {
Lat: 0,
Lng: 0,
S2: "85",
Olc: "",
OLC: "",
Dist: 0,
Quality: 0,
Review: false,
@ -195,7 +195,7 @@ func TestGeo(t *testing.T) {
assert.Empty(t, result)
assert.IsType(t, GeoResults{}, result)
})
t.Run("search for Olc", func(t *testing.T) {
t.Run("search for OLC", func(t *testing.T) {
f := form.SearchPhotosGeo{
Query: "",
Before: time.Time{},
@ -204,7 +204,7 @@ func TestGeo(t *testing.T) {
Lat: 0,
Lng: 0,
S2: "",
Olc: "9",
OLC: "9",
Dist: 0,
Quality: 0,
Review: false,

View file

@ -667,9 +667,9 @@ func TestPhotos(t *testing.T) {
assert.LessOrEqual(t, 2, len(photos))
})
t.Run("Latmin:33.45343166666667 Latmax:49.519234", func(t *testing.T) {
t.Run("LatLng:33.453431,-180.0,49.519234,180.0", func(t *testing.T) {
var f form.SearchPhotos
f.Query = "Latmin:33.45343166666667 Latmax:49.519234"
f.Query = "LatLng:33.453431,-180.0,49.519234,180.0"
f.Count = 10
f.Offset = 0
f.Order = "imported"
@ -688,9 +688,9 @@ func TestPhotos(t *testing.T) {
assert.LessOrEqual(t, 2, len(photos))
})
t.Run("Latmin:0.00 Latmax:49.519234 Lngmin:-30.123 Lngmax:9.1001234", func(t *testing.T) {
t.Run("LatLng:0.00,-30.123.0,49.519234,9.1001234", func(t *testing.T) {
var f form.SearchPhotos
f.Query = "Latmin:0.00 Latmax:49.519234 Lngmin:-30.123 Lngmax:9.1001234"
f.Query = "LatLng:0.00,-30.123.0,49.519234,9.1001234"
f.Count = 10
f.Offset = 0
f.Order = "imported"

62
pkg/clean/gps.go Normal file
View file

@ -0,0 +1,62 @@
package clean
import (
"fmt"
"strings"
"github.com/photoprism/photoprism/pkg/txt"
)
// GPSBounds parses the GPS bounds (Lat N, Lng E, Lat S, Lng W) and returns the coordinates if any.
func GPSBounds(bounds string) (latNorth, lngEast, latSouth, lngWest float32, err error) {
if len(bounds) < 7 {
return 0, 0, 0, 0, fmt.Errorf("no coordinates found")
}
values := strings.SplitN(bounds, ",", 5)
found := len(values)
// Invalid number of values?
if found != 4 {
return 0, 0, 0, 0, fmt.Errorf("invalid number of coordinates")
}
// Parse floating point coordinates.
latNorth, lngEast, latSouth, lngWest = txt.Float32(values[0]), txt.Float32(values[1]), txt.Float32(values[2]), txt.Float32(values[3])
// Latitudes (from +90 to -90 degrees).
if latNorth > 90 {
latNorth = 90
} else if latNorth < -90 {
latNorth = -90
}
if latSouth > 90 {
latSouth = 90
} else if latSouth < -90 {
latSouth = -90
}
if latNorth > latSouth {
latNorth, latSouth = latSouth, latNorth
}
// Longitudes (from -180 to 180 degrees).
if lngEast > 180 {
lngEast = 180
} else if lngEast < -180 {
lngEast = -180
}
if lngWest > 180 {
lngWest = 180
} else if lngWest < -180 {
lngWest = -180
}
if lngEast > lngWest {
lngEast, lngWest = lngWest, lngEast
}
return latNorth, lngEast, latSouth, lngWest, nil
}

66
pkg/clean/gps_test.go Normal file
View file

@ -0,0 +1,66 @@
package clean
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGPSBounds(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("41.87760543823242,-87.62521362304688,41.89404296875,-87.6215591430664")
assert.Equal(t, float32(41.87760543823242), latNorth)
assert.Equal(t, float32(-87.62521362304688), lngEast)
assert.Equal(t, float32(41.89404296875), latSouth)
assert.Equal(t, float32(-87.6215591430664), lngWest)
assert.NoError(t, err)
})
t.Run("FlippedLat", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("41.89404296875,-87.62521362304688,41.87760543823242,-87.6215591430664")
assert.Equal(t, float32(41.87760543823242), latNorth)
assert.Equal(t, float32(-87.62521362304688), lngEast)
assert.Equal(t, float32(41.89404296875), latSouth)
assert.Equal(t, float32(-87.6215591430664), lngWest)
assert.NoError(t, err)
})
t.Run("FlippedLng", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("41.87760543823242,-87.6215591430664,41.89404296875,-87.62521362304688")
assert.Equal(t, float32(41.87760543823242), latNorth)
assert.Equal(t, float32(-87.62521362304688), lngEast)
assert.Equal(t, float32(41.89404296875), latSouth)
assert.Equal(t, float32(-87.6215591430664), lngWest)
assert.NoError(t, err)
})
t.Run("Empty", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("")
assert.Equal(t, float32(0), latNorth)
assert.Equal(t, float32(0), lngEast)
assert.Equal(t, float32(0), latSouth)
assert.Equal(t, float32(0), lngWest)
assert.Error(t, err)
})
t.Run("One", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("41.87760543823242")
assert.Equal(t, float32(0), latNorth)
assert.Equal(t, float32(0), lngEast)
assert.Equal(t, float32(0), latSouth)
assert.Equal(t, float32(0), lngWest)
assert.Error(t, err)
})
t.Run("Three", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("41.87760543823242,-87.62521362304688,41.89404296875")
assert.Equal(t, float32(0), latNorth)
assert.Equal(t, float32(0), lngEast)
assert.Equal(t, float32(0), latSouth)
assert.Equal(t, float32(0), lngWest)
assert.Error(t, err)
})
t.Run("Five", func(t *testing.T) {
latNorth, lngEast, latSouth, lngWest, err := GPSBounds("41.87760543823242,-87.62521362304688,41.89404296875,-87.6215591430664,41.89404296875")
assert.Equal(t, float32(0), latNorth)
assert.Equal(t, float32(0), lngEast)
assert.Equal(t, float32(0), latSouth)
assert.Equal(t, float32(0), lngWest)
assert.Error(t, err)
})
}

View file

@ -65,6 +65,11 @@ func Float(s string) float64 {
return f
}
// Float32 converts a string to a 32-bit floating point number or 0 if invalid.
func Float32(s string) float32 {
return float32(Float(s))
}
// Int64 converts a string to a signed 64-bit integer or 0 if invalid.
func Int64(s string) int64 {
if s == "" {