map: restore functionality
parent
bcc35d6132
commit
7d90aeacb1
|
@ -458,12 +458,13 @@ class ApiBase extends Controller
|
|||
}
|
||||
|
||||
// Filter geological bounds
|
||||
$minLat = $this->request->getParam('minLat');
|
||||
$maxLat = $this->request->getParam('maxLat');
|
||||
$minLng = $this->request->getParam('minLng');
|
||||
$maxLng = $this->request->getParam('maxLng');
|
||||
if ($minLat && $maxLat && $minLng && $maxLng) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformBoundFilter', $minLat, $maxLat, $minLng, $maxLng];
|
||||
$bounds = $this->request->getParam('mapbounds');
|
||||
if ($bounds) {
|
||||
$bounds = explode(',', $bounds);
|
||||
$bounds = array_map('floatval', $bounds);
|
||||
if (4 === \count($bounds)) {
|
||||
$transforms[] = [$this->timelineQuery, 'transformBoundFilter', $bounds];
|
||||
}
|
||||
}
|
||||
|
||||
return $transforms;
|
||||
|
|
|
@ -46,22 +46,24 @@ class LocationController extends ApiBase
|
|||
|
||||
// Get the folder to show
|
||||
$root = null;
|
||||
|
||||
try {
|
||||
$root = $this->getRequestRoot();
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Just check bound parameters instead of using them; they are used in transformation
|
||||
$minLat = $this->request->getParam('minLat');
|
||||
$maxLat = $this->request->getParam('maxLat');
|
||||
$minLng = $this->request->getParam('minLng');
|
||||
$maxLng = $this->request->getParam('maxLng');
|
||||
// Make sure we have bounds
|
||||
// $bounds = $this->request->getParam('bounds');
|
||||
// if (!$bounds) {
|
||||
// return new JSONResponse(['message' => 'Invalid perameters'], Http::STATUS_PRECONDITION_FAILED);
|
||||
// }
|
||||
|
||||
if (!is_numeric($minLat) || !is_numeric($maxLat) || !is_numeric($minLng) || !is_numeric($maxLng)) {
|
||||
return new JSONResponse(['message' => 'Invalid perameters'], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
// // Make sure we have 4 bounds
|
||||
// $bounds = explode(',', $bounds);
|
||||
// $bounds = array_map('floatval', $bounds);
|
||||
// if (4 !== \count($bounds)) {
|
||||
// return new JSONResponse(['message' => 'Invalid perameters'], Http::STATUS_PRECONDITION_FAILED);
|
||||
// }
|
||||
|
||||
// Zoom level is used to determine the grid length
|
||||
$zoomLevel = $this->request->getParam('zoom');
|
||||
|
|
|
@ -40,14 +40,14 @@ trait TimelineQueryFilters
|
|||
$query->setMaxResults($limit);
|
||||
}
|
||||
|
||||
public function transformBoundFilter(IQueryBuilder &$query, string $userId, string $minLat, string $maxLat, string $minLng, string $maxLng)
|
||||
public function transformBoundFilter(IQueryBuilder &$query, string $userId, array $bounds)
|
||||
{
|
||||
$query->andWhere(
|
||||
$query->expr()->andX(
|
||||
$query->expr()->gte('m.lat', $query->createNamedParameter($minLat, IQueryBuilder::PARAM_STR)),
|
||||
$query->expr()->lte('m.lat', $query->createNamedParameter($maxLat, IQueryBuilder::PARAM_STR)),
|
||||
$query->expr()->gte('m.lon', $query->createNamedParameter($minLng, IQueryBuilder::PARAM_STR)),
|
||||
$query->expr()->lte('m.lon', $query->createNamedParameter($maxLng, IQueryBuilder::PARAM_STR))
|
||||
$query->expr()->gte('m.lat', $query->createNamedParameter($bounds[0], IQueryBuilder::PARAM_STR)),
|
||||
$query->expr()->lte('m.lat', $query->createNamedParameter($bounds[1], IQueryBuilder::PARAM_STR)),
|
||||
$query->expr()->gte('m.lon', $query->createNamedParameter($bounds[2], IQueryBuilder::PARAM_STR)),
|
||||
$query->expr()->lte('m.lon', $query->createNamedParameter($bounds[3], IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
15
src/App.vue
15
src/App.vue
|
@ -30,7 +30,12 @@
|
|||
</NcAppNavigation>
|
||||
|
||||
<NcAppContent>
|
||||
<div class="outer">
|
||||
<div
|
||||
:class="{
|
||||
outer: true,
|
||||
'remove-gap': removeNavGap,
|
||||
}"
|
||||
>
|
||||
<router-view />
|
||||
</div>
|
||||
</NcAppContent>
|
||||
|
@ -144,6 +149,10 @@ export default defineComponent({
|
|||
showNavigation(): boolean {
|
||||
return !this.$route.name?.endsWith("-share");
|
||||
},
|
||||
|
||||
removeNavGap(): boolean {
|
||||
return this.$route.name === "locations";
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
@ -330,6 +339,10 @@ export default defineComponent({
|
|||
padding: 0 0 0 44px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&.remove-gap {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<div class="container">
|
||||
<div class="primary">
|
||||
<component :is="primary" />
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<Timeline />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import Timeline from "./Timeline.vue";
|
||||
import LocationTopMatter from "./top-matter/LocationTopMatter.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "SplitTimeline",
|
||||
|
||||
components: {
|
||||
Timeline,
|
||||
},
|
||||
|
||||
computed: {
|
||||
primary() {
|
||||
switch (this.$route.name) {
|
||||
case "locations":
|
||||
return LocationTopMatter;
|
||||
default:
|
||||
return "None";
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.primary {
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
}
|
||||
.timeline {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
}
|
||||
.primary {
|
||||
width: 100%;
|
||||
height: 40%;
|
||||
}
|
||||
.timeline {
|
||||
width: 100%;
|
||||
height: 60%;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -5,7 +5,7 @@
|
|||
:class="{ 'icon-loading': loading > 0 }"
|
||||
>
|
||||
<!-- Static top matter -->
|
||||
<TopMatter ref="topmatter" @updateBoundary="updateBoundary" />
|
||||
<TopMatter ref="topmatter" />
|
||||
|
||||
<!-- No content found and nothing is loading -->
|
||||
<NcEmptyContent
|
||||
|
@ -140,15 +140,7 @@ import { subscribe, unsubscribe } from "@nextcloud/event-bus";
|
|||
import NcEmptyContent from "@nextcloud/vue/dist/Components/NcEmptyContent";
|
||||
|
||||
import { getLayout } from "../services/Layout";
|
||||
import {
|
||||
IDay,
|
||||
IFolder,
|
||||
IHeadRow,
|
||||
IPhoto,
|
||||
IRow,
|
||||
IRowType,
|
||||
MapBoundary,
|
||||
} from "../types";
|
||||
import { IDay, IFolder, IHeadRow, IPhoto, IRow, IRowType } from "../types";
|
||||
import Folder from "./frame/Folder.vue";
|
||||
import Photo from "./frame/Photo.vue";
|
||||
import Tag from "./frame/Tag.vue";
|
||||
|
@ -231,14 +223,6 @@ export default defineComponent({
|
|||
selectionManager: null as InstanceType<typeof SelectionManager> & any,
|
||||
/** Scroller manager component */
|
||||
scrollerManager: null as InstanceType<typeof ScrollerManager> & any,
|
||||
|
||||
/** The boundary of the map */
|
||||
mapBoundary: {
|
||||
minLat: -90,
|
||||
maxLat: 90,
|
||||
minLng: -180,
|
||||
maxLng: 180,
|
||||
} as MapBoundary,
|
||||
}),
|
||||
|
||||
mounted() {
|
||||
|
@ -341,13 +325,16 @@ export default defineComponent({
|
|||
|
||||
methods: {
|
||||
async routeChange(to: any, from?: any) {
|
||||
if (
|
||||
from?.path !== to.path ||
|
||||
JSON.stringify(from.query) !== JSON.stringify(to.query)
|
||||
) {
|
||||
// Always do a hard refresh if the path changes
|
||||
if (from?.path !== to.path) {
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
// Do a soft refresh if the query changes
|
||||
else if (JSON.stringify(from.query) !== JSON.stringify(to.query)) {
|
||||
await this.softRefresh();
|
||||
}
|
||||
|
||||
// The viewer might change the route immediately again
|
||||
await this.$nextTick();
|
||||
|
||||
|
@ -408,7 +395,9 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
isMobileLayout() {
|
||||
return globalThis.windowInnerWidth <= 600;
|
||||
return (
|
||||
globalThis.windowInnerWidth <= 600 || this.$route.name === "locations"
|
||||
);
|
||||
},
|
||||
|
||||
allowBreakout() {
|
||||
|
@ -700,20 +689,17 @@ export default defineComponent({
|
|||
query.set("tag", <string>this.$route.params.name);
|
||||
}
|
||||
|
||||
// Map Bounds
|
||||
if (this.$route.name === "locations" && this.$route.query.b) {
|
||||
query.set("mapbounds", <string>this.$route.query.b);
|
||||
}
|
||||
|
||||
// Month view
|
||||
if (this.isMonthView) {
|
||||
query.set("monthView", "1");
|
||||
query.set("reverse", "1");
|
||||
}
|
||||
|
||||
// Geological Bounds
|
||||
if (this.$route.name === "locations") {
|
||||
query.set("minLat", "" + this.mapBoundary.minLat);
|
||||
query.set("maxLat", "" + this.mapBoundary.maxLat);
|
||||
query.set("minLng", "" + this.mapBoundary.minLng);
|
||||
query.set("maxLng", "" + this.mapBoundary.maxLng);
|
||||
}
|
||||
|
||||
return query;
|
||||
},
|
||||
|
||||
|
@ -1314,11 +1300,6 @@ export default defineComponent({
|
|||
this.processDay(day.dayid, newDetail);
|
||||
}
|
||||
},
|
||||
|
||||
async updateBoundary(mapBoundary: MapBoundary) {
|
||||
this.mapBoundary = mapBoundary;
|
||||
await this.softRefresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
@ -1347,7 +1328,7 @@ export default defineComponent({
|
|||
will-change: scroll-position;
|
||||
contain: strict;
|
||||
height: 300px;
|
||||
width: calc(100% + 20px);
|
||||
width: 100%;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
:deep .vue-recycle-scroller__slot {
|
||||
|
@ -1365,6 +1346,7 @@ export default defineComponent({
|
|||
&.empty {
|
||||
opacity: 0;
|
||||
transition: none;
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
<template>
|
||||
<div class="location-top-matter">
|
||||
<l-map
|
||||
style="height: 100%; width: 100%; margin-right: 3.5em; z-index: 0"
|
||||
:zoom="zoom"
|
||||
class="map"
|
||||
ref="map"
|
||||
@moveend="updateMapAndTimeline"
|
||||
@zoomend="updateMapAndTimeline"
|
||||
:zoom="zoom"
|
||||
:minZoom="2"
|
||||
@moveend="refresh"
|
||||
@zoomend="refresh"
|
||||
>
|
||||
<l-tile-layer :url="url" :attribution="attribution" />
|
||||
<l-marker
|
||||
|
@ -20,21 +21,20 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
import { defineComponent } from "vue";
|
||||
import { LMap, LTileLayer, LMarker, LPopup } from "vue2-leaflet";
|
||||
import { IMarkerCluster } from "../../types";
|
||||
import { Icon } from "leaflet";
|
||||
|
||||
import { API } from "../../services/API";
|
||||
import axios from "axios";
|
||||
|
||||
import Vue2LeafletMarkerCluster from "vue2-leaflet-markercluster";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { IPhoto, MarkerClusters } from "../../types";
|
||||
|
||||
import { Icon } from "leaflet";
|
||||
import axios from "axios";
|
||||
import { API } from "../../services/API";
|
||||
|
||||
type D = Icon.Default & {
|
||||
_getIconUrl?: string;
|
||||
};
|
||||
|
||||
delete (Icon.Default.prototype as D)._getIconUrl;
|
||||
const TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
const ATTRIBUTION =
|
||||
'© <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
|
||||
|
||||
Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: require("leaflet/dist/images/marker-icon-2x.png"),
|
||||
|
@ -53,63 +53,55 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
data: () => ({
|
||||
name: "locations", // add for test
|
||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
attribution:
|
||||
'© <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors',
|
||||
zoom: 1,
|
||||
clusters: [] as MarkerClusters[],
|
||||
url: TILE_URL,
|
||||
attribution: ATTRIBUTION,
|
||||
zoom: 2,
|
||||
clusters: [] as IMarkerCluster[],
|
||||
}),
|
||||
|
||||
watch: {
|
||||
$route: function (from: any, to: any) {
|
||||
this.createMatter();
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.createMatter();
|
||||
this.updateMapAndTimeline();
|
||||
},
|
||||
const map = this.$refs.map as LMap;
|
||||
|
||||
computed: {
|
||||
getPhotos() {
|
||||
let photos: IPhoto[] = [];
|
||||
return photos;
|
||||
},
|
||||
// Make sure the zoom control doesn't overlap with the navbar
|
||||
map.mapObject.zoomControl.setPosition("topright");
|
||||
|
||||
// Initialize
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
methods: {
|
||||
createMatter() {
|
||||
this.name = <string>this.$route.params.name || "";
|
||||
},
|
||||
async refresh() {
|
||||
const map = this.$refs.map as LMap;
|
||||
|
||||
async updateMapAndTimeline() {
|
||||
let map = this.$refs.map as LMap;
|
||||
let boundary = map.mapObject.getBounds();
|
||||
let minLat = boundary.getSouth();
|
||||
let maxLat = boundary.getNorth();
|
||||
let minLng = boundary.getWest();
|
||||
let maxLng = boundary.getEast();
|
||||
let zoomLevel = map.mapObject.getZoom().toString();
|
||||
// Get boundaries of the map
|
||||
const boundary = map.mapObject.getBounds();
|
||||
const minLat = boundary.getSouth();
|
||||
const maxLat = boundary.getNorth();
|
||||
const minLon = boundary.getWest();
|
||||
const maxLon = boundary.getEast();
|
||||
|
||||
this.$parent.$emit("updateBoundary", {
|
||||
minLat: minLat,
|
||||
maxLat: maxLat,
|
||||
minLng: minLng,
|
||||
maxLng: maxLng,
|
||||
});
|
||||
// Set query parameters to route if required
|
||||
const s = (x: number) => x.toFixed(6);
|
||||
const bounds = `${s(minLat)},${s(maxLat)},${s(minLon)},${s(maxLon)}`;
|
||||
const zoom = map.mapObject.getZoom().toString();
|
||||
if (this.$route.query.b === bounds && this.$route.query.z === zoom) {
|
||||
return;
|
||||
}
|
||||
this.$router.replace({ query: { b: bounds, z: zoom } });
|
||||
|
||||
// Get query parameters for cluster API
|
||||
// const mapWidth = maxLat - minLat;
|
||||
// const mapHeight = maxLon - minLon;
|
||||
|
||||
let mapWidth = maxLat - minLat;
|
||||
let mapHeight = maxLng - minLng;
|
||||
const query = new URLSearchParams();
|
||||
// Show clusters correctly while draging the map
|
||||
query.set("minLat", (minLat - mapWidth).toString());
|
||||
query.set("maxLat", (maxLat + mapWidth).toString());
|
||||
query.set("minLng", (minLng - mapHeight).toString());
|
||||
query.set("maxLng", (maxLng + mapHeight).toString());
|
||||
query.set("zoom", zoomLevel);
|
||||
const query = new URLSearchParams();
|
||||
// query.set("minLat", (minLat - mapWidth).toString());
|
||||
// query.set("maxLat", (maxLat + mapWidth).toString());
|
||||
// query.set("minLon", (minLon - mapHeight).toString());
|
||||
// query.set("maxLon", (maxLon + mapHeight).toString());
|
||||
query.set("zoom", zoom);
|
||||
|
||||
// Make API call
|
||||
const url = API.Q(API.CLUSTERS(), query);
|
||||
const res = await axios.get(url);
|
||||
this.clusters = res.data;
|
||||
|
@ -124,8 +116,14 @@ export default defineComponent({
|
|||
@import "~leaflet.markercluster/dist/MarkerCluster.Default.css";
|
||||
|
||||
.location-top-matter {
|
||||
display: flex;
|
||||
vertical-align: middle;
|
||||
height: 20em;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
<TagTopMatter v-else-if="type === 2" />
|
||||
<FaceTopMatter v-else-if="type === 3" />
|
||||
<AlbumTopMatter v-else-if="type === 4" />
|
||||
<LocationTopMatter v-else-if="type === 5" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -65,8 +64,6 @@ export default defineComponent({
|
|||
return this.$route.params.name
|
||||
? TopMatterType.TAG
|
||||
: TopMatterType.NONE;
|
||||
case "locations":
|
||||
return TopMatterType.LOCATION;
|
||||
default:
|
||||
return TopMatterType.NONE;
|
||||
}
|
||||
|
|
|
@ -87,3 +87,18 @@ aside.app-sidebar {
|
|||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide scrollbar
|
||||
.recycler::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0 !important;
|
||||
}
|
||||
.recycler {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
// Make sure empty content is full width
|
||||
[role="note"].empty-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { translate as t, translatePlural as n } from "@nextcloud/l10n";
|
|||
import Router from "vue-router";
|
||||
import Vue from "vue";
|
||||
import Timeline from "./components/Timeline.vue";
|
||||
import SplitTimeline from "./components/SplitTimeline.vue";
|
||||
|
||||
Vue.use(Router);
|
||||
|
||||
|
@ -141,11 +142,11 @@ export default new Router({
|
|||
|
||||
{
|
||||
path: "/locations",
|
||||
component: Timeline,
|
||||
component: SplitTimeline,
|
||||
name: "locations",
|
||||
props: (route) => ({
|
||||
rootTitle: t("memories", "Locations"),
|
||||
})
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
12
src/types.ts
12
src/types.ts
|
@ -240,14 +240,14 @@ export type ISelectionAction = {
|
|||
allowPublic?: boolean;
|
||||
};
|
||||
|
||||
export type MapBoundary = {
|
||||
export type IMapBoundary = {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
}
|
||||
minLon: number;
|
||||
maxLon: number;
|
||||
};
|
||||
|
||||
export type MarkerClusters = {
|
||||
export type IMarkerCluster = {
|
||||
center: [number, number];
|
||||
count: number;
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue