map: restore functionality

pull/396/head
Varun Patil 2023-02-08 13:35:42 -08:00
parent bcc35d6132
commit 7d90aeacb1
11 changed files with 209 additions and 132 deletions

View File

@ -458,12 +458,13 @@ class ApiBase extends Controller
} }
// Filter geological bounds // Filter geological bounds
$minLat = $this->request->getParam('minLat'); $bounds = $this->request->getParam('mapbounds');
$maxLat = $this->request->getParam('maxLat'); if ($bounds) {
$minLng = $this->request->getParam('minLng'); $bounds = explode(',', $bounds);
$maxLng = $this->request->getParam('maxLng'); $bounds = array_map('floatval', $bounds);
if ($minLat && $maxLat && $minLng && $maxLng) { if (4 === \count($bounds)) {
$transforms[] = [$this->timelineQuery, 'transformBoundFilter', $minLat, $maxLat, $minLng, $maxLng]; $transforms[] = [$this->timelineQuery, 'transformBoundFilter', $bounds];
}
} }
return $transforms; return $transforms;

View File

@ -46,22 +46,24 @@ class LocationController extends ApiBase
// Get the folder to show // Get the folder to show
$root = null; $root = null;
try { try {
$root = $this->getRequestRoot(); $root = $this->getRequestRoot();
} catch (\Exception $e) { } catch (\Exception $e) {
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND); return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND);
} }
// Just check bound parameters instead of using them; they are used in transformation // Make sure we have bounds
$minLat = $this->request->getParam('minLat'); // $bounds = $this->request->getParam('bounds');
$maxLat = $this->request->getParam('maxLat'); // if (!$bounds) {
$minLng = $this->request->getParam('minLng'); // return new JSONResponse(['message' => 'Invalid perameters'], Http::STATUS_PRECONDITION_FAILED);
$maxLng = $this->request->getParam('maxLng'); // }
if (!is_numeric($minLat) || !is_numeric($maxLat) || !is_numeric($minLng) || !is_numeric($maxLng)) { // // Make sure we have 4 bounds
return new JSONResponse(['message' => 'Invalid perameters'], Http::STATUS_PRECONDITION_FAILED); // $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 // Zoom level is used to determine the grid length
$zoomLevel = $this->request->getParam('zoom'); $zoomLevel = $this->request->getParam('zoom');

View File

@ -40,14 +40,14 @@ trait TimelineQueryFilters
$query->setMaxResults($limit); $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->andWhere(
$query->expr()->andX( $query->expr()->andX(
$query->expr()->gte('m.lat', $query->createNamedParameter($minLat, IQueryBuilder::PARAM_STR)), $query->expr()->gte('m.lat', $query->createNamedParameter($bounds[0], IQueryBuilder::PARAM_STR)),
$query->expr()->lte('m.lat', $query->createNamedParameter($maxLat, IQueryBuilder::PARAM_STR)), $query->expr()->lte('m.lat', $query->createNamedParameter($bounds[1], IQueryBuilder::PARAM_STR)),
$query->expr()->gte('m.lon', $query->createNamedParameter($minLng, IQueryBuilder::PARAM_STR)), $query->expr()->gte('m.lon', $query->createNamedParameter($bounds[2], IQueryBuilder::PARAM_STR)),
$query->expr()->lte('m.lon', $query->createNamedParameter($maxLng, IQueryBuilder::PARAM_STR)) $query->expr()->lte('m.lon', $query->createNamedParameter($bounds[3], IQueryBuilder::PARAM_STR))
) )
); );
} }

View File

