diff --git a/.eslintrc.js b/.eslintrc.js index 6c1515c1..a07ecbed 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -20,6 +20,7 @@ module.exports = { "@typescript-eslint/no-non-null-asserted-optional-chain": ["error"], "@typescript-eslint/prefer-as-const": ["error"], "no-restricted-globals": ["error", "$"], + "no-restricted-imports": ["error", "vue/types/umd"], "constructor-super": ["error"], "for-direction": ["error"], diff --git a/client/src/client.ts b/client/src/client.ts index 573f7652..248a08d4 100644 --- a/client/src/client.ts +++ b/client/src/client.ts @@ -28,7 +28,9 @@ export interface ClientEvents extends MapEvents { route: [RouteWithTrackPoints | undefined]; - emit: { [eventName in RequestName]: [eventName, RequestData] }[RequestName] + emit: { [eventName in RequestName]: [eventName, RequestData] }[RequestName], + emitResolve: { [eventName in RequestName]: [eventName, ResponseData] }[RequestName], + emitReject: { [eventName in RequestName]: [eventName, Error] }[RequestName] } const MANAGER_EVENTS: Array> = ['error', 'reconnect', 'reconnect_attempt', 'reconnect_error', 'reconnect_failed']; @@ -105,7 +107,7 @@ export default class Client { on>(eventName: E, fn: EventHandler): void { if(!this._listeners[eventName]) { - (MANAGER_EVENTS.includes(eventName) ? this.socket.io : this.socket) + (MANAGER_EVENTS.includes(eventName) ? this.socket.io as any : this.socket) .on(eventName, (...[data]: ClientEvents[E]) => { this._simulateEvent(eventName as any, data); }); } @@ -135,10 +137,13 @@ export default class Client { return await new Promise((resolve, reject) => { this.socket.emit(eventName, data, (err: Error, data: ResponseData) => { - if(err) + if(err) { reject(err); - else + this._simulateEvent("emitReject", eventName as any, err); + } else { resolve(data); + this._simulateEvent("emitResolve", eventName as any, data as any); + } }); }); } finally { @@ -318,8 +323,11 @@ export default class Client { return marker; } - addMarker(data: MarkerCreate): Promise { - return this._emit("addMarker", data); + async addMarker(data: MarkerCreate): Promise { + const marker = await this._emit("addMarker", data); + // If the marker is out of view, we will not recieve it in an event. Add it here manually to make sure that we have it. + this._set(this.markers, marker.id, marker); + return marker; } editMarker(data: ObjectWithId & MarkerUpdate): Promise { diff --git a/docker-compose.yml b/docker-compose.yml index 65c6483e..a7e4adee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,4 +20,10 @@ services: MYSQL_DATABASE: facilmap MYSQL_USER: facilmap MYSQL_PASSWORD: facilmap - MYSQL_RANDOM_ROOT_PASSWORD: "true" \ No newline at end of file + MYSQL_RANDOM_ROOT_PASSWORD: "true" + phpmyadmin: + image: phpmyadmin + links: + - mysql:db + ports: + - 127.0.0.1:8090:80 \ No newline at end of file diff --git a/frontend/app/search/search-import.js b/frontend/app/search/search-import.js index 94b6f42b..3379cf4b 100644 --- a/frontend/app/search/search-import.js +++ b/frontend/app/search/search-import.js @@ -1,21 +1,5 @@ import fm from '../app'; -function _lineStringToTrackPoints(geometry) { - var ret = [ ]; - var coords = (["MultiPolygon", "MultiLineString"].indexOf(geometry.type) != -1 ? geometry.coordinates : [ geometry.coordinates ]); - - // Take only outer ring of polygons - if(["MultiPolygon", "Polygon"].indexOf(geometry.type) != -1) - coords = coords.map((coordArr) => coordArr[0]); - - coords.forEach(function(linePart) { - linePart.forEach(function(latlng) { - ret.push({ lat: latlng[1], lon: latlng[0] }); - }); - }); - return ret; -} - fm.app.factory("fmSearchImport", function($uibModal, $rootScope) { return function(map) { let importUi = { @@ -34,60 +18,6 @@ fm.app.factory("fmSearchImport", function($uibModal, $rootScope) { } }); }, - - addResultToMap(result, type, showEdit) { - let obj = { - name: result.short_name - }; - - if(result.fmProperties) { // Import GeoJSON - Object.assign(obj, result.fmProperties); - delete obj.typeId; - } else { - obj.data = importUi._mapSearchResultToType(result, type) - } - - if(type.type == "marker") { - return map.markersUi.createMarker(result.lat != null && result.lon != null ? result : { lat: result.geojson.coordinates[1], lon: result.geojson.coordinates[0] }, type, obj, !showEdit); - } else if(type.type == "line") { - if(!result.fmProperties || !result.fmProperties.routePoints) { - var trackPoints = _lineStringToTrackPoints(result.geojson); - $.extend(obj, { trackPoints: trackPoints, mode: "track" }); - return map.linesUi.createLine(type, [ trackPoints[0], trackPoints[trackPoints.length-1] ], obj, !showEdit); - } else { - return map.linesUi.createLine(type, obj.routePoints, obj, !showEdit); - } - } - }, - - /** - * Prefills the fields of a type with information from a search result. The "address" and "extratags" from - * the search result are taken matched whose names equal the tag key (ignoring case and non-letters). The - * returned object is an object that can be used as "data" for a marker and line, so an object that maps - * field names to values. - */ - _mapSearchResultToType(result, type) { - let keyMap = (keys) => { - let ret = {}; - for(let key of keys) - ret[key.replace(/[^a-z0-9]/gi, "").toLowerCase()] = key; - return ret; - }; - - let resultData = Object.assign({ - address: result.address - }, result.extratags); - - let fieldKeys = keyMap(type.fields.map((field) => (field.name))); - let resultDataKeys = keyMap(Object.keys(resultData)); - - let ret = {}; - for(let key in resultDataKeys) { - if(fieldKeys[key]) - ret[fieldKeys[key]] = resultData[resultDataKeys[key]]; - } - return ret; - } }; return importUi; diff --git a/frontend/src/map/file-results/file-results.vue b/frontend/src/map/file-results/file-results.vue index cfd1e4c8..c41d0535 100644 --- a/frontend/src/map/file-results/file-results.vue +++ b/frontend/src/map/file-results/file-results.vue @@ -14,7 +14,7 @@ {{" "}} (View) - + @@ -31,7 +31,7 @@ {{" "}} (Type) - + diff --git a/frontend/src/map/import/import.ts b/frontend/src/map/import/import.ts index 73e1d113..aa120172 100644 --- a/frontend/src/map/import/import.ts +++ b/frontend/src/map/import/import.ts @@ -31,16 +31,16 @@ export default class Import extends Vue { } mounted(): void { - this.$root.$on("fm-import-file", this.handleImportFile); - this.$root.$on("fm-open-selection", this.handleOpenSelection); + this.mapContext.$on("fm-import-file", this.handleImportFile); + this.mapContext.$on("fm-open-selection", this.handleOpenSelection); this.mapComponents.container.addEventListener("dragenter", this.handleMapDragEnter); this.mapComponents.container.addEventListener("dragover", this.handleMapDragOver); this.mapComponents.container.addEventListener("drop", this.handleMapDrop); } beforeDestroy(): void { - this.$root.$off("fm-import-file", this.handleImportFile); - this.$root.$off("fm-open-selection", this.handleOpenSelection); + this.mapContext.$off("fm-import-file", this.handleImportFile); + this.mapContext.$off("fm-open-selection", this.handleOpenSelection); this.mapComponents.container.removeEventListener("dragenter", this.handleMapDragEnter); this.mapComponents.container.removeEventListener("dragover", this.handleMapDragOver); this.mapComponents.container.removeEventListener("drop", this.handleMapDrop); @@ -59,7 +59,7 @@ export default class Import extends Vue { handleOpenSelection(): void { for (let i = 0; i < this.layerIds.length; i++) { if (this.mapContext.selection.some((item) => item.type == "searchResult" && item.layerId == this.layerIds[i])) { - this.$root.$emit("fm-search-box-show-tab", `fm-import-tab-${i}`); + this.mapContext.$emit("fm-search-box-show-tab", `fm-import-tab-${i}`); break; } } @@ -128,7 +128,7 @@ export default class Import extends Vue { this.files.push(result); this.layers.push(layer); setTimeout(() => { - this.$root.$emit("fm-search-box-show-tab", `fm-import-tab-${this.files.length -1}`); + this.mapContext.$emit("fm-search-box-show-tab", `fm-import-tab-${this.files.length -1}`); }, 0); } } catch (err) { diff --git a/frontend/src/map/leaflet-map/events.ts b/frontend/src/map/leaflet-map/events.ts new file mode 100644 index 00000000..b65e645f --- /dev/null +++ b/frontend/src/map/leaflet-map/events.ts @@ -0,0 +1,31 @@ +import { EventHandler, EventName, FindOnMapResult, SearchResult } from "facilmap-types"; +import Vue from "vue"; +import { SelectedItem } from "../../utils/selection"; + +export interface MapContextEvents { + "fm-import-file": [] + "fm-open-selection": [selection: SelectedItem[]], + "fm-search-box-show-tab": [id: string, expand?: boolean]; + "fm-route-set-queries": [queries: string[]]; + "fm-route-set-from": [query: string, searchSuggestions?: SearchResult[], mapSuggestions?: FindOnMapResult[], selectedSuggestion?: SearchResult | FindOnMapResult]; + "fm-route-add-via": [query: string, searchSuggestions?: SearchResult[], mapSuggestions?: FindOnMapResult[], selectedSuggestion?: SearchResult | FindOnMapResult]; + "fm-route-set-to": [query: string, searchSuggestions?: SearchResult[], mapSuggestions?: FindOnMapResult[], selectedSuggestion?: SearchResult | FindOnMapResult]; +} + +export interface EventBus { + $on>(event: E, callback: EventHandler): void; + $once>(event: E, callback: EventHandler): void; + $off>(event: E, callback: EventHandler): void; + $emit>(event: E, ...args: MapContextEvents[E]): void; +} + +export function createEventBus(): EventBus { + const bus = new Vue(); + + return { + $on: (...args) => { bus.$on(...args); }, + $once: (...args) => { bus.$once(...args); }, + $off: (...args) => { bus.$off(...args); }, + $emit: (...args) => { bus.$emit(...args); }, + }; +} \ No newline at end of file diff --git a/frontend/src/map/leaflet-map/leaflet-map.ts b/frontend/src/map/leaflet-map/leaflet-map.ts index 0005003f..c2e78afc 100644 --- a/frontend/src/map/leaflet-map/leaflet-map.ts +++ b/frontend/src/map/leaflet-map/leaflet-map.ts @@ -18,6 +18,7 @@ import SelectionHandler, { SelectedItem } from "../../utils/selection"; import { FilterFunc } from "facilmap-utils"; import { getHashQuery } from "../../utils/zoom"; import context from "../context"; +import { createEventBus, EventBus } from "./events"; /* function createButton(symbol: string, onClick: () => void): Control { return Object.assign(new Control(), { @@ -51,7 +52,7 @@ export interface MapComponents { selectionHandler: SelectionHandler; } -export interface MapContext { +export interface MapContext extends EventBus { center: LatLng; zoom: number; layers: VisibleLayers; @@ -131,7 +132,8 @@ export default class LeafletMap extends Vue { hash: location.hash.replace(/^#/, ""), showToolbox: false, selection: [], - interaction: false + interaction: false, + ...createEventBus() }; map.on("moveend", () => { @@ -168,7 +170,7 @@ export default class LeafletMap extends Vue { if (event.open) { setTimeout(() => { - this.$root.$emit("fm-open-selection", selection); + this.mapContext.$emit("fm-open-selection", selection); }, 0); } }); diff --git a/frontend/src/map/line-info/line-info-tab.ts b/frontend/src/map/line-info/line-info-tab.ts index a1f46a47..18cd2a7e 100644 --- a/frontend/src/map/line-info/line-info-tab.ts +++ b/frontend/src/map/line-info/line-info-tab.ts @@ -18,11 +18,11 @@ export default class LineInfoTab extends Vue { @InjectMapComponents() mapComponents!: MapComponents; mounted(): void { - this.$root.$on("fm-open-selection", this.handleOpenSelection); + this.mapContext.$on("fm-open-selection", this.handleOpenSelection); } beforeDestroy(): void { - this.$root.$off("fm-open-selection", this.handleOpenSelection); + this.mapContext.$off("fm-open-selection", this.handleOpenSelection); } get lineId(): ID | undefined { @@ -51,7 +51,7 @@ export default class LineInfoTab extends Vue { handleOpenSelection(): void { if (this.line) - this.$root.$emit("fm-search-box-show-tab", "fm-line-info-tab") + this.mapContext.$emit("fm-search-box-show-tab", "fm-line-info-tab") } } \ No newline at end of file diff --git a/frontend/src/map/line-info/line-info.scss b/frontend/src/map/line-info/line-info.scss new file mode 100644 index 00000000..d09ae9cc --- /dev/null +++ b/frontend/src/map/line-info/line-info.scss @@ -0,0 +1,17 @@ +.fm-line-info { + display: flex; + flex-direction: column; + min-height: 0; + flex-grow: 1; + + .fm-search-box-collapse-point { + display: flex; + flex-direction: column; + min-height: 1.5em; + flex-grow: 1; + } + + .fm-elevation-plot { + margin-bottom: 1rem; + } +} \ No newline at end of file diff --git a/frontend/src/map/line-info/line-info.ts b/frontend/src/map/line-info/line-info.ts index 63de6554..e4f44c4c 100644 --- a/frontend/src/map/line-info/line-info.ts +++ b/frontend/src/map/line-info/line-info.ts @@ -1,7 +1,7 @@ import WithRender from "./line-info.vue"; import Vue from "vue"; import { Component, Prop } from "vue-property-decorator"; -import { ID, Line } from "facilmap-types"; +import { ExportFormat, ID, Line } from "facilmap-types"; import { IdType } from "../../utils/utils"; import Client from "facilmap-client"; import { InjectClient, InjectMapComponents, InjectMapContext } from "../../utils/decorators"; @@ -11,6 +11,8 @@ import ElevationStats from "../ui/elevation-stats/elevation-stats"; import { MapComponents, MapContext } from "../leaflet-map/leaflet-map"; import ElevationPlot from "../ui/elevation-plot/elevation-plot"; import Icon from "../ui/icon/icon"; +import "./line-info.scss"; +import { flyTo, getZoomDestinationForLine } from "../../utils/zoom"; @WithRender @Component({ @@ -47,4 +49,23 @@ export default class LineInfo extends Vue { } } + zoomToLine(): void { + if (this.line) + flyTo(this.mapComponents.map, getZoomDestinationForLine(this.line)); + } + + async exportRoute(format: ExportFormat): Promise { + if (!this.line) + return; + + this.$bvToast.hide("fm-line-info-export-error"); + + try { + const exported = await this.client.exportLine({ id: this.line.id, format }); + saveAs(new Blob([exported], { type: "application/gpx+xml" }), `${this.line.name}.gpx`); + } catch(err) { + showErrorToast(this, "fm-line-info-export-error", "Error exporting line", err); + } + } + } \ No newline at end of file diff --git a/frontend/src/map/line-info/line-info.vue b/frontend/src/map/line-info/line-info.vue index 1df6c2ef..39730897 100644 --- a/frontend/src/map/line-info/line-info.vue +++ b/frontend/src/map/line-info/line-info.vue @@ -1,45 +1,57 @@

{{line.name}}

- + + + +
-
-
Distance
-
{{line.distance | round(2)}} km ({{line.time | fmFormatTime}} h {{line.mode | fmRouteMode}})
+
+
+
Distance
+
{{line.distance | round(2)}} km ({{line.time | fmFormatTime}} h {{line.mode | fmRouteMode}})
- + - -
+ +
- + +
+ + + + + + Export as GPX track + Export as GPX route + -
Edit data + + Remove - -
+
\ No newline at end of file diff --git a/frontend/src/map/map.scss b/frontend/src/map/map.scss index 07571d11..ee9ce48c 100644 --- a/frontend/src/map/map.scss +++ b/frontend/src/map/map.scss @@ -13,6 +13,6 @@ .btn-toolbar { > * + * { - margin-left: 0.5rem; + margin-left: 0.25rem; } } \ No newline at end of file diff --git a/frontend/src/map/marker-info/marker-info-tab.ts b/frontend/src/map/marker-info/marker-info-tab.ts index 3e26d95f..d812ff44 100644 --- a/frontend/src/map/marker-info/marker-info-tab.ts +++ b/frontend/src/map/marker-info/marker-info-tab.ts @@ -18,11 +18,11 @@ export default class MarkerInfoTab extends Vue { @InjectMapComponents() mapComponents!: MapComponents; mounted(): void { - this.$root.$on("fm-open-selection", this.handleOpenSelection); + this.mapContext.$on("fm-open-selection", this.handleOpenSelection); } beforeDestroy(): void { - this.$root.$off("fm-open-selection", this.handleOpenSelection); + this.mapContext.$off("fm-open-selection", this.handleOpenSelection); } get markerId(): ID | undefined { @@ -44,7 +44,7 @@ export default class MarkerInfoTab extends Vue { handleOpenSelection(): void { if (this.marker) - this.$root.$emit("fm-search-box-show-tab", "fm-marker-info-tab"); + this.mapContext.$emit("fm-search-box-show-tab", "fm-marker-info-tab"); } get title(): string | undefined { diff --git a/frontend/src/map/marker-info/marker-info.scss b/frontend/src/map/marker-info/marker-info.scss new file mode 100644 index 00000000..6f7182d9 --- /dev/null +++ b/frontend/src/map/marker-info/marker-info.scss @@ -0,0 +1,9 @@ +.fm-marker-info { + display: flex; + flex-direction: column; + min-height: 0; + + .fm-search-box-collapse-point { + min-height: 1.5em; + } +} \ No newline at end of file diff --git a/frontend/src/map/marker-info/marker-info.ts b/frontend/src/map/marker-info/marker-info.ts index fcba5c08..25b2b41f 100644 --- a/frontend/src/map/marker-info/marker-info.ts +++ b/frontend/src/map/marker-info/marker-info.ts @@ -1,7 +1,7 @@ import WithRender from "./marker-info.vue"; import Vue from "vue"; import { Component, Prop } from "vue-property-decorator"; -import { ID, Marker } from "facilmap-types"; +import { FindOnMapResult, ID, Marker } from "facilmap-types"; import { IdType } from "../../utils/utils"; import Client from "facilmap-client"; import { moveMarker } from "../../utils/draw"; @@ -9,12 +9,13 @@ import { InjectClient, InjectMapComponents, InjectMapContext } from "../../utils import { showErrorToast } from "../../utils/toasts"; import EditMarker from "../edit-marker/edit-marker"; import { MapComponents, MapContext } from "../leaflet-map/leaflet-map"; +import "./marker-info.scss"; +import { flyTo, getZoomDestinationForMarker } from "../../utils/zoom"; +import Icon from "../ui/icon/icon"; @WithRender @Component({ - components: { - EditMarker - } + components: { EditMarker, Icon } }) export default class MarkerInfo extends Vue { @@ -50,10 +51,30 @@ export default class MarkerInfo extends Vue { } } - /* - scope.useForRoute = function(mode) { - map.searchUi.setRouteDestination(`${marker.lat},${marker.lon}`, mode); - }; - */ + zoomToMarker(): void { + if (this.marker) + flyTo(this.mapComponents.map, getZoomDestinationForMarker(this.marker)); + } + + useAs(event: "fm-route-set-from" | "fm-route-add-via" | "fm-route-set-to"): void { + if (!this.marker) + return; + + const markerSuggestion: FindOnMapResult = { ...this.marker, kind: "marker", similarity: 1 }; + this.mapContext.$emit(event, this.marker.name, [], [markerSuggestion], markerSuggestion); + this.mapContext.$emit("fm-search-box-show-tab", "fm-route-form-tab"); + } + + useAsFrom(): void { + this.useAs("fm-route-set-from"); + } + + useAsVia(): void { + this.useAs("fm-route-add-via"); + } + + useAsTo(): void { + this.useAs("fm-route-set-to"); + } } \ No newline at end of file diff --git a/frontend/src/map/marker-info/marker-info.vue b/frontend/src/map/marker-info/marker-info.vue index 5b7f65d0..2b7b0c27 100644 --- a/frontend/src/map/marker-info/marker-info.vue +++ b/frontend/src/map/marker-info/marker-info.vue @@ -1,6 +1,6 @@

{{marker.name}}

-
+
Coordinates
{{marker.lat | round(5)}}, {{marker.lon | round(5)}}
@@ -15,21 +15,19 @@
-
+ + + + + Route start + Route via + Route destination + + Edit data Move Remove - -
+
\ No newline at end of file diff --git a/frontend/src/map/route-form/route-form-tab.ts b/frontend/src/map/route-form/route-form-tab.ts index b069b826..fc119015 100644 --- a/frontend/src/map/route-form/route-form-tab.ts +++ b/frontend/src/map/route-form/route-form-tab.ts @@ -18,7 +18,7 @@ export default class RouteFormTab extends Vue { tabActive = false; activate(): void { - this.$root.$emit("fm-search-box-show-tab", "fm-route-form-tab"); + this.mapContext.$emit("fm-search-box-show-tab", "fm-route-form-tab"); } } \ No newline at end of file diff --git a/frontend/src/map/route-form/route-form.scss b/frontend/src/map/route-form/route-form.scss index ead70aff..7b51f7b7 100644 --- a/frontend/src/map/route-form/route-form.scss +++ b/frontend/src/map/route-form/route-form.scss @@ -35,6 +35,7 @@ .fm-route-suggestions.show { display: grid !important; grid-template-columns: auto 1fr; + opacity: 0.6; &.isPending { display: flex !important; diff --git a/frontend/src/map/route-form/route-form.ts b/frontend/src/map/route-form/route-form.ts index bd4114d2..a6ebee71 100644 --- a/frontend/src/map/route-form/route-form.ts +++ b/frontend/src/map/route-form/route-form.ts @@ -52,16 +52,15 @@ function makeCoordDestination(latlng: LatLng) { }; } -/* function _setDestination(dest, query, searchSuggestions, mapSuggestions, selectedSuggestion) { - dest.query = query; - - if(searchSuggestions) { - dest.searchSuggestions = searchSuggestions; - dest.mapSuggestions = mapSuggestions && mapSuggestions.filter((suggestion) => (suggestion.kind == "marker")); - dest.loadingQuery = dest.loadedQuery = query; - dest.selectedSuggestion = selectedSuggestion; - } -} */ +function makeDestination(query: string, searchSuggestions?: SearchResult[], mapSuggestions?: FindOnMapResult[], selectedSuggestion?: SearchResult | FindOnMapResult): Destination { + return { + query, + loadedQuery: searchSuggestions || mapSuggestions ? query : undefined, + searchSuggestions, + mapSuggestions: mapSuggestions?.filter((result) => result.kind == "marker") as MapSuggestion[], + selectedSuggestion: selectedSuggestion as MapSuggestion + }; +} const startMarkerColour = "00ff00"; const dragMarkerColour = "ffd700"; @@ -103,6 +102,11 @@ export default class RouteForm extends Vue { suggestionMarker: MarkerLayer | undefined; mounted(): void { + this.mapContext.$on("fm-route-set-queries", this.setQueries); + this.mapContext.$on("fm-route-set-from", this.setFrom); + this.mapContext.$on("fm-route-add-via", this.addVia); + this.mapContext.$on("fm-route-set-to", this.setTo); + this.routeLayer = new RouteLayer(this.client, { weight: 7, opacity: 1, raised: true }).addTo(this.mapComponents.map); this.routeLayer.on("click", (e) => { if (!this.active && !(e.originalEvent as any).ctrlKey && !(e.originalEvent as any).shiftKey) { @@ -180,6 +184,11 @@ export default class RouteForm extends Vue { } beforeDestroy(): void { + this.mapContext.$off("fm-route-set-queries", this.setQueries); + this.mapContext.$off("fm-route-set-from", this.setFrom); + this.mapContext.$off("fm-route-add-via", this.addVia); + this.mapContext.$off("fm-route-set-to", this.setTo); + this.draggable.disable(); this.routeLayer.remove(); } @@ -300,7 +309,7 @@ export default class RouteForm extends Vue { marker: { colour: dragMarkerColour, size: 35, - symbol: (suggestion as any).icon || (suggestion as any).symbol, + symbol: "", shape: "drop" } })).addTo(this.mapComponents.map); @@ -346,7 +355,7 @@ export default class RouteForm extends Vue { return null; } - async route(zoom = true): Promise { + async route(zoom: boolean): Promise { this.reset(); if(this.destinations[0].query.trim() == "" || this.destinations[this.destinations.length-1].query.trim() == "") @@ -392,7 +401,7 @@ export default class RouteForm extends Vue { } } - reroute(zoom = true): void { + reroute(zoom: boolean): void { if(this.hasRoute) this.route(zoom); } @@ -420,6 +429,11 @@ export default class RouteForm extends Vue { ]; } + zoomToRoute(): void { + if (this.client.route) + flyTo(this.mapComponents.map, getZoomDestinationForRoute(this.client.route)); + } + handleSubmit(event: Event): void { this.submitButton.focus(); this.route(true); @@ -448,37 +462,31 @@ export default class RouteForm extends Vue { } } - /* const routeUi = searchUi.routeUi = { - setQueries: function(queries) { - scope.submittedQueries = null; - scope.submittedMode = null; - scope.destinations = [ ]; + setQueries(queries: string[]): void { + this.clear(); + this.destinations = queries.map((query) => ({ query })); + while (this.destinations.length < 2) + this.destinations.push({ query: "" }); + this.route(true); + } - for(const i=0; i): void { + Vue.set(this.destinations, 0, makeDestination(...args)); + this.reroute(true); + } - $.extend(scope.destinations[i], typeof queries[i] == "object" ? queries[i] : { query: queries[i] }); - } + addVia(...args: Parameters): void { + this.destinations.splice(this.destinations.length - 1, 0, makeDestination(...args)); + this.reroute(true); + } - while(scope.destinations.length < 2) - scope.addDestination(); - }, + setTo(...args: Parameters): void { + Vue.set(this.destinations, this.destinations.length - 1, makeDestination(...args)); + this.reroute(true); + } - setFrom: function(from, searchSuggestions, mapSuggestions, selectedSuggestion) { - _setDestination(scope.destinations[0], from, searchSuggestions, mapSuggestions, selectedSuggestion); - }, - - addVia: function(via, searchSuggestions, mapSuggestions, selectedSuggestion) { - scope.addDestination(); - const newDest = scope.destinations.pop(); - _setDestination(newDest, via, searchSuggestions, mapSuggestions, selectedSuggestion); - scope.destinations.splice(scope.destinations.length-1, 0, newDest); - }, - - setTo: function(to, searchSuggestions, mapSuggestions, selectedSuggestion) { - _setDestination(scope.destinations[scope.destinations.length-1], to, searchSuggestions, mapSuggestions, selectedSuggestion); - }, + /* TODO + const routeUi = searchUi.routeUi = { setMode: function(mode) { scope.routeMode = mode; @@ -492,14 +500,6 @@ export default class RouteForm extends Vue { return scope.destinations.map((destination) => (destination.query)); }, - getMode: function() { - return scope.submittedMode; - }, - - submit: function(noZoom) { - scope.route(noZoom); - }, - getSubmittedSearch() { const queries = routeUi.getQueries(); if(queries) @@ -511,9 +511,5 @@ export default class RouteForm extends Vue { if(zoomDestination) return map.map.getZoom() == zoomDestination[1] && fmUtils.pointsEqual(map.map.getCenter(), zoomDestination[0], map.map); }, - - hasResults() { - return map.routeUi.routes.length > 0 - } }; */ } diff --git a/frontend/src/map/route-form/route-form.vue b/frontend/src/map/route-form/route-form.vue index 95a6e2ad..eec3ad70 100644 --- a/frontend/src/map/route-form/route-form.vue +++ b/frontend/src/map/route-form/route-form.vue @@ -15,15 +15,15 @@ @@ -35,23 +35,23 @@ {{suggestion.display_name}} ({{suggestion.type}}) - + @@ -59,12 +59,12 @@ - + Go! - + - - \ No newline at end of file + + + + {{type.name}} + + Add to map + + Route start + Route via + Route destination + + + \ No newline at end of file diff --git a/frontend/src/map/search-results/search-results.scss b/frontend/src/map/search-results/search-results.scss index d37a6de6..54749051 100644 --- a/frontend/src/map/search-results/search-results.scss +++ b/frontend/src/map/search-results/search-results.scss @@ -27,10 +27,13 @@ } .carousel-caption { - overflow: auto; position: static; padding: 0; color: inherit; text-align: inherit; } + + .fm-search-box-collapse-point { + min-height: 3em; + } } \ No newline at end of file diff --git a/frontend/src/map/search-results/search-results.ts b/frontend/src/map/search-results/search-results.ts index 3e6bcac0..6f8bc83f 100644 --- a/frontend/src/map/search-results/search-results.ts +++ b/frontend/src/map/search-results/search-results.ts @@ -1,7 +1,7 @@ import WithRender from "./search-results.vue"; import Vue from "vue"; -import { Component, Prop } from "vue-property-decorator"; -import { FindOnMapResult, SearchResult } from "facilmap-types"; +import { Component, Prop, Watch } from "vue-property-decorator"; +import { FindOnMapResult, LineCreate, MarkerCreate, SearchResult, Type } from "facilmap-types"; import "./search-results.scss"; import Icon from "../ui/icon/icon"; import Client from "facilmap-client"; @@ -10,6 +10,11 @@ import context from "../context"; import SearchResultInfo from "../search-result-info/search-result-info"; import { MapComponents, MapContext } from "../leaflet-map/leaflet-map"; import { SelectedItem } from "../../utils/selection"; +import { Point } from "geojson"; +import { FileResult } from "../../utils/files"; +import { showErrorToast } from "../../utils/toasts"; +import { lineStringToTrackPoints, mapSearchResultToType } from "./utils"; +import { isFileResult, isSearchResult } from "../../utils/search"; @WithRender @Component({ @@ -21,7 +26,7 @@ export default class SearchResults extends Vue { @InjectMapContext() mapContext!: MapContext; @InjectMapComponents() mapComponents!: MapComponents; - @Prop({ type: Array }) searchResults?: SearchResult[]; + @Prop({ type: Array }) searchResults?: Array; @Prop({ type: Array }) mapResults?: FindOnMapResult[]; @Prop({ type: Boolean, default: false }) showZoom!: boolean; @Prop({ type: Number, required: true }) layerId!: number; @@ -57,6 +62,12 @@ export default class SearchResults extends Vue { this.activeTab = 0; } + @Watch("openResult") + handleOpenResultChange(openResult: SearchResult | undefined): void { + if (!openResult && this.activeTab != 0) + this.activeTab = 0; + } + handleClick(result: SearchResult | FindOnMapResult, event: MouseEvent): void { this.selectResult(result, event.ctrlKey || event.shiftKey); this.$emit('click-result', result); @@ -69,10 +80,12 @@ export default class SearchResults extends Vue { handleOpen(result: SearchResult | FindOnMapResult, event: MouseEvent): void { this.selectResult(result, false); - setTimeout(() => { - if ("kind" in result) - this.$root.$emit("fm-search-box-show-tab", "fm-marker-info-tab", false); - else + setTimeout(async () => { + if ("kind" in result) { + if (result.kind == "marker" && !this.client.markers[result.id]) + await this.client.getMarker({ id: result.id }); + this.mapContext.$emit("fm-search-box-show-tab", "fm-marker-info-tab", false); + } else this.activeTab = 1; }, 0); } @@ -85,4 +98,77 @@ export default class SearchResults extends Vue { this.mapComponents.selectionHandler.setSelectedItems([item]); } + async addToMap(results: Array, type: Type): Promise { + this.$bvToast.hide("fm-search-result-info-add-error"); + + try { + for (const result of results) { + const obj: Partial = { + name: result.short_name + }; + + if("fmProperties" in result && result.fmProperties) { // Import GeoJSON + Object.assign(obj, result.fmProperties); + delete obj.typeId; + } else { + obj.data = mapSearchResultToType(result, type) + } + + if(type.type == "marker") { + const marker = await this.client.addMarker({ + ...obj, + lat: result.lat ?? (result.geojson as Point).coordinates[1], + lon: result.lon ?? (result.geojson as Point).coordinates[0], + typeId: type.id + }); + + this.mapComponents.selectionHandler.setSelectedItems([{ type: "marker", id: marker.id }], true); + } else if(type.type == "line") { + if (obj.routePoints) { + const line = await this.client.addLine({ + ...obj, + routePoints: obj.routePoints, + typeId: type.id + }); + + this.mapComponents.selectionHandler.setSelectedItems([{ type: "line", id: line.id }], true); + } else { + const trackPoints = lineStringToTrackPoints(result.geojson as any); + const line = await this.client.addLine({ + ...obj, + typeId: type.id, + routePoints: [trackPoints[0], trackPoints[trackPoints.length-1]], + trackPoints: trackPoints, + mode: "track" + }); + + this.mapComponents.selectionHandler.setSelectedItems([{ type: "line", id: line.id }], true); + } + } + } + } catch (err) { + showErrorToast(this, "fm-search-result-info-add-error", "Error adding to map", err); + } + } + + useAs(result: SearchResult | FileResult, event: "fm-route-set-from" | "fm-route-add-via" | "fm-route-set-to"): void { + if (isFileResult(result)) + this.mapContext.$emit(event, `${result.lat},${result.lon}`); + else + this.mapContext.$emit(event, result.short_name, this.searchResults, this.mapResults, result); + this.mapContext.$emit("fm-search-box-show-tab", "fm-route-form-tab"); + } + + useAsFrom(result: SearchResult | FileResult): void { + this.useAs(result, "fm-route-set-from"); + } + + useAsVia(result: SearchResult | FileResult): void { + this.useAs(result, "fm-route-add-via"); + } + + useAsTo(result: SearchResult | FileResult): void { + this.useAs(result, "fm-route-set-to"); + } + } \ No newline at end of file diff --git a/frontend/src/map/search-results/search-results.vue b/frontend/src/map/search-results/search-results.vue index b88757d2..50558c7a 100644 --- a/frontend/src/map/search-results/search-results.vue +++ b/frontend/src/map/search-results/search-results.vue @@ -3,35 +3,37 @@ No results have been found. - +
+ - - - - {{result.name}} - {{" "}} - ({{client.types[result.typeId].name}}) - - - - - + + + + {{result.name}} + {{" "}} + ({{client.types[result.typeId].name}}) + + + + + -
+
- - - - {{result.display_name}} - {{" "}} - ({{result.type}}) - - - - - + + + + {{result.display_name}} + {{" "}} + ({{result.type}}) + + + + + - + +
Open file - Export as GeoJSON - Export as GPX (tracks) - Export as GPX (routes) + Export as GeoJSON + Export as GPX (tracks) + Export as GPX (routes) Export as table Filter diff --git a/frontend/src/map/ui/elevation-plot/elevation-plot.scss b/frontend/src/map/ui/elevation-plot/elevation-plot.scss index 78bfd46c..4ed0ef27 100644 --- a/frontend/src/map/ui/elevation-plot/elevation-plot.scss +++ b/frontend/src/map/ui/elevation-plot/elevation-plot.scss @@ -2,6 +2,7 @@ flex-grow: 1; flex-basis: 12rem; overflow: hidden; + min-height: 6.5rem; .heightgraph-toggle, .heightgraph-close-icon { display: none !important; diff --git a/frontend/src/map/ui/elevation-plot/elevation-plot.ts b/frontend/src/map/ui/elevation-plot/elevation-plot.ts index a21c37bf..ddba5e5e 100644 --- a/frontend/src/map/ui/elevation-plot/elevation-plot.ts +++ b/frontend/src/map/ui/elevation-plot/elevation-plot.ts @@ -31,16 +31,20 @@ export default class ElevationPlot extends Vue { this.container.append(this.elevationPlot.onAdd(this.mapComponents.map)); this.handleResize(); - if (this.searchBoxContext) + if (this.searchBoxContext) { this.searchBoxContext.$on("resizeend", this.handleResize); + this.searchBoxContext.$on("resizereset", this.handleResize); + } $(window).on("resize", this.handleResize); } beforeDestroy(): void { - if (this.searchBoxContext) + if (this.searchBoxContext) { this.searchBoxContext.$off("resizeend", this.handleResize); + this.searchBoxContext.$off("resizereset", this.handleResize); + } $(window).off("resize", this.handleResize); this.elevationPlot.onRemove(this.mapComponents.map); diff --git a/frontend/src/map/ui/elevation-stats/elevation-stats.scss b/frontend/src/map/ui/elevation-stats/elevation-stats.scss new file mode 100644 index 00000000..ee3db501 --- /dev/null +++ b/frontend/src/map/ui/elevation-stats/elevation-stats.scss @@ -0,0 +1,10 @@ +.fm-elevation-stats { + display: inline-flex; + align-items: center; + + button { + margin-left: 0.5rem; + padding: 0 0.25rem; + line-height: 1; + } +} \ No newline at end of file diff --git a/frontend/src/map/ui/elevation-stats/elevation-stats.ts b/frontend/src/map/ui/elevation-stats/elevation-stats.ts index 776ece7e..8b7e4637 100644 --- a/frontend/src/map/ui/elevation-stats/elevation-stats.ts +++ b/frontend/src/map/ui/elevation-stats/elevation-stats.ts @@ -5,6 +5,7 @@ import { sortBy } from "lodash"; import { LineWithTrackPoints, RouteWithTrackPoints } from "facilmap-client"; import { createElevationStats } from "../../../utils/heightgraph"; import Icon from "../icon/icon"; +import "./elevation-stats.scss"; @WithRender @Component({ diff --git a/frontend/src/map/ui/elevation-stats/elevation-stats.vue b/frontend/src/map/ui/elevation-stats/elevation-stats.vue index ec5dfc53..de889875 100644 --- a/frontend/src/map/ui/elevation-stats/elevation-stats.vue +++ b/frontend/src/map/ui/elevation-stats/elevation-stats.vue @@ -1,6 +1,9 @@ - -  {{route.ascent}} m /  {{route.descent}} m - + + + {{route.ascent}} m / {{route.descent}} m + + +
Total ascent
{{route.ascent}} m
diff --git a/frontend/src/map/ui/icon/icon.scss b/frontend/src/map/ui/icon/icon.scss new file mode 100644 index 00000000..b910d3f4 --- /dev/null +++ b/frontend/src/map/ui/icon/icon.scss @@ -0,0 +1,4 @@ +.fm-icon { + display: inline-block; + vertical-align: middle; +} \ No newline at end of file diff --git a/frontend/src/map/ui/icon/icon.ts b/frontend/src/map/ui/icon/icon.ts index 6922d5c9..ff08e33f 100644 --- a/frontend/src/map/ui/icon/icon.ts +++ b/frontend/src/map/ui/icon/icon.ts @@ -2,6 +2,7 @@ import WithRender from "./icon.vue"; import Vue from "vue"; import { Component, Prop } from "vue-property-decorator"; import { getSymbolHtml } from "facilmap-leaflet"; +import "./icon.scss"; @WithRender @Component({ @@ -11,7 +12,7 @@ export default class Icon extends Vue { @Prop({ type: String }) icon!: string | undefined; @Prop({ type: String }) alt?: string; // TODO - @Prop({ type: String, default: "1.4em" }) size!: string; + @Prop({ type: String, default: "1.35em" }) size!: string; get iconCode(): string { return getSymbolHtml("currentColor", this.size, this.icon); diff --git a/frontend/src/utils/files.ts b/frontend/src/utils/files.ts index 9cddbe09..ea3c5756 100644 --- a/frontend/src/utils/files.ts +++ b/frontend/src/utils/files.ts @@ -5,15 +5,17 @@ import { Feature, Geometry } from "geojson"; import { GeoJsonExport, LineFeature, MarkerFeature, SearchResult } from "facilmap-types"; import { flattenObject } from "facilmap-utils"; -type FeatureProperties = Partial & Partial & { +type FmFeatureProperties = Partial | Partial; +type FeatureProperties = FmFeatureProperties & { tags?: Record; // Tags for OSM objects type?: string; id?: string; } export type FileResult = SearchResult & { + isFileResult: true; fmTypeId?: number; - fmProperties?: FeatureProperties; + fmProperties?: FmFeatureProperties; } export interface FileResultObject { @@ -96,6 +98,7 @@ export function parseFiles(files: string[]): FileResultObject { name = feature.geometry.type || "Object"; let f: FileResult = { + isFileResult: true, short_name: name, display_name: name, extratags: feature.properties.data || feature.properties.tags || flattenObject(Object.assign({}, feature.properties, {coordTimes: null})), diff --git a/frontend/src/utils/search.ts b/frontend/src/utils/search.ts new file mode 100644 index 00000000..1e5670fb --- /dev/null +++ b/frontend/src/utils/search.ts @@ -0,0 +1,28 @@ +import { FindOnMapResult, SearchResult } from "facilmap-types"; +import { FileResult } from "./files"; + +export function isSearchResult(result: SearchResult | FindOnMapResult | FileResult): result is SearchResult { + return !isMapResult(result) && !isFileResult(result); +} + +export function isMapResult(result: SearchResult | FindOnMapResult | FileResult): result is FindOnMapResult { + return "kind" in result; +} + +export function isFileResult(result: SearchResult | FindOnMapResult | FileResult): result is FileResult { + return "isFileResult" in result && result.isFileResult; +} + +export function isMarkerResult(result: SearchResult | FindOnMapResult | FileResult): boolean { + if (isMapResult(result)) + return result.kind == "marker"; + else + return (result.lat != null && result.lon != null) || (!!result.geojson && result.geojson.type == "Point"); +} + +export function isLineResult(result: SearchResult | FindOnMapResult | FileResult): boolean { + if (isMapResult(result)) + return result.kind == "line"; + else + return !!result.geojson && ["LineString", "MultiLineString", "Polygon", "MultiPolygon"].includes(result.geojson.type); +} \ No newline at end of file diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index ebe8ffc6..0a830b15 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -1,7 +1,7 @@ import Vue from "vue"; import { isEqual } from "lodash"; import { clone } from "facilmap-utils"; -import { Field, Line, Marker, Type } from "facilmap-types"; +import { Field, Line, Marker, SearchResult, Type } from "facilmap-types"; /** Can be used as the "type" of props that accept an ID */ export const IdType = Number; diff --git a/leaflet/src/bbox-handler.ts b/leaflet/src/bbox-handler.ts index 6ba8e082..9ff40a53 100644 --- a/leaflet/src/bbox-handler.ts +++ b/leaflet/src/bbox-handler.ts @@ -36,7 +36,7 @@ export default class BboxHandler extends Handler { this.updateBbox(bounds, zoom); } - handleEmit: EventHandler = (name, data) => { + handleEmitResolve: EventHandler = (name, data) => { if (["setPadId", "setRoute"].includes(name)) { this.updateBbox(); } @@ -45,12 +45,12 @@ export default class BboxHandler extends Handler { addHooks(): void { this._map.on("moveend", this.handleMoveEnd); this._map.on("fmFlyTo", this.handleFlyTo); - this.client.on("emit", this.handleEmit); + this.client.on("emitResolve", this.handleEmitResolve); } removeHooks(): void { this._map.off("moveend", this.handleMoveEnd); this._map.off("fmFlyTo", this.handleFlyTo); - this.client.removeListener("emit", this.handleEmit); + this.client.removeListener("emitResolve", this.handleEmitResolve); } } \ No newline at end of file diff --git a/leaflet/src/lines/lines-layer.ts b/leaflet/src/lines/lines-layer.ts index 2e3cbb50..720f803a 100644 --- a/leaflet/src/lines/lines-layer.ts +++ b/leaflet/src/lines/lines-layer.ts @@ -176,6 +176,7 @@ export default class LinesLayer extends FeatureGroup { const style: HighlightableLayerOptions = { color: '#'+line.colour, weight: line.width, + raised: false, opacity: 0.35 } as any; diff --git a/leaflet/src/utils/icons.ts b/leaflet/src/utils/icons.ts index 292721c8..54ebf446 100644 --- a/leaflet/src/utils/icons.ts +++ b/leaflet/src/utils/icons.ts @@ -15,7 +15,7 @@ for (const key of rawIconsContext.keys() as string[]) { } rawIcons["fontawesome"] = {}; -for (const name of ["arrow-left", "arrow-right", "biking", "car-alt", "chart-line", "slash", "walking"]) { +for (const name of ["arrow-left", "arrow-right", "biking", "car-alt", "chart-line", "info-circle", "slash", "walking"]) { rawIcons["fontawesome"][name] = require(`@fortawesome/fontawesome-free/svgs/solid/${name}.svg`); } diff --git a/server/package.json b/server/package.json index e76c6a6f..1c98ec3b 100644 --- a/server/package.json +++ b/server/package.json @@ -39,6 +39,7 @@ "ejs": "^3.1.5", "event-stream": "^4.0.1", "express": "^4.16.4", + "express-domain-middleware": "^0.1.0", "facilmap-frontend": "^2.7.0", "facilmap-leaflet": "^2.7.0", "facilmap-utils": "^2.7.0", @@ -70,6 +71,7 @@ "@types/ejs": "^3.0.5", "@types/event-stream": "^3.3.34", "@types/express": "^4.17.9", + "@types/express-domain-middleware": "^0.0.6", "@types/geojson": "^7946.0.7", "@types/highland": "^2.12.10", "@types/jest": "^26.0.20", diff --git a/server/src/database/helpers.ts b/server/src/database/helpers.ts index 579e385b..1f834672 100644 --- a/server/src/database/helpers.ts +++ b/server/src/database/helpers.ts @@ -56,27 +56,25 @@ export interface BboxWithExcept extends Bbox { except?: Bbox; } -export function makeBboxCondition(bbox: BboxWithExcept | null | undefined, prefix?: string): WhereOptions { +export function makeBboxCondition(bbox: BboxWithExcept | null | undefined, prefix = "", suffix = ""): WhereOptions { if(!bbox) return { }; - prefix = prefix || ""; - const conditions = [ ]; conditions.push({ - [prefix + "lat"]: { [Op.lte]: bbox.top, [Op.gte]: bbox.bottom } + [prefix + "lat" + suffix]: { [Op.lte]: bbox.top, [Op.gte]: bbox.bottom } }); if(bbox.right < bbox.left) { // Bbox spans over lon=180 conditions.push({ [Op.or]: [ - { [prefix + "lon" ]: { [Op.gte]: bbox.left } }, - { [prefix + "lon"]: { [Op.lte]: bbox.right } } + { [prefix + "lon" + suffix]: { [Op.gte]: bbox.left } }, + { [prefix + "lon" + suffix]: { [Op.lte]: bbox.right } } ] }); } else { conditions.push({ - [prefix + "lon"]: { [Op.gte]: bbox.left, [Op.lte]: bbox.right } + [prefix + "lon" + suffix]: { [Op.gte]: bbox.left, [Op.lte]: bbox.right } }); } @@ -84,20 +82,20 @@ export function makeBboxCondition(bbox: BboxWithExcept | null | undefined, prefi const exceptConditions = [ ]; exceptConditions.push({ [Op.or]: [ - { [prefix + "lat"]: { [Op.gt]: bbox.except.top } }, - { [prefix + "lat"]: { [Op.lt]: bbox.except.bottom } } + { [prefix + "lat" + suffix]: { [Op.gt]: bbox.except.top } }, + { [prefix + "lat" + suffix]: { [Op.lt]: bbox.except.bottom } } ] }); if(bbox.except.right < bbox.except.left) { exceptConditions.push({ - [prefix + "lon" ]: { [Op.lt]: bbox.except.left, [Op.gt]: bbox.except.right } + [prefix + "lon" + suffix]: { [Op.lt]: bbox.except.left, [Op.gt]: bbox.except.right } }); } else { exceptConditions.push({ [Op.or]: [ - { [prefix + "lon"]: { [Op.lt]: bbox.except.left } }, - { [prefix + "lon"]: { [Op.gt]: bbox.except.right } } + { [prefix + "lon" + suffix]: { [Op.lt]: bbox.except.left } }, + { [prefix + "lon" + suffix]: { [Op.gt]: bbox.except.right } } ] }); } diff --git a/server/src/database/line.ts b/server/src/database/line.ts index 07279591..6b99c8e0 100644 --- a/server/src/database/line.ts +++ b/server/src/database/line.ts @@ -7,7 +7,7 @@ import { wrapAsync } from "../utils/streams"; import { calculateRouteForLine } from "../routing/routing"; export type LineWithTrackPoints = Line & { - trackPoints: Point[]; + trackPoints: TrackPoint[]; } function createLineModel() { @@ -125,6 +125,9 @@ export default class DatabaseLines { ele: { type: DataTypes.INTEGER, allowNull: true } }, { sequelize: this._db._conn, + indexes: [ + { fields: [ "lineId", "zoom" ] } + ], modelName: "LinePoint" }); @@ -157,10 +160,10 @@ export default class DatabaseLines { return this._db.helpers._getPadObjects("Line", padId, { where: { typeId: typeId } }); } - getPadLinesWithPoints(padId: PadId, bboxWithZoom?: BboxWithZoom): Highland.Stream { + getPadLinesWithPoints(padId: PadId): Highland.Stream { return this.getPadLines(padId) .flatMap(wrapAsync(async (line): Promise => { - const trackPoints = await this.getLinePoints(line.id, bboxWithZoom); + const trackPoints = await this.getAllLinePoints(line.id); return { ...line, trackPoints }; })); } @@ -266,52 +269,26 @@ export default class DatabaseLines { } getLinePointsForPad(padId: PadId, bboxWithZoom: BboxWithZoom & BboxWithExcept): Highland.Stream<{ id: ID; trackPoints: TrackPoint[] }> { - return this.getPadLines(padId, [ "id" ]) - .flatMap(wrapAsync(async (line): Promise<{ id: ID, trackPoints: TrackPoint[] } | undefined> => { - const trackPoints = await this.getLinePoints(line.id, bboxWithZoom); - if(trackPoints.length >= 2) - return { id: line.id, trackPoints: trackPoints }; - })) - .filter((obj) => obj != null) as Highland.Stream<{ id: ID, trackPoints: TrackPoint[] }>; - } + return this._db.helpers._toStream(async () => { + const results = await this.LineModel.findAll({ + attributes: ["id"], + where: { + [Op.and]: [ + { + padId, + "$LinePoints.zoom$": { [Op.lte]: bboxWithZoom.zoom } + }, + makeBboxCondition(bboxWithZoom, "$LinePoints.", "$") + ] + }, + include: this.LinePointModel + }); - async getLinePoints(lineId: ID, bboxWithZoom?: BboxWithZoom & BboxWithExcept): Promise { - const data = await this.LineModel.build({ id: lineId }).getLinePoints({ - where: { - [Op.and]: [ - makeBboxCondition(bboxWithZoom), - ...(bboxWithZoom ? [ { zoom: { [Op.lte]: bboxWithZoom.zoom } } ] : []) - ] - }, - attributes: [ "idx" ], - order: [[ "idx", "ASC" ]] + return results.map((res) => { + const val = res.toJSON() as any; + return { id: val.id, trackPoints: val.LinePoints }; + }); }); - - // Get one more point outside of the bbox for each segment - const indexes = [ ]; - for(let i=0; i { - const data = await this.LineModel.build({ id: lineId }).getLinePoints({ - where: { idx: indexes }, - attributes: [ "lon", "lat", "idx", "ele" ], - order: [[ "idx", "ASC" ]] - }); - return data.map((point) => point.toJSON() as TrackPoint); } async getAllLinePoints(lineId: ID): Promise { diff --git a/server/src/database/migrations.ts b/server/src/database/migrations.ts index 5bb18858..3c2c03bb 100644 --- a/server/src/database/migrations.ts +++ b/server/src/database/migrations.ts @@ -101,7 +101,7 @@ export default class DatabaseMigrations { // Get rid of the dropdown key, save the value in the data instead const dropdownKeyMigration = this._db.meta.getMeta("dropdownKeysMigrated").then((dropdownKeysMigrated) => { - if(dropdownKeysMigrated == "true") + if(dropdownKeysMigrated == "1") return; return this._db.types.TypeModel.findAll().then((types) => { @@ -146,7 +146,7 @@ export default class DatabaseMigrations { } return operations; }).then(() => { - return this._db.meta.setMeta("dropdownKeysMigrated", "true"); + return this._db.meta.setMeta("dropdownKeysMigrated", "1"); }); }); @@ -154,7 +154,7 @@ export default class DatabaseMigrations { const elevationMigration = addColMigrations.then(() => { return this._db.meta.getMeta("hasElevation"); }).then((hasElevation) => { - if(hasElevation == "true") + if(hasElevation == "1") return; return Promise.all([ @@ -181,14 +181,14 @@ export default class DatabaseMigrations { }); }) ]).then(() => { - return this._db.meta.setMeta("hasElevation", "true"); + return this._db.meta.setMeta("hasElevation", "1"); }); }); // Add showInLegend field to types const legendMigration = addColMigrations.then(() => (this._db.meta.getMeta("hasLegendOption"))).then((hasLegendOption) => { - if(hasLegendOption == "true") + if(hasLegendOption == "1") return; return this._db.types.TypeModel.findAll().then((types) => { @@ -211,13 +211,13 @@ export default class DatabaseMigrations { operations = operations.then(() => (this._db.helpers._updatePadObject("Type", type.padId, type.id, { showInLegend }, true))); } return operations; - }).then(() => (this._db.meta.setMeta("hasLegendOption", "true"))); + }).then(() => (this._db.meta.setMeta("hasLegendOption", "1"))); }); // Calculate bounding box for lines const bboxMigration = addColMigrations.then(async () => { - if(await this._db.meta.getMeta("hasBboxes") == "true") + if(await this._db.meta.getMeta("hasBboxes") == "1") return; const LinePoint = this._db.lines.LinePointModel; @@ -236,7 +236,7 @@ export default class DatabaseMigrations { await this._db.helpers._updatePadObject("Line", line.padId, line.id, bbox, true); } - await this._db.meta.setMeta("hasBboxes", "true"); + await this._db.meta.setMeta("hasBboxes", "1"); }); await Promise.all([ renameColMigrations, changeColMigrations, addColMigrations, dropdownKeyMigration, elevationMigration, legendMigration, bboxMigration ]); diff --git a/server/src/database/route.ts b/server/src/database/route.ts index 265e5012..b2429427 100644 --- a/server/src/database/route.ts +++ b/server/src/database/route.ts @@ -43,7 +43,7 @@ export default class DatabaseRoutes { }, { sequelize: this._db._conn, indexes: [ - { fields: [ "routeId" ] } + { fields: [ "routeId", "zoom" ] } ], modelName: "Route" }); diff --git a/server/src/database/search.ts b/server/src/database/search.ts index a055f578..a27108e8 100644 --- a/server/src/database/search.ts +++ b/server/src/database/search.ts @@ -1,14 +1,10 @@ -import { Line, Marker, PadId } from "facilmap-types"; +import { FindOnMapResult, PadId } from "facilmap-types"; import Sequelize, { ModelCtor } from "sequelize"; import Database from "./database"; import { LineModel } from "./line"; import { MarkerModel } from "./marker"; import similarity from "string-similarity"; -type DatabaseSearchResult = ((Marker & { kind: "marker" }) | (Line & { kind: "line" })) & { - similarity: number; -}; - const Op = Sequelize.Op; export default class DatabaseSearch { @@ -19,7 +15,7 @@ export default class DatabaseSearch { this._db = database; } - async search(padId: PadId, searchText: string): Promise> { + async search(padId: PadId, searchText: string): Promise> { const objects = (await Promise.all([ "Marker", "Line" ].map(async (kind) => { const model = this._db._conn.model(kind) as ModelCtor; const objs = await model.findAll({ diff --git a/server/src/export/gpx-pad.ejs b/server/src/export/gpx-pad.ejs deleted file mode 100644 index 91e7441b..00000000 --- a/server/src/export/gpx-pad.ejs +++ /dev/null @@ -1,31 +0,0 @@ - - - - <%=padData.name%> - - -<% for(let marker of markers) { -%> - ele="<%=marker.ele%>"<% } %>> - <%=marker.name%> - <%=dataToText(types[marker.typeId].fields, marker.data)%> - -<% } -%> -<% for(let line of lines) { -%> -<% let t = (useTracks || line.mode == "track"); -%> - <<%=t ? 'trk' : 'rte'%>> - <%=line.name%> - <%=dataToText(types[line.typeId].fields, line.data)%> -<% if(t) { -%> - -<% for(let trackPoint of line.trackPoints) { -%> - ele="<%=trackPoint.ele%>"<% } %> /> -<% } -%> - -<% } else { -%> -<% for(let routePoint of line.routePoints) { %> - -<% } -%> -<% } -%> - > -<% } -%> - \ No newline at end of file diff --git a/server/src/export/gpx.ts b/server/src/export/gpx.ts index 9dadce34..763349df 100644 --- a/server/src/export/gpx.ts +++ b/server/src/export/gpx.ts @@ -1,19 +1,15 @@ -import { streamToArrayPromise } from "../utils/streams"; -import ejs from "ejs"; +import { streamToArrayPromise, toStream } from "../utils/streams"; +import { compile } from "ejs"; import fs from "fs"; import Database from "../database/database"; import { Field, PadId, Type } from "facilmap-types"; -import { compileExpression, prepareObject } from "facilmap-utils"; +import { compileExpression, prepareObject, quoteHtml } from "facilmap-utils"; import { LineWithTrackPoints } from "../database/line"; import { keyBy } from "lodash"; - - -const padTemplateP = fs.promises.readFile(`${__dirname}/gpx-pad.ejs`).then((t) => { - return ejs.compile(t.toString()); -}); +import highland from "highland"; const lineTemplateP = fs.promises.readFile(`${__dirname}/gpx-line.ejs`).then((t) => { - return ejs.compile(t.toString()); + return compile(t.toString()); }); function dataToText(fields: Field[], data: Record) { @@ -27,32 +23,55 @@ function dataToText(fields: Field[], data: Record) { return text.join('\n\n'); } -export async function exportGpx(database: Database, padId: PadId, useTracks: boolean, filter?: string): Promise { - const filterFunc = compileExpression(filter); +export function exportGpx(database: Database, padId: PadId, useTracks: boolean, filter?: string): Highland.Stream { + return toStream(async () => { + const filterFunc = compileExpression(filter); - const typesP = streamToArrayPromise(database.types.getTypes(padId)).then((types) => keyBy(types, 'id')); + const [padData, types] = await Promise.all([ + database.pads.getPadData(padId), + streamToArrayPromise(database.types.getTypes(padId)).then((types) => keyBy(types, 'id')) + ]); - const [ padData, types, markers, lines, padTemplate ] = await Promise.all([ - database.pads.getPadData(padId), - typesP, - typesP.then(async (types) => ( - await streamToArrayPromise(database.markers.getPadMarkers(padId).filter((marker) => filterFunc(prepareObject(marker, types[marker.typeId])))) - )), - typesP.then(async (types) => ( - await streamToArrayPromise(database.lines.getPadLinesWithPoints(padId).filter((line) => filterFunc(prepareObject(line, types[line.typeId])))) - )), - padTemplateP - ]); + if (!padData) + throw new Error(`Pad ${padId} could not be found.`); - return padTemplate({ - time: new Date().toISOString(), - padData, - types, - markers, - lines, - dataToText, - useTracks - }); + const markers = database.markers.getPadMarkers(padId).filter((marker) => filterFunc(prepareObject(marker, types[marker.typeId]))); + const lines = database.lines.getPadLinesWithPoints(padId).filter((line) => filterFunc(prepareObject(line, types[line.typeId]))); + + return highland([ + `\n` + + `\n` + + `\t\n` + + `\t\t${quoteHtml(padData.name)}\n` + + `\t\t\n` + + `\t\n` + ]).concat(markers.map((marker) => ( + `\t\n` + + `\t\t${quoteHtml(marker.name)}\n` + + `\t\t${quoteHtml(dataToText(types[marker.typeId].fields, marker.data))}\n` + + `\t\n` + ))).concat(lines.map((line) => ((useTracks || line.mode == "track") ? ( + `\t\n` + + `\t\t${quoteHtml(line.name)}\n` + + `\t\t${dataToText(types[line.typeId].fields, line.data)}\n` + + `\t\t\n` + + line.trackPoints.map((trackPoint) => ( + `\t\t\t\n` + )).join("") + + `\t\t\n` + + `\t\n` + ) : ( + `\t\n` + + `\t\t${quoteHtml(line.name)}\n` + + `\t\t${quoteHtml(dataToText(types[line.typeId].fields, line.data))}\n` + + line.routePoints.map((routePoint) => ( + `\t\t\n` + )).join("") + + `\t\n` + )))).concat([ + `` + ]); + }).flatten(); } type LineForExport = Partial>; diff --git a/server/src/webserver.ts b/server/src/webserver.ts index b900d6af..2332dc32 100644 --- a/server/src/webserver.ts +++ b/server/src/webserver.ts @@ -3,13 +3,13 @@ import ejs from "ejs"; import express, { Request, Response, NextFunction } from "express"; import fs from "fs"; import { createServer, Server as HttpServer } from "http"; -import jsonFormat from "json-format"; import { dirname } from "path"; import { PadId } from "facilmap-types"; import { createTable } from "./export/table"; import Database from "./database/database"; import { exportGeoJson } from "./export/geojson"; import { exportGpx } from "./export/gpx"; +import domainMiddleware from "express-domain-middleware"; const frontendPath = dirname(require.resolve("facilmap-frontend/package.json")); // Do not resolve main property @@ -26,7 +26,7 @@ const staticMiddleware = isDevMode ? require("webpack-dev-middleware")(webpackCompiler, { // require the stuff here so that it doesn't fail if devDependencies are not installed publicPath: "/" }) - : express.static(frontendPath + "/build/"); + : express.static(frontendPath + "/dist/"); const hotMiddleware = isDevMode ? require("webpack-hot-middleware")(webpackCompiler) : undefined; @@ -69,6 +69,7 @@ export async function initWebserver(database: Database, port: number, host?: str }; const app = express(); + app.use(domainMiddleware); app.use(compression()); app.get("/bundle-:hash.js", function(req, res, next) { @@ -96,11 +97,9 @@ export async function initWebserver(database: Database, port: number, host?: str if(!padData) throw new Error(`Map with ID ${req.params.padId} could not be found.`); - const gpx = await exportGpx(database, padData ? padData.id : req.params.padId, req.query.useTracks == "1", req.query.filter as string | undefined); - res.set("Content-type", "application/gpx+xml"); res.attachment(padData.name.replace(/[\\/:*?"<>|]+/g, '_') + ".gpx"); - res.send(gpx); + exportGpx(database, padData ? padData.id : req.params.padId, req.query.useTracks == "1", req.query.filter as string | undefined).pipe(res); } catch (e) { next(e); } @@ -150,6 +149,6 @@ export function getFrontendFile(path: string): Promise { // We don't want express.static's ETag handling, as it sometimes returns an empty template, // so we have to read it directly from the file system - return fs.promises.readFile(`${frontendPath}/build/${path}`, "utf8"); + return fs.promises.readFile(`${frontendPath}/dist/${path}`, "utf8"); } } diff --git a/types/src/events.ts b/types/src/events.ts index dc5e0a53..0fe47ce1 100644 --- a/types/src/events.ts +++ b/types/src/events.ts @@ -5,7 +5,6 @@ import { View } from "./view"; import { Line, TrackPoint } from "./line"; import { Marker } from "./marker"; import { PadData } from "./padData"; -import { RequestData, RequestName } from "./socket"; export interface LinePointsEvent { id: ID; @@ -35,4 +34,4 @@ export type EventHandler, E extends E export type MultipleEvents> = { [E in EventName]?: Array; -}; +}; \ No newline at end of file diff --git a/types/src/socket.ts b/types/src/socket.ts index 4c482595..3c258b81 100644 --- a/types/src/socket.ts +++ b/types/src/socket.ts @@ -31,7 +31,7 @@ export interface FindOnMapQuery { query: string; } -export type FindOnMapMarker = Pick & { kind: "marker"; similarity: number }; +export type FindOnMapMarker = Pick & { kind: "marker"; similarity: number }; export type FindOnMapLine = Pick & { kind: "line"; similarity: number }; export type FindOnMapResult = FindOnMapMarker | FindOnMapLine; diff --git a/utils/src/utils.ts b/utils/src/utils.ts index dbbb62b2..211a0c61 100644 --- a/utils/src/utils.ts +++ b/utils/src/utils.ts @@ -1,18 +1,16 @@ -import { isEqual } from "lodash"; - const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const LENGTH = 12; -export function quoteJavaScript(str: string): string { - return "'" + (""+str).replace(/['\\]/g, '\\$1').replace(/\n/g, "\\n") + "'"; +export function quoteJavaScript(str: any): string { + return "'" + `${str}`.replace(/['\\]/g, '\\$1').replace(/\n/g, "\\n") + "'"; } -export function quoteHtml(str: string): string { - return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +export function quoteHtml(str: any): string { + return `${str}`.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } export function quoteRegExp(str: string): string { - return (str+'').replace(/[.?*+^$[\]\\(){}|-]/g, "\\$&"); + return `${str}`.replace(/[.?*+^$[\]\\(){}|-]/g, "\\$&"); } export function generateRandomPadId(length: number = LENGTH): string { diff --git a/yarn.lock b/yarn.lock index 4d878fc5..4c972877 100644 --- a/yarn.lock +++ b/yarn.lock @@ -772,6 +772,13 @@ dependencies: "@types/node" "*" +"@types/express-domain-middleware@^0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@types/express-domain-middleware/-/express-domain-middleware-0.0.6.tgz#e5babd03950b10b80465f8f349a176807f8fa0be" + integrity sha512-EljBMHFHDA/vRJQu+Zf4c3GlckYc+9Q6W4xfphwFc6RSSmKNmtGiPknWffYBq1hVnGnqt+eHMjMvrtY84wtlCQ== + dependencies: + "@types/express" "*" + "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.18": version "4.17.19" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.19.tgz#00acfc1632e729acac4f1530e9e16f6dd1508a1d" @@ -3528,6 +3535,11 @@ expose-loader@^2.0.0: resolved "https://registry.yarnpkg.com/expose-loader/-/expose-loader-2.0.0.tgz#1c323577db3d16f7817e4652024f2c545b3f6a70" integrity sha512-WBpSGlNkn7YwbU2us7O+h0XsoFrB43Y/VCNSpRV4OZFXXKgw8W800BgNxLV0S97N3+KGnFYSCAJi1AV86NO22w== +express-domain-middleware@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/express-domain-middleware/-/express-domain-middleware-0.1.0.tgz#36731b7c1901284fbf4fb5a62b0e7b0457d8e8c5" + integrity sha1-NnMbfBkBKE+/T7WmKw57BFfY6MU= + express@^4.16.4, express@^4.17.1: version "4.17.1" resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"