Initial code for new Places UI

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-01-15 04:04:33 +01:00
parent bba914878a
commit c31470dafb
28 changed files with 3305 additions and 1171 deletions

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

@ -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) {

View file

@ -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: '&copy; <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">&copy; MapTiler</a> <a href="https://www.openstreetmap.org/copyright" target="_blank">&copy; 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>

View file

@ -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
View file

@ -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
View file

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

View file

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

View 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)
}

View 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)
})
}

View file

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

View file

@ -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
View 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
View 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
}

View file

@ -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
View 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
}

View 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)
})
}

View file

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

View file

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