@ -30,7 +30,12 @@
</NcAppNavigation> </NcAppNavigation>
<NcAppContent> <NcAppContent>
<div class="outer"> <div
:class="{
outer: true,
'remove-gap': removeNavGap,
}"
>
<router-view /> <router-view />
</div> </div>
</NcAppContent> </NcAppContent>
@ -144,6 +149,10 @@ export default defineComponent({
showNavigation(): boolean { showNavigation(): boolean {
return !this.$route.name?.endsWith("-share"); return !this.$route.name?.endsWith("-share");
}, },
removeNavGap(): boolean {
return this.$route.name === "locations";
},
}, },
watch: { watch: {
@ -330,6 +339,10 @@ export default defineComponent({
padding: 0 0 0 44px; padding: 0 0 0 44px;
height: 100%; height: 100%;
width: 100%; width: 100%;
&.remove-gap {
padding: 0;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {

View File

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

View File

@ -5,7 +5,7 @@
:class="{ 'icon-loading': loading > 0 }" :class="{ 'icon-loading': loading > 0 }"
> >
<!-- Static top matter --> <!-- Static top matter -->
<TopMatter ref="topmatter" @updateBoundary="updateBoundary" /> <TopMatter ref="topmatter" />
<!-- No content found and nothing is loading --> <!-- No content found and nothing is loading -->
<NcEmptyContent <NcEmptyContent
@ -140,15 +140,7 @@ import { subscribe, unsubscribe } from "@nextcloud/event-bus";
import NcEmptyContent from "@nextcloud/vue/dist/Components/NcEmptyContent"; import NcEmptyContent from "@nextcloud/vue/dist/Components/NcEmptyContent";
import { getLayout } from "../services/Layout"; import { getLayout } from "../services/Layout";
import { import { IDay, IFolder, IHeadRow, IPhoto, IRow, IRowType } from "../types";
IDay,
IFolder,
IHeadRow,
IPhoto,
IRow,
IRowType,
MapBoundary,
} from "../types";
import Folder from "./frame/Folder.vue"; import Folder from "./frame/Folder.vue";
import Photo from "./frame/Photo.vue"; import Photo from "./frame/Photo.vue";
import Tag from "./frame/Tag.vue"; import Tag from "./frame/Tag.vue";
@ -231,14 +223,6 @@ export default defineComponent({
selectionManager: null as InstanceType<typeof SelectionManager> & any, selectionManager: null as InstanceType<typeof SelectionManager> & any,
/** Scroller manager component */ /** Scroller manager component */
scrollerManager: null as InstanceType<typeof ScrollerManager> & any, scrollerManager: null as InstanceType<typeof ScrollerManager> & any,
/** The boundary of the map */
mapBoundary: {
minLat: -90,
maxLat: 90,
minLng: -180,
maxLng: 180,
} as MapBoundary,
}), }),
mounted() { mounted() {
@ -341,13 +325,16 @@ export default defineComponent({
methods: { methods: {
async routeChange(to: any, from?: any) { async routeChange(to: any, from?: any) {
if ( // Always do a hard refresh if the path changes
from?.path !== to.path || if (from?.path !== to.path) {
JSON.stringify(from.query) !== JSON.stringify(to.query)
) {
await this.refresh(); 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 // The viewer might change the route immediately again
await this.$nextTick(); await this.$nextTick();
@ -408,7 +395,9 @@ export default defineComponent({
}, },
isMobileLayout() { isMobileLayout() {
return globalThis.windowInnerWidth <= 600; return (
globalThis.windowInnerWidth <= 600 || this.$route.name === "locations"
);
}, },
allowBreakout() { allowBreakout() {
@ -700,20 +689,17 @@ export default defineComponent({
query.set("tag", <string>this.$route.params.name); 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 // Month view
if (this.isMonthView) { if (this.isMonthView) {
query.set("monthView", "1"); query.set("monthView", "1");
query.set("reverse", "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; return query;
}, },
@ -1314,11 +1300,6 @@ export default defineComponent({
this.processDay(day.dayid, newDetail); this.processDay(day.dayid, newDetail);
} }
}, },
async updateBoundary(mapBoundary: MapBoundary) {
this.mapBoundary = mapBoundary;
await this.softRefresh();
},
}, },
}); });
</script> </script>
@ -1347,7 +1328,7 @@ export default defineComponent({
will-change: scroll-position; will-change: scroll-position;
contain: strict; contain: strict;
height: 300px; height: 300px;
width: calc(100% + 20px); width: 100%;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
:deep .vue-recycle-scroller__slot { :deep .vue-recycle-scroller__slot {
@ -1365,6 +1346,7 @@ export default defineComponent({
&.empty { &.empty {
opacity: 0; opacity: 0;
transition: none; transition: none;
width: 0;
} }
} }

View File

@ -1,11 +1,12 @@
<template> <template>
<div class="location-top-matter"> <div class="location-top-matter">
<l-map <l-map
style="height: 100%; width: 100%; margin-right: 3.5em; z-index: 0" class="map"
:zoom="zoom"
ref="map" ref="map"
@moveend="updateMapAndTimeline" :zoom="zoom"
@zoomend="updateMapAndTimeline" :minZoom="2"
@moveend="refresh"
@zoomend="refresh"
> >
<l-tile-layer :url="url" :attribution="attribution" /> <l-tile-layer :url="url" :attribution="attribution" />
<l-marker <l-marker
@ -20,21 +21,20 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from "vue"; import { defineComponent } from "vue";
import { LMap, LTileLayer, LMarker, LPopup } from "vue2-leaflet"; 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 Vue2LeafletMarkerCluster from "vue2-leaflet-markercluster";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import { IPhoto, MarkerClusters } from "../../types";
import { Icon } from "leaflet"; const TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
import axios from "axios"; const ATTRIBUTION =
import { API } from "../../services/API"; '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
type D = Icon.Default & {
_getIconUrl?: string;
};
delete (Icon.Default.prototype as D)._getIconUrl;
Icon.Default.mergeOptions({ Icon.Default.mergeOptions({
iconRetinaUrl: require("leaflet/dist/images/marker-icon-2x.png"), iconRetinaUrl: require("leaflet/dist/images/marker-icon-2x.png"),
@ -53,63 +53,55 @@ export default defineComponent({
}, },
data: () => ({ data: () => ({
name: "locations", // add for test url: TILE_URL,
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: ATTRIBUTION,
attribution: zoom: 2,
'&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors', clusters: [] as IMarkerCluster[],
zoom: 1,
clusters: [] as MarkerClusters[],
}), }),
watch: {
$route: function (from: any, to: any) {
this.createMatter();
},
},
mounted() { mounted() {
this.createMatter(); const map = this.$refs.map as LMap;
this.updateMapAndTimeline();
},
computed: { // Make sure the zoom control doesn't overlap with the navbar
getPhotos() { map.mapObject.zoomControl.setPosition("topright");
let photos: IPhoto[] = [];
return photos; // Initialize
}, this.refresh();
}, },
methods: { methods: {
createMatter() { async refresh() {
this.name = <string>this.$route.params.name || ""; const map = this.$refs.map as LMap;
},
async updateMapAndTimeline() { // Get boundaries of the map
let map = this.$refs.map as LMap; const boundary = map.mapObject.getBounds();
let boundary = map.mapObject.getBounds(); const minLat = boundary.getSouth();
let minLat = boundary.getSouth(); const maxLat = boundary.getNorth();
let maxLat = boundary.getNorth(); const minLon = boundary.getWest();
let minLng = boundary.getWest(); const maxLon = boundary.getEast();
let maxLng = boundary.getEast();
let zoomLevel = map.mapObject.getZoom().toString();
this.$parent.$emit("updateBoundary", { // Set query parameters to route if required
minLat: minLat, const s = (x: number) => x.toFixed(6);
maxLat: maxLat, const bounds = `${s(minLat)},${s(maxLat)},${s(minLon)},${s(maxLon)}`;
minLng: minLng, const zoom = map.mapObject.getZoom().toString();
maxLng: maxLng, 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 // Show clusters correctly while draging the map
query.set("minLat", (minLat - mapWidth).toString()); const query = new URLSearchParams();
query.set("maxLat", (maxLat + mapWidth).toString()); // query.set("minLat", (minLat - mapWidth).toString());
query.set("minLng", (minLng - mapHeight).toString()); // query.set("maxLat", (maxLat + mapWidth).toString());
query.set("maxLng", (maxLng + mapHeight).toString()); // query.set("minLon", (minLon - mapHeight).toString());
query.set("zoom", zoomLevel); // query.set("maxLon", (maxLon + mapHeight).toString());
query.set("zoom", zoom);
// Make API call
const url = API.Q(API.CLUSTERS(), query); const url = API.Q(API.CLUSTERS(), query);
const res = await axios.get(url); const res = await axios.get(url);
this.clusters = res.data; this.clusters = res.data;
@ -124,8 +116,14 @@ export default defineComponent({
@import "~leaflet.markercluster/dist/MarkerCluster.Default.css"; @import "~leaflet.markercluster/dist/MarkerCluster.Default.css";
.location-top-matter { .location-top-matter {
display: flex; height: 100%;
vertical-align: middle; width: 100%;
height: 20em; }
.map {
height: 100%;
width: 100%;
margin: 0;
z-index: 0;
} }
</style> </style>

View File

@ -4,7 +4,6 @@
<TagTopMatter v-else-if="type === 2" /> <TagTopMatter v-else-if="type === 2" />
<FaceTopMatter v-else-if="type === 3" /> <FaceTopMatter v-else-if="type === 3" />
<AlbumTopMatter v-else-if="type === 4" /> <AlbumTopMatter v-else-if="type === 4" />
<LocationTopMatter v-else-if="type === 5" />
</div> </div>
</template> </template>
@ -65,8 +64,6 @@ export default defineComponent({
return this.$route.params.name return this.$route.params.name
? TopMatterType.TAG ? TopMatterType.TAG
: TopMatterType.NONE; : TopMatterType.NONE;
case "locations":
return TopMatterType.LOCATION;
default: default:
return TopMatterType.NONE; return TopMatterType.NONE;
} }

View File

@ -87,3 +87,18 @@ aside.app-sidebar {
transform: scale(1.05); 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%;
}

View File

@ -3,6 +3,7 @@ import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import Router from "vue-router"; import Router from "vue-router";
import Vue from "vue"; import Vue from "vue";
import Timeline from "./components/Timeline.vue"; import Timeline from "./components/Timeline.vue";
import SplitTimeline from "./components/SplitTimeline.vue";
Vue.use(Router); Vue.use(Router);
@ -141,11 +142,11 @@ export default new Router({
{ {
path: "/locations", path: "/locations",
component: Timeline, component: SplitTimeline,
name: "locations", name: "locations",
props: (route) => ({ props: (route) => ({
rootTitle: t("memories", "Locations"), rootTitle: t("memories", "Locations"),
}) }),
}, },
], ],
}); });

View File

@ -240,14 +240,14 @@ export type ISelectionAction = {
allowPublic?: boolean; allowPublic?: boolean;
}; };
export type MapBoundary = { export type IMapBoundary = {
minLat: number; minLat: number;
maxLat: number; maxLat: number;
minLng: number; minLon: number;
maxLng: number; maxLon: number;
} };
export type MarkerClusters = { export type IMarkerCluster = {
center: [number, number]; center: [number, number];
count: number; count: number;
} };