Initial code for new Places UI
Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
parent
bba914878a
commit
c31470dafb
28 changed files with 3305 additions and 1171 deletions
3002
frontend/package-lock.json
generated
3002
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -17,13 +17,13 @@
|
|||
"gettext-compile": "gettext-compile --output src/resources/translations.json src/resources/*.po"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/cli": "^7.7.7",
|
||||
"@babel/core": "^7.7.7",
|
||||
"@babel/plugin-transform-runtime": "^7.7.6",
|
||||
"@babel/polyfill": "^7.7.0",
|
||||
"@babel/preset-env": "^7.7.7",
|
||||
"@babel/register": "^7.7.7",
|
||||
"@babel/runtime": "^7.7.7",
|
||||
"@babel/cli": "^7.8.3",
|
||||
"@babel/core": "^7.8.3",
|
||||
"@babel/plugin-transform-runtime": "^7.8.3",
|
||||
"@babel/polyfill": "^7.8.3",
|
||||
"@babel/preset-env": "^7.8.3",
|
||||
"@babel/register": "^7.8.3",
|
||||
"@babel/runtime": "^7.8.3",
|
||||
"@fortawesome/fontawesome-free": "^5.12.0",
|
||||
"@types/leaflet": "^1.5.7",
|
||||
"acorn": "^6.4.0",
|
||||
|
@ -42,9 +42,9 @@
|
|||
"clean-webpack-plugin": "^3.0.0",
|
||||
"connect-history-api-fallback": "^1.3.0",
|
||||
"copy-webpack-plugin": "^5.1.1",
|
||||
"core-js": "^3.6.2",
|
||||
"core-js": "^3.6.4",
|
||||
"cross-env": "^5.2.1",
|
||||
"crypto-random-string": "^3.0.1",
|
||||
"crypto-random-string": "^3.1.0",
|
||||
"css-loader": "^2.1.1",
|
||||
"cssnano": "^4.1.10",
|
||||
"easygettext": "^2.9.0",
|
||||
|
@ -54,7 +54,7 @@
|
|||
"eslint-friendly-formatter": "^4.0.1",
|
||||
"eslint-loader": "^3.0.3",
|
||||
"eslint-plugin-html": "^6.0.0",
|
||||
"eslint-plugin-import": "^2.19.1",
|
||||
"eslint-plugin-import": "^2.20.0",
|
||||
"eslint-plugin-node": "^10.0.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-standard": "^4.0.1",
|
||||
|
@ -72,8 +72,8 @@
|
|||
"karma-mocha": "^1.3.0",
|
||||
"karma-verbose-reporter": "^0.0.6",
|
||||
"karma-webpack": "^4.0.2",
|
||||
"leaflet": "^1.6.0",
|
||||
"luxon": "^1.21.3",
|
||||
"mapbox-gl": "^1.6.1",
|
||||
"material-design-icons-iconfont": "^5.0.1",
|
||||
"mini-css-extract-plugin": "^0.7.0",
|
||||
"mocha": "^6.2.2",
|
||||
|
@ -108,12 +108,11 @@
|
|||
"vue-infinite-scroll": "^2.0.2",
|
||||
"vue-loader": "^14.2.4",
|
||||
"vue-luxon": "^0.7.0",
|
||||
"vue-router": "^3.1.3",
|
||||
"vue-router": "^3.1.4",
|
||||
"vue-style-loader": "^4.1.2",
|
||||
"vue-template-compiler": "^2.6.11",
|
||||
"vue2-filters": "^0.9.1",
|
||||
"vue2-leaflet": "^2.4.2",
|
||||
"vuelidate": "^0.7.4",
|
||||
"vuelidate": "^0.7.5",
|
||||
"vuetify": "^1.5.22",
|
||||
"webpack": "^4.41.5",
|
||||
"webpack-bundle-analyzer": "^3.6.0",
|
||||
|
|
|
@ -9,7 +9,6 @@ import Dialogs from "dialog/dialogs";
|
|||
import Event from "pubsub-js";
|
||||
import GetTextPlugin from "vue-gettext";
|
||||
import Log from "common/log";
|
||||
import Maps from "maps/components";
|
||||
import PhotoPrism from "photoprism.vue";
|
||||
import Router from "vue-router";
|
||||
import Routes from "routes";
|
||||
|
@ -55,7 +54,6 @@ Vue.use(VueFullscreen);
|
|||
Vue.use(VueFilters);
|
||||
Vue.use(Components);
|
||||
Vue.use(Dialogs);
|
||||
Vue.use(Maps);
|
||||
Vue.use(Router);
|
||||
|
||||
// Configure client-side routing
|
||||
|
|
|
@ -166,7 +166,7 @@
|
|||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile to="/places" @click="" class="p-navigation-places">
|
||||
<v-list-tile :to="{ name: 'places' }" @click="" class="p-navigation-places">
|
||||
<v-list-tile-action>
|
||||
<v-icon>place</v-icon>
|
||||
</v-list-tile-action>
|
||||
|
|
|
@ -72,7 +72,7 @@
|
|||
{{ photo.getCamera() }}
|
||||
<br/>
|
||||
<v-icon size="14">location_on</v-icon>
|
||||
<span class="p-pointer" :title="photo.LocationID"
|
||||
<span class="p-pointer"
|
||||
@click.stop="openLocation(index)">{{ photo.getLocation() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
@import url("../../node_modules/material-design-icons-iconfont/dist/material-design-icons.css");
|
||||
@import url("../../node_modules/vuetify/dist/vuetify.min.css");
|
||||
@import url("../../node_modules/leaflet/dist/leaflet.css");
|
||||
@import url("../../node_modules/mapbox-gl/dist/mapbox-gl.css");
|
||||
@import url("colorchange.css");
|
||||
@import url("maps.css");
|
||||
@import url("viewer.css");
|
||||
|
|
|
@ -1,4 +1,12 @@
|
|||
#photoprism div.leaflet-container .leaflet-marker-photo {
|
||||
#photoprism .p-map-control {
|
||||
position: fixed;
|
||||
background: transparent;
|
||||
bottom: 35px;
|
||||
right: 30px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#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);
|
||||
border-color: #fff;
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
import {LMap, LTileLayer, LMarker, LControl} from "vue2-leaflet";
|
||||
import {Icon} from "leaflet";
|
||||
|
||||
const components = {};
|
||||
|
||||
components.install = (Vue) => {
|
||||
Vue.component("l-map", LMap);
|
||||
Vue.component("l-tile-layer", LTileLayer);
|
||||
Vue.component("l-marker", LMarker);
|
||||
Vue.component("l-control", LControl);
|
||||
|
||||
delete Icon.Default.prototype._getIconUrl;
|
||||
|
||||
Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: require("./marker/marker-icon-2x-red.png"),
|
||||
iconUrl: require("./marker/marker-icon-red.png"),
|
||||
shadowUrl: require("./marker/marker-shadow.png"),
|
||||
});
|
||||
};
|
||||
|
||||
export default components;
|
|
@ -115,10 +115,10 @@
|
|||
openLocation(index) {
|
||||
const photo = this.results[index];
|
||||
|
||||
if (photo.PhotoLat && photo.PhotoLng) {
|
||||
this.$router.push({name: "places", query: {lat: String(photo.PhotoLat), lng: String(photo.PhotoLng)}});
|
||||
} else if (photo.LocCity) {
|
||||
this.$router.push({name: "places", query: {q: photo.LocCity}});
|
||||
if (photo.LocationID) {
|
||||
this.$router.push({name: "place", params: {q: "s2:" + photo.LocationID}});
|
||||
} else if (photo.PlaceID && photo.PlaceID !== "-") {
|
||||
this.$router.push({name: "place", params: {q: "s2:" + photo.PlaceID}});
|
||||
}
|
||||
},
|
||||
openPhoto(index) {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
:refresh="refresh"></p-photo-search>
|
||||
|
||||
<v-container fluid class="pa-4" v-if="loading">
|
||||
<v-progress-linear color="secondary-dark" :indeterminate="true"></v-progress-linear>
|
||||
<v-progress-linear color="secondary-dark" :indeterminate="true"></v-progress-linear>
|
||||
</v-container>
|
||||
<v-container fluid class="pa-0" v-else>
|
||||
<p-scroll-top></p-scroll-top>
|
||||
|
@ -97,11 +97,11 @@
|
|||
},
|
||||
computed: {
|
||||
context: function () {
|
||||
if(!this.staticFilter) {
|
||||
if (!this.staticFilter) {
|
||||
return "photos"
|
||||
}
|
||||
|
||||
if(this.staticFilter.archived) {
|
||||
if (this.staticFilter.archived) {
|
||||
return "archive"
|
||||
} else if (this.staticFilter.favorites) {
|
||||
return "favorites"
|
||||
|
@ -129,10 +129,10 @@
|
|||
openLocation(index) {
|
||||
const photo = this.results[index];
|
||||
|
||||
if (photo.PhotoLat && photo.PhotoLng) {
|
||||
this.$router.push({name: "places", query: {lat: String(photo.PhotoLat), lng: String(photo.PhotoLng)}});
|
||||
} else if (photo.LocCity) {
|
||||
this.$router.push({name: "places", query: {q: photo.LocCity}});
|
||||
if (photo.LocationID) {
|
||||
this.$router.push({name: "place", params: {q: "s2:" + photo.LocationID}});
|
||||
} else if (photo.PlaceID && photo.PlaceID !== "-") {
|
||||
this.$router.push({name: "place", params: {q: "s2:" + photo.PlaceID}});
|
||||
}
|
||||
},
|
||||
openPhoto(index) {
|
||||
|
|
|
@ -1,82 +1,63 @@
|
|||
<template>
|
||||
<v-container fluid fill-height class="pa-0 p-page p-page-places">
|
||||
<l-map :zoom="zoom" :center="center" :bounds="bounds" :options="options"
|
||||
@update:zoom="onZoom"
|
||||
@update:center="onCenter">
|
||||
|
||||
<l-control position="bottomright">
|
||||
<!-- v-container class="pb-0 pt-0 pl-3 pr-3 mb-0 mt-0" v-if="loading">
|
||||
<v-progress-linear :indeterminate="true" color="light-blue lighten-1"></v-progress-linear>
|
||||
</v-container -->
|
||||
<v-toolbar dense floating color="accent lighten-4 mt-0" v-on:dblclick.stop v-on:click.stop>
|
||||
<v-btn icon v-on:click="currentPosition()">
|
||||
<v-icon>my_location</v-icon>
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-text-field class="pt-3 pr-3"
|
||||
<div id="map" style="width: 100%; height: 100%;">
|
||||
<div class="p-map-control">
|
||||
<div class="mapboxgl-ctrl mapboxgl-ctrl-group">
|
||||
<v-text-field class="pa-0 ma-0"
|
||||
single-line
|
||||
solo
|
||||
flat
|
||||
:label="labels.search"
|
||||
prepend-inner-icon="search"
|
||||
clearable
|
||||
hide-details
|
||||
browser-autocomplete="off"
|
||||
color="secondary-dark"
|
||||
@click:clear="clearQuery"
|
||||
v-model="query.q"
|
||||
v-model="filter.q"
|
||||
@keyup.enter.native="formChange"
|
||||
></v-text-field>
|
||||
</v-toolbar>
|
||||
</l-control>
|
||||
<l-tile-layer :url="url" :attribution="attribution"></l-tile-layer>
|
||||
<l-marker v-for="(photo, index) in photos" v-bind:data="photo"
|
||||
v-bind:key="index" :lat-lng="photo.location" :icon="photo.icon"
|
||||
:options="photo.options" @click="openPhoto(index)"></l-marker>
|
||||
<l-marker v-if="position" :lat-lng="position" :z-index-offset="100"></l-marker>
|
||||
</l-map>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as L from "leaflet";
|
||||
import Photo from "model/photo";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import Api from "../common/api";
|
||||
|
||||
export default {
|
||||
name: 'p-page-places',
|
||||
watch: {
|
||||
'$route'() {
|
||||
this.filter.q = this.query();
|
||||
this.lastFilter = {};
|
||||
this.search();
|
||||
}
|
||||
},
|
||||
data() {
|
||||
const pos = this.startPos();
|
||||
const query = this.$route.query;
|
||||
const q = query['q'] ? query['q'] : "";
|
||||
const zoom = query['zoom'] ? parseInt(query['zoom']) : 12;
|
||||
const dist = this.getDistance(zoom);
|
||||
|
||||
return {
|
||||
map: null,
|
||||
markers: {},
|
||||
markersOnScreen: {},
|
||||
loading: false,
|
||||
zoom: zoom,
|
||||
position: null,
|
||||
center: L.latLng(parseFloat(pos.lat), parseFloat(pos.lng)),
|
||||
url: 'https://{s}.tile.osm.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors',
|
||||
url: 'https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key=xCDwZsNKW3rlveVG0WUU',
|
||||
attribution: '<a href="https://www.maptiler.com/copyright/" target="_blank">© MapTiler</a> <a href="https://www.openstreetmap.org/copyright" target="_blank">© OpenStreetMap contributors</a>',
|
||||
options: {
|
||||
icon: {
|
||||
iconSize: [50, 50]
|
||||
},
|
||||
minZoom: 3,
|
||||
container: "map",
|
||||
style: "https://api.maptiler.com/maps/streets/style.json?key=xCDwZsNKW3rlveVG0WUU",
|
||||
attributionControl: true,
|
||||
customAttribution: this.attribution,
|
||||
zoom: 0,
|
||||
},
|
||||
photos: [],
|
||||
results: [],
|
||||
query: {
|
||||
q: q,
|
||||
lat: pos.lat,
|
||||
lng: pos.lng,
|
||||
dist: dist.toString(),
|
||||
zoom: zoom.toString(),
|
||||
result: {},
|
||||
filter: {
|
||||
q: this.query(),
|
||||
},
|
||||
offset: 0,
|
||||
pageSize: 101,
|
||||
lastQuery: {},
|
||||
bounds: null,
|
||||
minLat: null,
|
||||
maxLat: null,
|
||||
minLng: null,
|
||||
maxLng: null,
|
||||
lastFilter: {},
|
||||
labels: {
|
||||
search: this.$gettext("Search"),
|
||||
},
|
||||
|
@ -84,224 +65,225 @@
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
getDistance(zoom) {
|
||||
switch (zoom) {
|
||||
case 18:
|
||||
return 1;
|
||||
case 17:
|
||||
return 3;
|
||||
case 16:
|
||||
return 5;
|
||||
case 15:
|
||||
return 6;
|
||||
case 14:
|
||||
return 10;
|
||||
case 13:
|
||||
return 15;
|
||||
case 12:
|
||||
return 30;
|
||||
case 11:
|
||||
return 60;
|
||||
case 10:
|
||||
return 100;
|
||||
case 9:
|
||||
return 300;
|
||||
case 8:
|
||||
return 400;
|
||||
case 7:
|
||||
return 800;
|
||||
case 6:
|
||||
return 1600;
|
||||
case 5:
|
||||
return 2000;
|
||||
query: function () {
|
||||
return this.$route.params.q ? this.$route.params.q : "";
|
||||
},
|
||||
openPhoto(id) {
|
||||
if(!this.photos || !this.photos.length) {
|
||||
this.photos = this.result.features.map((f) => new Photo(f.properties));
|
||||
}
|
||||
|
||||
return 2500;
|
||||
},
|
||||
onZoom(zoom) {
|
||||
if(this.query.zoom === zoom.toString()) return;
|
||||
if(this.photos.length > 0) {
|
||||
const index = this.photos.findIndex((p) => p.PhotoUUID === id);
|
||||
|
||||
this.query.zoom = zoom.toString();
|
||||
this.query.dist = this.getDistance(zoom).toString();
|
||||
|
||||
this.search();
|
||||
},
|
||||
onCenter(pos) {
|
||||
const changed = Math.abs(this.query.lat - pos.lat) > 0.001 ||
|
||||
Math.abs(this.query.lng - pos.lng) > 0.001;
|
||||
|
||||
if(!changed) return;
|
||||
|
||||
this.query.lat = pos.lat.toString();
|
||||
this.query.lng = pos.lng.toString();
|
||||
|
||||
this.search();
|
||||
},
|
||||
startPos() {
|
||||
const pos = this.$config.getValue("pos");
|
||||
const query = this.$route.query;
|
||||
|
||||
let result = {
|
||||
lat: pos.lat.toString(),
|
||||
lng: pos.lng.toString(),
|
||||
};
|
||||
|
||||
const queryLat = query['lat'];
|
||||
const queryLng = query['lng'];
|
||||
|
||||
let storedLat = window.localStorage.getItem("lat");
|
||||
let storedLng = window.localStorage.getItem("lng");
|
||||
|
||||
if (queryLat && queryLng) {
|
||||
result.lat = queryLat;
|
||||
result.lng = queryLng;
|
||||
} else if (storedLat && storedLng) {
|
||||
result.lat = storedLat;
|
||||
result.lng = storedLng;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
openPhoto(index) {
|
||||
this.$viewer.show(this.results, index)
|
||||
},
|
||||
onPosition(position) {
|
||||
this.position = L.latLng(position.coords.latitude, position.coords.longitude);
|
||||
this.center = L.latLng(position.coords.latitude, position.coords.longitude);
|
||||
this.query.q = "";
|
||||
},
|
||||
onPositionError(error) {
|
||||
this.$notify.warning(error.message);
|
||||
},
|
||||
currentPosition() {
|
||||
if ("geolocation" in navigator) {
|
||||
this.$notify.success(this.$gettext('Finding your position...'));
|
||||
navigator.geolocation.getCurrentPosition(this.onPosition.bind(this), this.onPositionError.bind(this));
|
||||
this.$viewer.show(this.photos, index)
|
||||
} else {
|
||||
this.$notify.warning(this.$gettext('Geolocation is not available'));
|
||||
this.$notify.warning("No photos found");
|
||||
}
|
||||
},
|
||||
formChange() {
|
||||
this.query.lat = "";
|
||||
this.query.lng = "";
|
||||
this.search();
|
||||
},
|
||||
clearQuery() {
|
||||
this.position = null;
|
||||
this.query.q = "";
|
||||
this.query.lat = "";
|
||||
this.query.lng = "";
|
||||
this.filter.q = "";
|
||||
this.search();
|
||||
},
|
||||
resetBoundingBox() {
|
||||
this.minLat = null;
|
||||
this.maxLat = null;
|
||||
this.minLng = null;
|
||||
this.maxLng = null;
|
||||
},
|
||||
fitBoundingBox(lat, lng) {
|
||||
if (this.maxLat === null || lat > this.maxLat) {
|
||||
this.maxLat = lat;
|
||||
}
|
||||
|
||||
if (this.minLat === null || lat < this.minLat) {
|
||||
this.minLat = lat;
|
||||
}
|
||||
|
||||
if (this.maxLng === null || lng > this.maxLng) {
|
||||
this.maxLng = lng;
|
||||
}
|
||||
|
||||
if (this.minLng === null || lng < this.minLng) {
|
||||
this.minLng = lng;
|
||||
}
|
||||
},
|
||||
updateMap(results) {
|
||||
for (let i = 0, len = results.length; i < len; i++) {
|
||||
let result = results[i];
|
||||
|
||||
if (!result.hasLocation()) continue;
|
||||
|
||||
let index = this.results.findIndex((p) => p.PhotoUUID === result.PhotoUUID);
|
||||
|
||||
if (index !== -1) continue;
|
||||
|
||||
this.results.push(result);
|
||||
this.photos.push({
|
||||
id: result.getId(),
|
||||
options: {
|
||||
title: result.getTitle(),
|
||||
clickable: true,
|
||||
},
|
||||
icon: L.icon({
|
||||
iconUrl: result.getThumbnailUrl('tile_50'),
|
||||
iconRetinaUrl: result.getThumbnailUrl('tile_100'),
|
||||
iconSize: [50, 50],
|
||||
className: 'leaflet-marker-photo',
|
||||
}),
|
||||
location: L.latLng(result.PhotoLat, result.PhotoLng),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.photos.length === 0) {
|
||||
this.$notify.warning(this.$gettext('Nothing to see here'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
if(!this.query.q) return;
|
||||
this.center = this.photos[this.photos.length - 1].location;
|
||||
this.position = this.photos[this.photos.length - 1].location;
|
||||
});
|
||||
},
|
||||
updateQuery() {
|
||||
const query = Object(this.query);
|
||||
|
||||
if (this.query.lat && this.query.lng) {
|
||||
window.localStorage.setItem("lat", this.query.lat.toString());
|
||||
window.localStorage.setItem("lng", this.query.lng.toString());
|
||||
} else {
|
||||
this.position = null;
|
||||
}
|
||||
|
||||
if (JSON.stringify(this.$route.query) !== JSON.stringify(query)) {
|
||||
this.$router.replace({query: query});
|
||||
if (this.query() !== this.filter.q) {
|
||||
if (this.filter.q) {
|
||||
this.$router.replace({name: "place", params: {q: this.filter.q}});
|
||||
} else {
|
||||
this.$router.replace({name: "places" });
|
||||
}
|
||||
}
|
||||
},
|
||||
search() {
|
||||
if (this.loading) return;
|
||||
|
||||
// Don't query the same data more than once
|
||||
if (JSON.stringify(this.lastQuery) === JSON.stringify(this.query)) return;
|
||||
|
||||
this.offset = 0;
|
||||
if (JSON.stringify(this.lastFilter) === JSON.stringify(this.filter)) return;
|
||||
this.loading = true;
|
||||
|
||||
Object.assign(this.lastQuery, this.query);
|
||||
Object.assign(this.lastFilter, this.filter);
|
||||
|
||||
this.updateQuery();
|
||||
|
||||
const params = {
|
||||
count: this.pageSize,
|
||||
offset: this.offset,
|
||||
location: 1,
|
||||
const options = {
|
||||
params: this.filter,
|
||||
};
|
||||
|
||||
Object.assign(params, this.query);
|
||||
|
||||
Photo.search(params).then(response => {
|
||||
return Api.get("geo", options).then((response) => {
|
||||
this.loading = false;
|
||||
|
||||
if (!response.models.length) {
|
||||
return;
|
||||
}
|
||||
if(response.data.features && response.data.features.length > 0) {
|
||||
this.markers = {};
|
||||
this.markersOnScreen = {};
|
||||
this.photos = {};
|
||||
this.result = response.data;
|
||||
|
||||
this.updateMap(response.models);
|
||||
this.map.getSource("photos").setData(this.result);
|
||||
|
||||
this.map.fitBounds(this.result.bbox, {maxZoom: 19});
|
||||
|
||||
this.updateMarkers();
|
||||
} else {
|
||||
this.$notify.warning("No photos found");
|
||||
}
|
||||
}).catch(() => this.loading = false);
|
||||
},
|
||||
renderMap() {
|
||||
this.map = new mapboxgl.Map(this.options);
|
||||
|
||||
this.map.addControl(new mapboxgl.NavigationControl({showCompass: false}, 'top-right'));
|
||||
this.map.addControl(new mapboxgl.FullscreenControl({container: document.querySelector('body')}));
|
||||
this.map.addControl(new mapboxgl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true
|
||||
},
|
||||
trackUserLocation: true
|
||||
}));
|
||||
|
||||
this.map.on("load", () => this.onMapLoad());
|
||||
},
|
||||
updateMarkers() {
|
||||
if(this.loading) return;
|
||||
let newMarkers = {};
|
||||
let features = this.map.querySourceFeatures("photos");
|
||||
|
||||
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 = props.PhotoUUID;
|
||||
|
||||
let marker = this.markers[id];
|
||||
if (!marker) {
|
||||
let el = document.createElement('div');
|
||||
el.className = 'marker';
|
||||
el.title = props.PhotoTitle;
|
||||
el.style.backgroundImage =
|
||||
'url(/api/v1/thumbnails/' +
|
||||
props.FileHash + '/tile_50)';
|
||||
el.style.width = '50px';
|
||||
el.style.height = '50px';
|
||||
|
||||
el.addEventListener('click', () => this.openPhoto(props.PhotoUUID));
|
||||
marker = this.markers[id] = new mapboxgl.Marker({
|
||||
element: el
|
||||
}).setLngLat(coords);
|
||||
}
|
||||
newMarkers[id] = marker;
|
||||
|
||||
if (!this.markersOnScreen[id]) {
|
||||
marker.addTo(this.map);
|
||||
}
|
||||
}
|
||||
for (let id in this.markersOnScreen) {
|
||||
if (!newMarkers[id]) {
|
||||
this.markersOnScreen[id].remove();
|
||||
}
|
||||
}
|
||||
this.markersOnScreen = newMarkers;
|
||||
},
|
||||
onMapLoad() {
|
||||
this.map.on("styleimagemissing", e => {
|
||||
if (!e.id.startsWith("/")) return;
|
||||
this.map.loadImage(e.id, (err, data) => {
|
||||
if (!err) {
|
||||
if (!this.map.hasImage(e.id)) {
|
||||
this.map.addImage(e.id, data);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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)
|
||||
});
|
||||
|
||||
this.map.addLayer({
|
||||
id: 'clusters',
|
||||
type: 'circle',
|
||||
source: 'photos',
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
'#2DC4B2',
|
||||
100,
|
||||
'#3BB3C3',
|
||||
750,
|
||||
'#669EC4'
|
||||
],
|
||||
'circle-radius': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
20,
|
||||
100,
|
||||
30,
|
||||
750,
|
||||
40
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
this.map.addLayer({
|
||||
id: 'cluster-count',
|
||||
type: 'symbol',
|
||||
source: 'photos',
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': '{point_count_abbreviated}',
|
||||
'text-font': ['Roboto', 'sans-serif'],
|
||||
'text-size': 13
|
||||
}
|
||||
});
|
||||
|
||||
this.map.on('data', (e) => {
|
||||
if (e.sourceId !== 'photos' || !e.isSourceLoaded) return;
|
||||
|
||||
//this.map.on('move', this.updateMarkers);
|
||||
this.map.on('moveend', this.updateMarkers);
|
||||
this.map.on('render', this.updateMarkers);
|
||||
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();
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.search();
|
||||
mounted() {
|
||||
this.renderMap();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -64,6 +64,12 @@ export default [
|
|||
component: Places,
|
||||
meta: {title: "Places"},
|
||||
},
|
||||
{
|
||||
name: "place",
|
||||
path: "/places/:q",
|
||||
component: Places,
|
||||
meta: {title: "Places"},
|
||||
},
|
||||
{
|
||||
name: "labels",
|
||||
path: "/labels",
|
||||
|
|
2
go.mod
2
go.mod
|
@ -20,6 +20,7 @@ require (
|
|||
github.com/golang/protobuf v1.3.2 // indirect
|
||||
github.com/golang/snappy v0.0.1 // indirect
|
||||
github.com/google/go-cmp v0.3.1 // indirect
|
||||
github.com/google/open-location-code/go v0.0.0-20191230190541-a6eb95b4d2f9
|
||||
github.com/gorilla/websocket v1.4.1
|
||||
github.com/gosimple/slug v1.5.0
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4 // indirect
|
||||
|
@ -39,6 +40,7 @@ require (
|
|||
github.com/myesui/uuid v1.0.0 // indirect
|
||||
github.com/opentracing/opentracing-go v1.0.2
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/paulmach/go.geojson v1.4.0
|
||||
github.com/pingcap/errors v0.11.1
|
||||
github.com/pingcap/goleveldb v0.0.0-20171020122428-b9ff6c35079e // indirect
|
||||
github.com/pingcap/parser v0.0.0-20190529073816-0550d84c65ad
|
||||
|
|
5
go.sum
5
go.sum
|
@ -124,6 +124,9 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
|
|||
github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/open-location-code v0.0.0-20191230190541-a6eb95b4d2f9 h1:MOOYh4mJsm+TXOgXRXT1E0g4FHrl41qhvUqXd/EPgFg=
|
||||
github.com/google/open-location-code/go v0.0.0-20191230190541-a6eb95b4d2f9 h1:6ILzS4n0F17S38XvOB1BcyzB+0BtVzU77EyuMtkMffo=
|
||||
github.com/google/open-location-code/go v0.0.0-20191230190541-a6eb95b4d2f9/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
|
@ -218,6 +221,8 @@ github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFSt
|
|||
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/paulmach/go.geojson v1.4.0 h1:5x5moCkCtDo5x8af62P9IOAYGQcYHtxz2QJ3x1DoCgY=
|
||||
github.com/paulmach/go.geojson v1.4.0/go.mod h1:YaKx1hKpWF+T2oj2lFJPsW/t1Q5e1jQI61eoQSTwpIs=
|
||||
github.com/pelletier/go-toml v1.3.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
|
||||
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||
github.com/pingcap/check v0.0.0-20190102082844-67f458068fc8 h1:USx2/E1bX46VG32FIw034Au6seQ2fY9NEILmNh/UlQg=
|
||||
|
|
82
internal/api/geo.go
Normal file
82
internal/api/geo.go
Normal file
|
@ -0,0 +1,82 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/query"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
|
||||
"github.com/paulmach/go.geojson"
|
||||
)
|
||||
|
||||
// GET /api/v1/geo
|
||||
func GetGeo(router *gin.RouterGroup, conf *config.Config) {
|
||||
router.GET("/geo", func(c *gin.Context) {
|
||||
var f form.GeoSearch
|
||||
|
||||
q := query.New(conf.OriginalsPath(), conf.Db())
|
||||
err := c.MustBindWith(&f, binding.Form)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
photos, err := q.Geo(f)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
fc := geojson.NewFeatureCollection()
|
||||
|
||||
bbox := make([]float64, 4)
|
||||
|
||||
bboxMin := func (pos int, val float64) {
|
||||
if bbox[pos] == 0.0 || bbox[pos] > val {
|
||||
bbox[pos] = val
|
||||
}
|
||||
}
|
||||
|
||||
bboxMax := func (pos int, val float64) {
|
||||
if bbox[pos] == 0.0 || bbox[pos] < val {
|
||||
bbox[pos] = val
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range photos {
|
||||
bboxMin(0, p.PhotoLng)
|
||||
bboxMin(1, p.PhotoLat)
|
||||
bboxMax(2, p.PhotoLng)
|
||||
bboxMax(3, p.PhotoLat)
|
||||
|
||||
feat := geojson.NewPointFeature([]float64{p.PhotoLng, p.PhotoLat})
|
||||
feat.Properties = gin.H{
|
||||
"PhotoUUID": p.PhotoUUID,
|
||||
"PhotoTitle": p.PhotoTitle,
|
||||
"FileHash": p.FileHash,
|
||||
"FileWidth": p.FileWidth,
|
||||
"FileHeight": p.FileHeight,
|
||||
"TakenAt": p.TakenAt,
|
||||
}
|
||||
fc.AddFeature(feat)
|
||||
}
|
||||
|
||||
fc.BoundingBox = bbox
|
||||
|
||||
resp, err := fc.MarshalJSON()
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "application/json", resp)
|
||||
})
|
||||
}
|
|
@ -1,21 +1,8 @@
|
|||
package form
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/araddon/dateparse"
|
||||
)
|
||||
|
||||
// Query parameters for GET /api/v1/albums
|
||||
// AlbumSearch represents search form fields for "/api/v1/albums".
|
||||
type AlbumSearch struct {
|
||||
Query string `form:"q"`
|
||||
Query string `form:"q"`
|
||||
|
||||
Slug string `form:"slug"`
|
||||
Name string `form:"name"`
|
||||
|
@ -26,89 +13,14 @@ type AlbumSearch struct {
|
|||
Order string `form:"order"`
|
||||
}
|
||||
|
||||
func (f *AlbumSearch) ParseQueryString() (result error) {
|
||||
var key, value []rune
|
||||
var escaped, isKeyValue bool
|
||||
|
||||
q := f.Query
|
||||
|
||||
f.Query = ""
|
||||
|
||||
formValues := reflect.ValueOf(f).Elem()
|
||||
|
||||
q = strings.TrimSpace(q) + "\n"
|
||||
|
||||
for _, char := range q {
|
||||
if unicode.IsSpace(char) && !escaped {
|
||||
if isKeyValue {
|
||||
fieldName := strings.Title(string(key))
|
||||
field := formValues.FieldByName(fieldName)
|
||||
stringValue := string(value)
|
||||
|
||||
if field.CanSet() {
|
||||
switch field.Interface().(type) {
|
||||
case time.Time:
|
||||
if timeValue, err := dateparse.ParseAny(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.Set(reflect.ValueOf(timeValue))
|
||||
}
|
||||
case float64:
|
||||
if floatValue, err := strconv.ParseFloat(stringValue, 64); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetFloat(floatValue)
|
||||
}
|
||||
case int, int64:
|
||||
if intValue, err := strconv.Atoi(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetInt(int64(intValue))
|
||||
}
|
||||
case uint, uint64:
|
||||
if intValue, err := strconv.Atoi(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetUint(uint64(intValue))
|
||||
}
|
||||
case string:
|
||||
field.SetString(stringValue)
|
||||
case bool:
|
||||
if stringValue == "1" || stringValue == "true" || stringValue == "yes" {
|
||||
field.SetBool(true)
|
||||
} else if stringValue == "0" || stringValue == "false" || stringValue == "no" {
|
||||
field.SetBool(false)
|
||||
} else {
|
||||
result = fmt.Errorf("not a bool value: %s", fieldName)
|
||||
}
|
||||
default:
|
||||
result = fmt.Errorf("unsupported type: %s", fieldName)
|
||||
}
|
||||
} else {
|
||||
result = fmt.Errorf("unknown filter: %s", fieldName)
|
||||
}
|
||||
} else {
|
||||
f.Query = string(key)
|
||||
}
|
||||
|
||||
escaped = false
|
||||
isKeyValue = false
|
||||
key = key[:0]
|
||||
value = value[:0]
|
||||
} else if char == ':' {
|
||||
isKeyValue = true
|
||||
} else if char == '"' {
|
||||
escaped = !escaped
|
||||
} else if isKeyValue {
|
||||
value = append(value, unicode.ToLower(char))
|
||||
} else {
|
||||
key = append(key, unicode.ToLower(char))
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
log.Errorf("error while parsing album form: %s", result)
|
||||
}
|
||||
|
||||
return result
|
||||
func (f *AlbumSearch) GetQuery() string {
|
||||
return f.Query
|
||||
}
|
||||
|
||||
func (f *AlbumSearch) SetQuery(q string) {
|
||||
f.Query = q
|
||||
}
|
||||
|
||||
func (f *AlbumSearch) ParseQueryString() error {
|
||||
return ParseQueryString(f)
|
||||
}
|
||||
|
|
30
internal/form/geo_search.go
Normal file
30
internal/form/geo_search.go
Normal file
|
@ -0,0 +1,30 @@
|
|||
package form
|
||||
|
||||
import "time"
|
||||
|
||||
// GeoSearch represents search form fields for "/api/v1/geo".
|
||||
type GeoSearch struct {
|
||||
Query string `form:"q"`
|
||||
Before time.Time `form:"before" time_format:"2006-01-02"`
|
||||
After time.Time `form:"after" time_format:"2006-01-02"`
|
||||
Lat float64 `form:"lat"`
|
||||
Lng float64 `form:"lng"`
|
||||
S2 string `form:"s2"`
|
||||
Olc string `form:"olc"`
|
||||
Dist uint `form:"dist"`
|
||||
}
|
||||
|
||||
// GetQuery returns the query parameter as string.
|
||||
func (f *GeoSearch) GetQuery() string {
|
||||
return f.Query
|
||||
}
|
||||
|
||||
// SetQuery sets the query parameter.
|
||||
func (f *GeoSearch) SetQuery(q string) {
|
||||
f.Query = q
|
||||
}
|
||||
|
||||
// ParseQueryString parses the query parameter if possible.
|
||||
func (f *GeoSearch) ParseQueryString() error {
|
||||
return ParseQueryString(f)
|
||||
}
|
26
internal/form/geo_search_test.go
Normal file
26
internal/form/geo_search_test.go
Normal file
|
@ -0,0 +1,26 @@
|
|||
package form
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func TestGeoSearch(t *testing.T) {
|
||||
t.Run("valid query", func(t *testing.T) {
|
||||
form := &GeoSearch{Query: "query:\"fooBar baz\" before:2019-01-15 dist:25000 lat:33.45343166666667"}
|
||||
|
||||
err := form.ParseQueryString()
|
||||
|
||||
log.Debugf("%+v\n", form)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "foobar baz", form.Query)
|
||||
assert.Equal(t, time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), form.Before)
|
||||
assert.Equal(t, uint(0x61a8), form.Dist)
|
||||
assert.Equal(t, 33.45343166666667, form.Lat)
|
||||
})
|
||||
}
|
|
@ -1,21 +1,8 @@
|
|||
package form
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/araddon/dateparse"
|
||||
)
|
||||
|
||||
// Query parameters for GET /api/v1/labels
|
||||
// PhotoSearch represents search form fields for "/api/v1/labels".
|
||||
type LabelSearch struct {
|
||||
Query string `form:"q"`
|
||||
Query string `form:"q"`
|
||||
|
||||
Slug string `form:"slug"`
|
||||
Name string `form:"name"`
|
||||
|
@ -27,89 +14,14 @@ type LabelSearch struct {
|
|||
Order string `form:"order"`
|
||||
}
|
||||
|
||||
func (f *LabelSearch) ParseQueryString() (result error) {
|
||||
var key, value []rune
|
||||
var escaped, isKeyValue bool
|
||||
|
||||
q := f.Query
|
||||
|
||||
f.Query = ""
|
||||
|
||||
formValues := reflect.ValueOf(f).Elem()
|
||||
|
||||
q = strings.TrimSpace(q) + "\n"
|
||||
|
||||
for _, char := range q {
|
||||
if unicode.IsSpace(char) && !escaped {
|
||||
if isKeyValue {
|
||||
fieldName := strings.Title(string(key))
|
||||
field := formValues.FieldByName(fieldName)
|
||||
stringValue := string(value)
|
||||
|
||||
if field.CanSet() {
|
||||
switch field.Interface().(type) {
|
||||
case time.Time:
|
||||
if timeValue, err := dateparse.ParseAny(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.Set(reflect.ValueOf(timeValue))
|
||||
}
|
||||
case float64:
|
||||
if floatValue, err := strconv.ParseFloat(stringValue, 64); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetFloat(floatValue)
|
||||
}
|
||||
case int, int64:
|
||||
if intValue, err := strconv.Atoi(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetInt(int64(intValue))
|
||||
}
|
||||
case uint, uint64:
|
||||
if intValue, err := strconv.Atoi(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetUint(uint64(intValue))
|
||||
}
|
||||
case string:
|
||||
field.SetString(stringValue)
|
||||
case bool:
|
||||
if stringValue == "1" || stringValue == "true" || stringValue == "yes" {
|
||||
field.SetBool(true)
|
||||
} else if stringValue == "0" || stringValue == "false" || stringValue == "no" {
|
||||
field.SetBool(false)
|
||||
} else {
|
||||
result = fmt.Errorf("not a bool value: %s", fieldName)
|
||||
}
|
||||
default:
|
||||
result = fmt.Errorf("unsupported type: %s", fieldName)
|
||||
}
|
||||
} else {
|
||||
result = fmt.Errorf("unknown filter: %s", fieldName)
|
||||
}
|
||||
} else {
|
||||
f.Query = string(key)
|
||||
}
|
||||
|
||||
escaped = false
|
||||
isKeyValue = false
|
||||
key = key[:0]
|
||||
value = value[:0]
|
||||
} else if char == ':' {
|
||||
isKeyValue = true
|
||||
} else if char == '"' {
|
||||
escaped = !escaped
|
||||
} else if isKeyValue {
|
||||
value = append(value, unicode.ToLower(char))
|
||||
} else {
|
||||
key = append(key, unicode.ToLower(char))
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
log.Errorf("error while parsing label form: %s", result)
|
||||
}
|
||||
|
||||
return result
|
||||
func (f *LabelSearch) GetQuery() string {
|
||||
return f.Query
|
||||
}
|
||||
|
||||
func (f *LabelSearch) SetQuery(q string) {
|
||||
f.Query = q
|
||||
}
|
||||
|
||||
func (f *LabelSearch) ParseQueryString() error {
|
||||
return ParseQueryString(f)
|
||||
}
|
||||
|
|
|
@ -1,21 +1,12 @@
|
|||
package form
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/araddon/dateparse"
|
||||
)
|
||||
|
||||
// Query parameters for GET /api/v1/photos
|
||||
// PhotoSearch represents search form fields for "/api/v1/photos".
|
||||
type PhotoSearch struct {
|
||||
Query string `form:"q"`
|
||||
Query string `form:"q"`
|
||||
|
||||
Title string `form:"title"`
|
||||
Description string `form:"description"`
|
||||
|
@ -54,89 +45,14 @@ type PhotoSearch struct {
|
|||
Order string `form:"order"`
|
||||
}
|
||||
|
||||
func (f *PhotoSearch) ParseQueryString() (result error) {
|
||||
var key, value []rune
|
||||
var escaped, isKeyValue bool
|
||||
|
||||
q := f.Query
|
||||
|
||||
f.Query = ""
|
||||
|
||||
formValues := reflect.ValueOf(f).Elem()
|
||||
|
||||
q = strings.TrimSpace(q) + "\n"
|
||||
|
||||
for _, char := range q {
|
||||
if unicode.IsSpace(char) && !escaped {
|
||||
if isKeyValue {
|
||||
fieldName := strings.Title(string(key))
|
||||
field := formValues.FieldByName(fieldName)
|
||||
stringValue := string(value)
|
||||
|
||||
if field.CanSet() {
|
||||
switch field.Interface().(type) {
|
||||
case time.Time:
|
||||
if timeValue, err := dateparse.ParseAny(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.Set(reflect.ValueOf(timeValue))
|
||||
}
|
||||
case float64:
|
||||
if floatValue, err := strconv.ParseFloat(stringValue, 64); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetFloat(floatValue)
|
||||
}
|
||||
case int, int64:
|
||||
if intValue, err := strconv.Atoi(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetInt(int64(intValue))
|
||||
}
|
||||
case uint, uint64:
|
||||
if intValue, err := strconv.Atoi(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetUint(uint64(intValue))
|
||||
}
|
||||
case string:
|
||||
field.SetString(stringValue)
|
||||
case bool:
|
||||
if stringValue == "1" || stringValue == "true" || stringValue == "yes" {
|
||||
field.SetBool(true)
|
||||
} else if stringValue == "0" || stringValue == "false" || stringValue == "no" {
|
||||
field.SetBool(false)
|
||||
} else {
|
||||
result = fmt.Errorf("not a bool value: %s", fieldName)
|
||||
}
|
||||
default:
|
||||
result = fmt.Errorf("unsupported type: %s", fieldName)
|
||||
}
|
||||
} else {
|
||||
result = fmt.Errorf("unknown filter: %s", fieldName)
|
||||
}
|
||||
} else {
|
||||
f.Query = string(key)
|
||||
}
|
||||
|
||||
escaped = false
|
||||
isKeyValue = false
|
||||
key = key[:0]
|
||||
value = value[:0]
|
||||
} else if char == ':' {
|
||||
isKeyValue = true
|
||||
} else if char == '"' {
|
||||
escaped = !escaped
|
||||
} else if isKeyValue {
|
||||
value = append(value, unicode.ToLower(char))
|
||||
} else {
|
||||
key = append(key, unicode.ToLower(char))
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
log.Errorf("error while parsing search form: %s", result)
|
||||
}
|
||||
|
||||
return result
|
||||
func (f *PhotoSearch) GetQuery() string {
|
||||
return f.Query
|
||||
}
|
||||
|
||||
func (f *PhotoSearch) SetQuery(q string) {
|
||||
f.Query = q
|
||||
}
|
||||
|
||||
func (f *PhotoSearch) ParseQueryString() error {
|
||||
return ParseQueryString(f)
|
||||
}
|
||||
|
|
106
internal/form/search.go
Normal file
106
internal/form/search.go
Normal file
|
@ -0,0 +1,106 @@
|
|||
package form
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/araddon/dateparse"
|
||||
)
|
||||
|
||||
type SearchForm interface {
|
||||
GetQuery() string
|
||||
SetQuery(q string)
|
||||
}
|
||||
|
||||
func ParseQueryString(f SearchForm) (result error) {
|
||||
var key, value []rune
|
||||
var escaped, isKeyValue bool
|
||||
|
||||
q := f.GetQuery()
|
||||
|
||||
f.SetQuery("")
|
||||
|
||||
formValues := reflect.ValueOf(f).Elem()
|
||||
|
||||
q = strings.TrimSpace(q) + "\n"
|
||||
|
||||
for _, char := range q {
|
||||
if unicode.IsSpace(char) && !escaped {
|
||||
if isKeyValue {
|
||||
fieldName := strings.Title(string(key))
|
||||
field := formValues.FieldByName(fieldName)
|
||||
stringValue := string(value)
|
||||
|
||||
if field.CanSet() {
|
||||
switch field.Interface().(type) {
|
||||
case time.Time:
|
||||
if timeValue, err := dateparse.ParseAny(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.Set(reflect.ValueOf(timeValue))
|
||||
}
|
||||
case float64:
|
||||
if floatValue, err := strconv.ParseFloat(stringValue, 64); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetFloat(floatValue)
|
||||
}
|
||||
case int, int64:
|
||||
if intValue, err := strconv.Atoi(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetInt(int64(intValue))
|
||||
}
|
||||
case uint, uint64:
|
||||
if intValue, err := strconv.Atoi(stringValue); err != nil {
|
||||
result = err
|
||||
} else {
|
||||
field.SetUint(uint64(intValue))
|
||||
}
|
||||
case string:
|
||||
field.SetString(stringValue)
|
||||
case bool:
|
||||
if stringValue == "1" || stringValue == "true" || stringValue == "yes" {
|
||||
field.SetBool(true)
|
||||
} else if stringValue == "0" || stringValue == "false" || stringValue == "no" {
|
||||
field.SetBool(false)
|
||||
} else {
|
||||
result = fmt.Errorf("not a bool value: %s", fieldName)
|
||||
}
|
||||
default:
|
||||
result = fmt.Errorf("unsupported type: %s", fieldName)
|
||||
}
|
||||
} else {
|
||||
result = fmt.Errorf("unknown filter: %s", fieldName)
|
||||
}
|
||||
} else {
|
||||
f.SetQuery(string(key))
|
||||
}
|
||||
|
||||
escaped = false
|
||||
isKeyValue = false
|
||||
key = key[:0]
|
||||
value = value[:0]
|
||||
} else if char == ':' {
|
||||
isKeyValue = true
|
||||
} else if char == '"' {
|
||||
escaped = !escaped
|
||||
} else if isKeyValue {
|
||||
value = append(value, unicode.ToLower(char))
|
||||
} else {
|
||||
key = append(key, unicode.ToLower(char))
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
log.Errorf("error while parsing search form: %s", result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
89
internal/query/geo.go
Normal file
89
internal/query/geo.go
Normal file
|
@ -0,0 +1,89 @@
|
|||
package query
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/capture"
|
||||
"github.com/photoprism/photoprism/pkg/pluscode"
|
||||
"github.com/photoprism/photoprism/pkg/s2"
|
||||
)
|
||||
|
||||
// GeoResult represents a photo for displaying it on a map.
|
||||
type GeoResult struct {
|
||||
PhotoLat float64 `json:"Lat"`
|
||||
PhotoLng float64 `json:"Lng"`
|
||||
PhotoUUID string `json:"PhotoUUID"`
|
||||
PhotoTitle string `json:"PhotoTitle"`
|
||||
FileHash string `json:"FileHash"`
|
||||
FileWidth int `json:"FileWidth"`
|
||||
FileHeight int `json:"FileHeight"`
|
||||
TakenAt time.Time `json:"TakenAt"`
|
||||
}
|
||||
|
||||
// Geo searches for photos based on a Form and returns a PhotoResult slice.
|
||||
func (s *Repo) Geo(f form.GeoSearch) (results []GeoResult, err error) {
|
||||
if err := f.ParseQueryString(); err != nil {
|
||||
return results, err
|
||||
}
|
||||
|
||||
defer log.Debug(capture.Time(time.Now(), fmt.Sprintf("search: %+v", f)))
|
||||
|
||||
q := s.db.NewScope(nil).DB()
|
||||
|
||||
// q.LogMode(true)
|
||||
|
||||
q = q.Table("photos").
|
||||
Select(`photos.photo_uuid, photos.photo_lat, photos.photo_lng, photos.photo_title, photos.taken_at,
|
||||
files.file_hash, files.file_width, files.file_height`).
|
||||
Joins(`JOIN files ON files.photo_id = photos.id
|
||||
AND files.file_missing = 0 AND files.file_primary AND files.deleted_at IS NULL`).
|
||||
Where("photos.photo_lat <> 0").
|
||||
Group("photos.id, files.id")
|
||||
|
||||
|
||||
if f.Query != "" {
|
||||
q = q.Joins("LEFT JOIN photos_keywords ON photos_keywords.photo_id = photos.id").
|
||||
Joins("LEFT JOIN keywords ON photos_keywords.keyword_id = keywords.id").
|
||||
Where("keywords.keyword LIKE ?", strings.ToLower(f.Query)+"%")
|
||||
}
|
||||
|
||||
if f.S2 != "" {
|
||||
s2Min, s2Max := s2.Range(f.S2, 1)
|
||||
q = q.Where("photos.location_id BETWEEN ? AND ?", s2Min, s2Max)
|
||||
} else if f.Olc != "" {
|
||||
s2Min, s2Max := s2.Range(pluscode.S2(f.Olc), 1)
|
||||
q = q.Where("photos.location_id BETWEEN ? AND ?", s2Min, s2Max)
|
||||
} else {
|
||||
// Inaccurate distance search, but probably 'good enough' for now
|
||||
if f.Lat > 0 {
|
||||
latMin := f.Lat - SearchRadius*float64(f.Dist)
|
||||
latMax := f.Lat + SearchRadius*float64(f.Dist)
|
||||
q = q.Where("photos.photo_lat BETWEEN ? AND ?", latMin, latMax)
|
||||
}
|
||||
|
||||
if f.Lng > 0 {
|
||||
lngMin := f.Lng - SearchRadius*float64(f.Dist)
|
||||
lngMax := f.Lng + SearchRadius*float64(f.Dist)
|
||||
q = q.Where("photos.photo_lng BETWEEN ? AND ?", lngMin, lngMax)
|
||||
}
|
||||
}
|
||||
|
||||
if !f.Before.IsZero() {
|
||||
q = q.Where("photos.taken_at <= ?", f.Before.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
if !f.After.IsZero() {
|
||||
q = q.Where("photos.taken_at >= ?", f.After.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
q = q.Order("taken_at, photos.photo_uuid")
|
||||
|
||||
if result := q.Scan(&results); result.Error != nil {
|
||||
return results, result.Error
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
|
@ -27,6 +27,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
|||
api.CreateZip(v1, conf)
|
||||
api.DownloadZip(v1, conf)
|
||||
|
||||
api.GetGeo(v1, conf)
|
||||
api.GetPhoto(v1, conf)
|
||||
api.UpdatePhoto(v1, conf)
|
||||
api.GetPhotos(v1, conf)
|
||||
|
|
56
pkg/pluscode/pluscode.go
Normal file
56
pkg/pluscode/pluscode.go
Normal file
|
@ -0,0 +1,56 @@
|
|||
package pluscode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
olc "github.com/google/open-location-code/go"
|
||||
"github.com/photoprism/photoprism/pkg/s2"
|
||||
)
|
||||
|
||||
var defaultLen = 8
|
||||
|
||||
// Encode returns the plus code for the given coordinates using the default length.
|
||||
func Encode(lat, lng float64) string {
|
||||
result, _ := EncodeLength(lat, lng, defaultLen)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// EncodeLength returns the plus code for the given coordinates.
|
||||
func EncodeLength(lat, lng float64, length int) (plusCode string, err error) {
|
||||
if lat < -90 || lat > 90 {
|
||||
return "", fmt.Errorf("latitude out of range (%f)", lat)
|
||||
}
|
||||
|
||||
if lng < -180 || lng > 180 {
|
||||
return "", fmt.Errorf("longitude out of range (%f)", lng)
|
||||
}
|
||||
|
||||
return olc.Encode(lat, lng, length), nil
|
||||
}
|
||||
|
||||
// LatLng returns the coordinates for a plus code token.
|
||||
func LatLng(token string) (lat, lng float64) {
|
||||
if token == "" || token == "-" {
|
||||
return lat, lng
|
||||
}
|
||||
|
||||
c, err := olc.Decode(token)
|
||||
|
||||
if err != nil {
|
||||
return lat, lng
|
||||
}
|
||||
|
||||
lat, lng = c.Center()
|
||||
|
||||
return lat, lng
|
||||
}
|
||||
|
||||
// S2 returns the S2 cell token for the plus code using the default cell level.
|
||||
func S2(plusCode string) string {
|
||||
lat, lng := LatLng(plusCode)
|
||||
|
||||
s2Token := s2.Token(lat, lng)
|
||||
|
||||
return s2Token
|
||||
}
|
101
pkg/pluscode/pluscode_test.go
Normal file
101
pkg/pluscode/pluscode_test.go
Normal file
|
@ -0,0 +1,101 @@
|
|||
package pluscode
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEncode(t *testing.T) {
|
||||
t.Run("germany", func(t *testing.T) {
|
||||
plusCode := Encode(48.56344833333333, 8.996878333333333)
|
||||
|
||||
expected := "8FWCHX7W+"
|
||||
|
||||
assert.Equal(t, expected, plusCode)
|
||||
})
|
||||
|
||||
t.Run("lat_overflow", func(t *testing.T) {
|
||||
plusCode := Encode(548.56344833333333, 8.996878333333333)
|
||||
|
||||
assert.Equal(t, "", plusCode)
|
||||
})
|
||||
|
||||
t.Run("lng_overflow", func(t *testing.T) {
|
||||
plusCode := Encode(48.56344833333333, 258.996878333333333)
|
||||
|
||||
assert.Equal(t, "", plusCode)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func TestEncodeLength(t *testing.T) {
|
||||
t.Run("germany_9", func(t *testing.T) {
|
||||
plusCode, err := EncodeLength(48.56344833333333, 8.996878333333333, 9)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := "8FWCHX7W+9Q"
|
||||
|
||||
assert.Equal(t, expected, plusCode)
|
||||
})
|
||||
|
||||
t.Run("germany_8", func(t *testing.T) {
|
||||
plusCode, err := EncodeLength(48.56344833333333, 8.996878333333333, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := "8FWCHX7W+"
|
||||
|
||||
assert.Equal(t, expected, plusCode)
|
||||
})
|
||||
|
||||
t.Run("germany_7", func(t *testing.T) {
|
||||
plusCode, err := EncodeLength(48.56344833333333, 8.996878333333333, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := "8FWCHX7W+"
|
||||
|
||||
assert.Equal(t, expected, plusCode)
|
||||
})
|
||||
|
||||
t.Run("germany_6", func(t *testing.T) {
|
||||
plusCode, err := EncodeLength(48.56344833333333, 8.996878333333333, 6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := "8FWCHX00+"
|
||||
|
||||
assert.Equal(t, expected, plusCode)
|
||||
})
|
||||
|
||||
t.Run("lat_overflow", func(t *testing.T) {
|
||||
plusCode, err := EncodeLength(548.56344833333333, 8.996878333333333, 7)
|
||||
if err == nil {
|
||||
t.Fatal("encode should return error")
|
||||
}
|
||||
assert.Equal(t, "", plusCode)
|
||||
})
|
||||
|
||||
t.Run("lng_overflow", func(t *testing.T) {
|
||||
plusCode, err := EncodeLength(48.56344833333333, 258.996878333333333,7)
|
||||
if err == nil {
|
||||
t.Fatal("encode should return error")
|
||||
}
|
||||
assert.Equal(t, "", plusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestS2(t *testing.T) {
|
||||
t.Run("germany", func(t *testing.T) {
|
||||
token := S2("8FWCHX7W+")
|
||||
|
||||
assert.Equal(t, "4799e3772d14", token)
|
||||
})
|
||||
}
|
||||
|
23
pkg/s2/s2.go
23
pkg/s2/s2.go
|
@ -49,6 +49,29 @@ func LatLng(token string) (lat, lng float64) {
|
|||
}
|
||||
|
||||
c := gs2.CellIDFromToken(token)
|
||||
|
||||
if !c.IsValid() {
|
||||
return 0.0, 0.0
|
||||
}
|
||||
|
||||
l := c.LatLng()
|
||||
return l.Lat.Degrees(), l.Lng.Degrees()
|
||||
}
|
||||
|
||||
// IsZero returns true if the coordinates are both empty.
|
||||
func IsZero(lat, lng float64) bool {
|
||||
return lat == 0.0 && lng == 0.0
|
||||
}
|
||||
|
||||
// Range returns a token range for finding nearby locations.
|
||||
func Range(token string, levelUp int) (min, max string) {
|
||||
c := gs2.CellIDFromToken(token)
|
||||
|
||||
if !c.IsValid() {
|
||||
return min, max
|
||||
}
|
||||
|
||||
parent := c.Parent(c.Level() - levelUp)
|
||||
|
||||
return parent.Prev().ToToken(), parent.Next().ToToken()
|
||||
}
|
||||
|
|
|
@ -8,21 +8,21 @@ import (
|
|||
)
|
||||
|
||||
func TestToken(t *testing.T) {
|
||||
t.Run("Wildgehege", func(t *testing.T) {
|
||||
t.Run("germany", func(t *testing.T) {
|
||||
token := Token(48.56344833333333, 8.996878333333333)
|
||||
expected := "4799e370"
|
||||
|
||||
assert.True(t, strings.HasPrefix(token, expected))
|
||||
})
|
||||
|
||||
t.Run("LatOverflow", func(t *testing.T) {
|
||||
t.Run("lat_overflow", func(t *testing.T) {
|
||||
token := Token(548.56344833333333, 8.996878333333333)
|
||||
expected := ""
|
||||
|
||||
assert.Equal(t, expected, token)
|
||||
})
|
||||
|
||||
t.Run("LongOverflow", func(t *testing.T) {
|
||||
t.Run("lng_overflow", func(t *testing.T) {
|
||||
token := Token(48.56344833333333, 258.996878333333333)
|
||||
expected := ""
|
||||
|
||||
|
@ -31,56 +31,63 @@ func TestToken(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestTokenLevel(t *testing.T) {
|
||||
t.Run("Wildgehege30", func(t *testing.T) {
|
||||
t.Run("level_30", func(t *testing.T) {
|
||||
token := TokenLevel(48.56344833333333, 8.996878333333333, 30)
|
||||
expected := "4799e370ca54c8b9"
|
||||
|
||||
assert.Equal(t, expected, token)
|
||||
})
|
||||
|
||||
t.Run("NearWildgehege30", func(t *testing.T) {
|
||||
t.Run("level_30_diff", func(t *testing.T) {
|
||||
plusCode := TokenLevel(48.56344839999999, 8.996878339999999, 30)
|
||||
expected := "4799e370ca54c8b7"
|
||||
|
||||
assert.Equal(t, expected, plusCode)
|
||||
})
|
||||
|
||||
t.Run("Wildgehege18", func(t *testing.T) {
|
||||
t.Run("level_21", func(t *testing.T) {
|
||||
plusCode := TokenLevel(48.56344839999999, 8.996878339999999, 21)
|
||||
expected := "4799e370ca54"
|
||||
|
||||
assert.Equal(t, expected, plusCode)
|
||||
})
|
||||
|
||||
t.Run("level_18", func(t *testing.T) {
|
||||
token := TokenLevel(48.56344833333333, 8.996878333333333, 18)
|
||||
expected := "4799e370cb"
|
||||
|
||||
assert.Equal(t, expected, token)
|
||||
})
|
||||
|
||||
t.Run("NearWildgehege18", func(t *testing.T) {
|
||||
t.Run("level_18_diff", func(t *testing.T) {
|
||||
token := TokenLevel(48.56344839999999, 8.996878339999999, 18)
|
||||
expected := "4799e370cb"
|
||||
|
||||
assert.Equal(t, expected, token)
|
||||
})
|
||||
|
||||
t.Run("NearWildgehege15", func(t *testing.T) {
|
||||
t.Run("level_15", func(t *testing.T) {
|
||||
plusCode := TokenLevel(48.56344833333333, 8.996878333333333, 15)
|
||||
expected := "4799e370c"
|
||||
|
||||
assert.Equal(t, expected, plusCode)
|
||||
})
|
||||
|
||||
t.Run("Wildgehege10", func(t *testing.T) {
|
||||
t.Run("level_10", func(t *testing.T) {
|
||||
token := TokenLevel(48.56344833333333, 8.996878333333333, 10)
|
||||
expected := "4799e3"
|
||||
|
||||
assert.Equal(t, expected, token)
|
||||
})
|
||||
|
||||
t.Run("LatOverflow", func(t *testing.T) {
|
||||
t.Run("lat_overflow", func(t *testing.T) {
|
||||
token := TokenLevel(548.56344833333333, 8.996878333333333, 30)
|
||||
expected := ""
|
||||
|
||||
assert.Equal(t, expected, token)
|
||||
})
|
||||
|
||||
t.Run("LongOverflow", func(t *testing.T) {
|
||||
t.Run("lng_overflow", func(t *testing.T) {
|
||||
token := TokenLevel(48.56344833333333, 258.996878333333333, 30)
|
||||
expected := ""
|
||||
|
||||
|
@ -89,9 +96,41 @@ func TestTokenLevel(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestLatLng(t *testing.T) {
|
||||
t.Run("Wildgehege", func(t *testing.T) {
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
lat, lng := LatLng("4799e370ca54c8b9")
|
||||
assert.Equal(t, 48.56344835921243, lat)
|
||||
assert.Equal(t, 8.996878323369781, lng)
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
lat, lng := LatLng("4799e370ca5q")
|
||||
assert.Equal(t, 0.0, lat)
|
||||
assert.Equal(t, 0.0, lng)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsZero(t *testing.T) {
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
lat, lng := LatLng("4799e370ca54c8b9")
|
||||
assert.False(t, IsZero(lat, lng))
|
||||
})
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
lat, lng := LatLng("4799e370ca5q")
|
||||
assert.True(t, IsZero(lat, lng))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRange(t *testing.T) {
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
min, max := Range("4799e370ca54c8b9", 1)
|
||||
assert.Equal(t, "4799e370ca54c8b4", min)
|
||||
assert.Equal(t, "4799e370ca54c8c4", max)
|
||||
})
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
min, max := Range("4799e370ca5q", 1)
|
||||
assert.Equal(t, "", min)
|
||||
assert.Equal(t, "", max)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue