kopia lustrzana https://github.com/FacilMap/facilmap
Status commit
rodzic
1354c37ddf
commit
c36a054ff9
|
@ -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"],
|
||||
|
|
|
@ -28,7 +28,9 @@ export interface ClientEvents extends MapEvents {
|
|||
|
||||
route: [RouteWithTrackPoints | undefined];
|
||||
|
||||
emit: { [eventName in RequestName]: [eventName, RequestData<eventName>] }[RequestName]
|
||||
emit: { [eventName in RequestName]: [eventName, RequestData<eventName>] }[RequestName],
|
||||
emitResolve: { [eventName in RequestName]: [eventName, ResponseData<eventName>] }[RequestName],
|
||||
emitReject: { [eventName in RequestName]: [eventName, Error] }[RequestName]
|
||||
}
|
||||
|
||||
const MANAGER_EVENTS: Array<EventName<ClientEvents>> = ['error', 'reconnect', 'reconnect_attempt', 'reconnect_error', 'reconnect_failed'];
|
||||
|
@ -105,7 +107,7 @@ export default class Client {
|
|||
|
||||
on<E extends EventName<ClientEvents>>(eventName: E, fn: EventHandler<ClientEvents, E>): 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<R>) => {
|
||||
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<Marker> {
|
||||
return this._emit("addMarker", data);
|
||||
async addMarker(data: MarkerCreate): Promise<Marker> {
|
||||
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<Marker> {
|
||||
|
|
|
@ -20,4 +20,10 @@ services:
|
|||
MYSQL_DATABASE: facilmap
|
||||
MYSQL_USER: facilmap
|
||||
MYSQL_PASSWORD: facilmap
|
||||
MYSQL_RANDOM_ROOT_PASSWORD: "true"
|
||||
MYSQL_RANDOM_ROOT_PASSWORD: "true"
|
||||
phpmyadmin:
|
||||
image: phpmyadmin
|
||||
links:
|
||||
- mysql:db
|
||||
ports:
|
||||
- 127.0.0.1:8090:80
|
|
@ -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;
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
{{" "}}
|
||||
<span class="result-type">(View)</span>
|
||||
</span>
|
||||
<a href="javascript:" v-if="client.padId && client.writable == 2 && !viewExists(view)" @click="addView(view)" title="Add this view to the map" v-b-tooltip><Icon icon="plus" alt="Add"></Icon></a>
|
||||
<a href="javascript:" v-if="client.padId && client.writable == 2 && !viewExists(view)" @click="addView(view)" v-b-tooltip.right="'Add this view to the map'"><Icon icon="plus" alt="Add"></Icon></a>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
</template>
|
||||
|
@ -31,7 +31,7 @@
|
|||
{{" "}}
|
||||
<span class="result-type">(Type)</span>
|
||||
</span>
|
||||
<a href="javascript:" v-if="client.padId && client.writable == 2 && !typeExists(type)" @click="addType(type)" title="Add this type to the map" v-b-tooltip><Icon icon="plus" alt="Add"></Icon></a>
|
||||
<a href="javascript:" v-if="client.padId && client.writable == 2 && !typeExists(type)" @click="addType(type)" v-b-tooltip.right="'Add this type to the map'"><Icon icon="plus" alt="Add"></Icon></a>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
</template>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<E extends EventName<MapContextEvents>>(event: E, callback: EventHandler<MapContextEvents, E>): void;
|
||||
$once<E extends EventName<MapContextEvents>>(event: E, callback: EventHandler<MapContextEvents, E>): void;
|
||||
$off<E extends EventName<MapContextEvents>>(event: E, callback: EventHandler<MapContextEvents, E>): void;
|
||||
$emit<E extends EventName<MapContextEvents>>(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); },
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,45 +1,57 @@
|
|||
<div class="fm-line-info" v-if="line">
|
||||
<div class="d-flex align-items-center">
|
||||
<h2 class="flex-grow-1">{{line.name}}</h2>
|
||||
<b-button
|
||||
v-if="line.ascent != null"
|
||||
:pressed.sync="showElevationPlot"
|
||||
:title="`${showElevationPlot ? 'Hide' : 'Show'} elevation plot`"
|
||||
v-b-tooltip
|
||||
><Icon icon="chart-line" :alt="`${showElevationPlot ? 'Hide' : 'Show'} elevation plot`"></Icon></b-button>
|
||||
<b-button-toolbar>
|
||||
<b-button
|
||||
v-if="line.ascent != null"
|
||||
:pressed.sync="showElevationPlot"
|
||||
v-b-tooltip.right="`${showElevationPlot ? 'Hide' : 'Show'} elevation plot`"
|
||||
><Icon icon="chart-line" :alt="`${showElevationPlot ? 'Hide' : 'Show'} elevation plot`"></Icon></b-button>
|
||||
|
||||
</b-button-toolbar>
|
||||
</div>
|
||||
|
||||
<dl>
|
||||
<dt class="distance">Distance</dt>
|
||||
<dd class="distance">{{line.distance | round(2)}} km <span v-if="line.time != null">({{line.time | fmFormatTime}} h {{line.mode | fmRouteMode}})</span></dd>
|
||||
<div class="fm-search-box-collapse-point">
|
||||
<dl>
|
||||
<dt class="distance">Distance</dt>
|
||||
<dd class="distance">{{line.distance | round(2)}} km <span v-if="line.time != null">({{line.time | fmFormatTime}} h {{line.mode | fmRouteMode}})</span></dd>
|
||||
|
||||
<template v-if="line.ascent != null">
|
||||
<dt class="elevation">Climb/drop</dt>
|
||||
<dd class="elevation"><ElevationStats :route="line"></ElevationStats></dd>
|
||||
</template>
|
||||
<template v-if="line.ascent != null">
|
||||
<dt class="elevation">Climb/drop</dt>
|
||||
<dd class="elevation"><ElevationStats :route="line"></ElevationStats></dd>
|
||||
</template>
|
||||
|
||||
<template v-if="line.ascent == null || !showElevationPlot" v-for="field in client.types[line.typeId].fields">
|
||||
<dt>{{field.name}}</dt>
|
||||
<dd v-html="$options.filters.fmFieldContent(line.data[field.name], field)"></dd>
|
||||
</template>
|
||||
</dl>
|
||||
<template v-if="line.ascent == null || !showElevationPlot" v-for="field in client.types[line.typeId].fields">
|
||||
<dt>{{field.name}}</dt>
|
||||
<dd v-html="$options.filters.fmFieldContent(line.data[field.name], field)"></dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<ElevationPlot :route="line" v-if="line.ascent != null && showElevationPlot"></ElevationPlot>
|
||||
<ElevationPlot :route="line" v-if="line.ascent != null && showElevationPlot"></ElevationPlot>
|
||||
</div>
|
||||
|
||||
<b-button-toolbar>
|
||||
<b-button v-b-tooltip="'Zoom to line'" @click="zoomToLine()" size="sm"><Icon icon="zoom-in" alt="Zoom to line"></Icon></b-button>
|
||||
|
||||
<b-dropdown text="Export" size="sm">
|
||||
<b-dropdown-item
|
||||
href="javascript:"
|
||||
@click="exportRoute('gpx-trk')"
|
||||
v-b-tooltip.right="'GPX files can be opened with most navigation software. In track mode, the calculated route is saved in the file.'"
|
||||
>Export as GPX track</b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
href="javascript:"
|
||||
@click="exportRoute('gpx-rte')"
|
||||
v-b-tooltip.right="'GPX files can be opened with most navigation software. In route mode, only the start/end/via points are saved in the file, and the navigation software needs to calculate the route.'"
|
||||
>Export as GPX route</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
|
||||
<div class="buttons" v-if="line.ascent == null || !showElevationPlot">
|
||||
<b-button v-if="!client.readonly" size="sm" v-b-modal.fm-line-info-edit :disabled="isSaving || mapContext.interaction">Edit data</b-button>
|
||||
|
||||
<!-- <b-button v-if="!client.readonly" size="sm" @click="move()" :disabled="isSaving || mapContext.interaction">Move</b-button> -->
|
||||
|
||||
<b-button v-if="!client.readonly" size="sm" @click="deleteLine()" :disabled="isSaving || mapContext.interaction">Remove</b-button>
|
||||
<!--
|
||||
<div uib-dropdown keyboard-nav="true" class="dropup">
|
||||
<button type="button" class="btn btn-default btn-sm" ng-disabled="saving" uib-dropdown-toggle>Export <span class="caret"></span></button>
|
||||
<ul class="dropdown-menu" uib-dropdown-menu role="menu">
|
||||
<li role="menuitem"><a href="javascript:" ng-click="export(true)" uib-tooltip="GPX files can be opened with most navigation software. In track mode, the calculated route is saved in the file."tooltip-placement="left">Export as GPX track</a></li>
|
||||
<li role="menuitem"><a href="javascript:" ng-click="export(false)" uib-tooltip="GPX files can be opened with most navigation software. In route mode, only the start/end/via points are saved in the file, and the navigation software needs to calculate the route."tooltip-placement="left">Export as GPX route</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
</b-button-toolbar>
|
||||
|
||||
<EditLine id="fm-line-info-edit" :lineId="lineId"></EditLine>
|
||||
</div>
|
|
@ -13,6 +13,6 @@
|
|||
|
||||
.btn-toolbar {
|
||||
> * + * {
|
||||
margin-left: 0.5rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
.fm-marker-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
.fm-search-box-collapse-point {
|
||||
min-height: 1.5em;
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
<div class="fm-marker-info" v-if="marker">
|
||||
<h2>{{marker.name}}</h2>
|
||||
<dl>
|
||||
<dl class="fm-search-box-collapse-point">
|
||||
<dt class="pos">Coordinates</dt>
|
||||
<dd class="pos">{{marker.lat | round(5)}}, {{marker.lon | round(5)}}</dd>
|
||||
|
||||
|
@ -15,21 +15,19 @@
|
|||
</template>
|
||||
</dl>
|
||||
|
||||
<div class="buttons">
|
||||
<b-button-toolbar>
|
||||
<b-button v-b-tooltip="'Zoom to marker'" @click="zoomToMarker()" size="sm"><Icon icon="zoom-in" alt="Zoom to line"></Icon></b-button>
|
||||
|
||||
<b-dropdown text="Use as" size="sm">
|
||||
<b-dropdown-item href="javascript:" @click="useAsFrom()">Route start</b-dropdown-item>
|
||||
<b-dropdown-item href="javascript:" @click="useAsVia()">Route via</b-dropdown-item>
|
||||
<b-dropdown-item href="javascript:" @click="useAsTo()">Route destination</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
|
||||
<b-button v-if="!client.readonly" size="sm" v-b-modal.fm-marker-info-edit :disabled="isSaving || mapContext.interaction">Edit data</b-button>
|
||||
<b-button v-if="!client.readonly" size="sm" @click="move()" :disabled="isSaving || mapContext.interaction">Move</b-button>
|
||||
<b-button v-if="!client.readonly" size="sm" @click="deleteMarker()" :disabled="isSaving || mapContext.interaction">Remove</b-button>
|
||||
<!--
|
||||
<div ng-if="map.searchUi" uib-dropdown keyboard-nav="true" class="dropup">
|
||||
<button type="button" class="btn btn-default btn-sm" uib-dropdown-toggle ng-disabled="saving">Use as <span class="caret"></span></button>
|
||||
<ul class="dropdown-menu" uib-dropdown-menu role="menu">
|
||||
<li role="menuitem"><a href="javascript:" ng-click="useForRoute(1)">Route start</a></li>
|
||||
<li role="menuitem"><a href="javascript:" ng-click="useForRoute(2)">Route via</a></li>
|
||||
<li role="menuitem"><a href="javascript:" ng-click="useForRoute(3)">Route destination</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
</b-button-toolbar>
|
||||
|
||||
<EditMarker id="fm-marker-info-edit" :markerId="markerId"></EditMarker>
|
||||
</div>
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
|
@ -35,6 +35,7 @@
|
|||
.fm-route-suggestions.show {
|
||||
display: grid !important;
|
||||
grid-template-columns: auto 1fr;
|
||||
opacity: 0.6;
|
||||
|
||||
&.isPending {
|
||||
display: flex !important;
|
||||
|
|
|
@ -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<void> {
|
||||
async route(zoom: boolean): Promise<void> {
|
||||
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<queries.length; i++) {
|
||||
if(scope.destinations.length <= i)
|
||||
scope.addDestination();
|
||||
setFrom(...args: Parameters<typeof makeDestination>): 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<typeof makeDestination>): void {
|
||||
this.destinations.splice(this.destinations.length - 1, 0, makeDestination(...args));
|
||||
this.reroute(true);
|
||||
}
|
||||
|
||||
while(scope.destinations.length < 2)
|
||||
scope.addDestination();
|
||||
},
|
||||
setTo(...args: Parameters<typeof makeDestination>): 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
|
||||
}
|
||||
}; */
|
||||
}
|
||||
|
|
|
@ -15,15 +15,15 @@
|
|||
<template v-for="suggestion in destination.mapSuggestions">
|
||||
<b-dropdown-item
|
||||
:active="suggestion === getSelectedSuggestion(destination)"
|
||||
@mouseenter="suggestionMouseOver(suggestion)"
|
||||
@mouseleave="suggestionMouseOut(suggestion)"
|
||||
@click="suggestionZoom(suggestion)"
|
||||
@mouseenter.native="suggestionMouseOver(suggestion)"
|
||||
@mouseleave.native="suggestionMouseOut(suggestion)"
|
||||
@click.native.capture.stop.prevent="suggestionZoom(suggestion)"
|
||||
class="fm-route-form-suggestions-zoom"
|
||||
><Icon icon="zoom-in" alt="Zoom"></Icon></b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
:active="suggestion === getSelectedSuggestion(destination)"
|
||||
@mouseenter="suggestionMouseOver(suggestion)"
|
||||
@mouseleave="suggestionMouseOut(suggestion)"
|
||||
@mouseenter.native="suggestionMouseOver(suggestion)"
|
||||
@mouseleave.native="suggestionMouseOut(suggestion)"
|
||||
@click="destination.selectedSuggestion = suggestion; reroute(true)"
|
||||
>{{suggestion.name}} ({{client.types[suggestion.typeId].name}})</b-dropdown-item>
|
||||
</template>
|
||||
|
@ -35,23 +35,23 @@
|
|||
<b-dropdown-item
|
||||
href="javascript:"
|
||||
:active="suggestion === getSelectedSuggestion(destination)"
|
||||
@mouseenter="suggestionMouseOver(suggestion)"
|
||||
@mouseleave="suggestionMouseOut(suggestion)"
|
||||
@click="suggestionZoom(suggestion)"
|
||||
@mouseenter.native="suggestionMouseOver(suggestion)"
|
||||
@mouseleave.native="suggestionMouseOut(suggestion)"
|
||||
@click.native.capture.stop.prevent="suggestionZoom(suggestion)"
|
||||
class="fm-route-form-suggestions-zoom"
|
||||
><Icon icon="zoom-in" alt="Zoom"></Icon></b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
href="javascript:"
|
||||
:active="suggestion === getSelectedSuggestion(destination)"
|
||||
@mouseenter="suggestionMouseOver(suggestion)"
|
||||
@mouseleave="suggestionMouseOut(suggestion)"
|
||||
@mouseenter.native="suggestionMouseOver(suggestion)"
|
||||
@mouseleave.native="suggestionMouseOut(suggestion)"
|
||||
@click="destination.selectedSuggestion = suggestion; reroute(true)"
|
||||
>{{suggestion.display_name}}<span v-if="suggestion.type"> ({{suggestion.type}})</span></b-dropdown-item>
|
||||
</template>
|
||||
</template>
|
||||
<b-spinner v-else></b-spinner>
|
||||
</b-dropdown>
|
||||
<b-button v-if="destinations.length > 2" @click="removeDestination(idx); reroute(false)" title="Remove this destination" v-b-tooltip><Icon icon="minus" alt="Remove" size="1.0em"></Icon></b-button>
|
||||
<b-button v-if="destinations.length > 2" @click="removeDestination(idx); reroute(false)" v-b-tooltip.right="'Remove this destination'"><Icon icon="minus" alt="Remove" size="1.0em"></Icon></b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-form-group>
|
||||
|
@ -59,12 +59,12 @@
|
|||
</draggable>
|
||||
|
||||
<b-button-toolbar>
|
||||
<b-button @click="addDestination()" title="Add another destination" v-b-tooltip :tabindex="destinations.length+1"><Icon icon="plus" alt="Add"></Icon></b-button>
|
||||
<b-button @click="addDestination()" v-b-tooltip="'Add another destination'" :tabindex="destinations.length+1"><Icon icon="plus" alt="Add"></Icon></b-button>
|
||||
|
||||
<RouteMode v-model="routeMode" :tabindex="destinations.length+2" @input="reroute(false)"></RouteMode>
|
||||
|
||||
<b-button type="submit" variant="primary" :tabindex="destinations.length+7" class="flex-grow-1" ref="submitButton">Go!</b-button>
|
||||
<b-button v-if="hasRoute" type="button" :tabindex="destinations.length+8" @click="reset()" title="Clear route" v-b-tooltip><Icon icon="remove" alt="Clear"></Icon></b-button>
|
||||
<b-button v-if="hasRoute" type="button" :tabindex="destinations.length+8" @click="reset()" v-b-tooltip.right="'Clear route'"><Icon icon="remove" alt="Clear"></Icon></b-button>
|
||||
</b-button-toolbar>
|
||||
|
||||
<template v-if="routeError">
|
||||
|
@ -89,6 +89,8 @@
|
|||
<ElevationPlot :route="client.route" v-if="client.route.ascent != null"></ElevationPlot>
|
||||
|
||||
<b-button-toolbar v-if="!client.readonly">
|
||||
<b-button v-b-tooltip="'Zoom to route'" @click="zoomToRoute()" size="sm"><Icon icon="zoom-in" alt="Zoom to route"></Icon></b-button>
|
||||
|
||||
<b-dropdown v-if="lineTypes.length > 1" text="Add to map" size="sm">
|
||||
<b-dropdown-item v-for="type in lineTypes" href="javascript:" @click="addToMap(type)">{{type.name}}</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
|
@ -97,14 +99,12 @@
|
|||
<b-dropdown-item
|
||||
href="javascript:"
|
||||
@click="exportRoute('gpx-trk')"
|
||||
title="GPX files can be opened with most navigation software. In track mode, the calculated route is saved in the file."
|
||||
v-b-tooltip
|
||||
v-b-tooltip.right="'GPX files can be opened with most navigation software. In track mode, the calculated route is saved in the file.'"
|
||||
>Export as GPX track</b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
href="javascript:"
|
||||
@click="exportRoute('gpx-rte')"
|
||||
title="GPX files can be opened with most navigation software. In route mode, only the start/end/via points are saved in the file, and the navigation software needs to calculate the route."
|
||||
v-b-tooltip
|
||||
v-b-tooltip.right="'GPX files can be opened with most navigation software. In route mode, only the start/end/via points are saved in the file, and the navigation software needs to calculate the route.'"
|
||||
>Export as GPX route</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</b-button-toolbar>
|
||||
|
|
|
@ -72,6 +72,10 @@
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
.fm-search-box-collapse-point {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
|
@ -113,10 +117,6 @@
|
|||
.pos,.distance,.elevation {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.buttons button + button {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -10,8 +10,8 @@ import SearchFormTab from "../search-form/search-form-tab";
|
|||
import MarkerInfoTab from "../marker-info/marker-info-tab";
|
||||
import LineInfoTab from "../line-info/line-info-tab";
|
||||
import hammer from "hammerjs";
|
||||
import { InjectMapComponents, SEARCH_BOX_CONTEXT_INJECT_KEY } from "../../utils/decorators";
|
||||
import { MapComponents } from "../leaflet-map/leaflet-map";
|
||||
import { InjectMapComponents, InjectMapContext, SEARCH_BOX_CONTEXT_INJECT_KEY } from "../../utils/decorators";
|
||||
import { MapComponents, MapContext } from "../leaflet-map/leaflet-map";
|
||||
import RouteFormTab from "../route-form/route-form-tab";
|
||||
|
||||
export type SearchBoxContext = Vue;
|
||||
|
@ -23,6 +23,7 @@ export type SearchBoxContext = Vue;
|
|||
export default class SearchBox extends Vue {
|
||||
|
||||
@InjectMapComponents() mapComponents!: MapComponents;
|
||||
@InjectMapContext() mapContext!: MapContext;
|
||||
|
||||
@ProvideReactive(SEARCH_BOX_CONTEXT_INJECT_KEY) searchBoxContext = new Vue();
|
||||
|
||||
|
@ -44,7 +45,7 @@ export default class SearchBox extends Vue {
|
|||
}
|
||||
|
||||
mounted(): void {
|
||||
this.$root.$on("fm-search-box-show-tab", this.handleShowTab);
|
||||
this.mapContext.$on("fm-search-box-show-tab", this.handleShowTab);
|
||||
|
||||
this.cardHeader = this.searchBox.querySelector(".card-header")!;
|
||||
|
||||
|
@ -64,7 +65,7 @@ export default class SearchBox extends Vue {
|
|||
}
|
||||
|
||||
beforeDestroy(): void {
|
||||
this.$root.$off("fm-search-box-show-tab", this.handleShowTab);
|
||||
this.mapContext.$off("fm-search-box-show-tab", this.handleShowTab);
|
||||
this.cardHeader = undefined as any;
|
||||
}
|
||||
|
||||
|
@ -142,6 +143,7 @@ export default class SearchBox extends Vue {
|
|||
this.searchBox.style.width = "";
|
||||
this.searchBox.style.height = "";
|
||||
this.$root.$emit('bv::hide::tooltip');
|
||||
this.searchBoxContext.$emit("resizereset");
|
||||
}
|
||||
|
||||
}
|
|
@ -6,5 +6,5 @@
|
|||
<LineInfoTab></LineInfoTab>
|
||||
<portal-target name="fm-search-box" multiple></portal-target>
|
||||
</b-tabs>
|
||||
<a v-show="!isNarrow" href="javascript:" class="fm-search-box-resize" :title="isResizing ? '' : 'Drag to resize, click to reset'" v-b-tooltip.bottom ref="resizeHandle"><Icon icon="resize-horizontal"></Icon></a>
|
||||
<a v-show="!isNarrow" href="javascript:" class="fm-search-box-resize" v-b-tooltip.right="'Drag to resize, click to reset'" ref="resizeHandle"><Icon icon="resize-horizontal"></Icon></a>
|
||||
</b-card>
|
|
@ -17,17 +17,17 @@ export default class SearchFormTab 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);
|
||||
}
|
||||
|
||||
handleOpenSelection(): void {
|
||||
const layerId = Util.stamp(this.mapComponents.searchResultsLayer);
|
||||
if (this.mapContext.selection.some((item) => item.type == "searchResult" && item.layerId == layerId))
|
||||
this.$root.$emit("fm-search-box-show-tab", "fm-search-form-tab");
|
||||
this.mapContext.$emit("fm-search-box-show-tab", "fm-search-form-tab");
|
||||
}
|
||||
|
||||
}
|
|
@ -160,7 +160,8 @@ export default class SearchForm extends Vue {
|
|||
|
||||
}
|
||||
|
||||
/* fm.app.directive("fmSearchQuery", function($rootScope, $compile, fmUtils, $timeout, $q, fmSearchFiles, fmSearchImport, fmHighlightableLayers) {
|
||||
/* TODO
|
||||
fm.app.directive("fmSearchQuery", function($rootScope, $compile, fmUtils, $timeout, $q, fmSearchFiles, fmSearchImport, fmHighlightableLayers) {
|
||||
return {
|
||||
require: "^fmSearch",
|
||||
scope: true,
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
.fm-search-result-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
.fm-search-box-collapse-point {
|
||||
min-height: 1.5em;
|
||||
}
|
||||
}
|
|
@ -2,8 +2,14 @@ import WithRender from "./search-result-info.vue";
|
|||
import Vue from "vue";
|
||||
import { Component, Prop } from "vue-property-decorator";
|
||||
import { renderOsmTag } from "facilmap-utils";
|
||||
import { SearchResult } from "facilmap-types";
|
||||
import { SearchResult, Type } from "facilmap-types";
|
||||
import Icon from "../ui/icon/icon";
|
||||
import { InjectClient, InjectMapComponents, InjectMapContext } from "../../utils/decorators";
|
||||
import Client from "facilmap-client";
|
||||
import "./search-result-info.scss";
|
||||
import { FileResult } from "../../utils/files";
|
||||
import { MapComponents, MapContext } from "../leaflet-map/leaflet-map";
|
||||
import { isLineResult, isMarkerResult } from "../../utils/search";
|
||||
|
||||
@WithRender
|
||||
@Component({
|
||||
|
@ -11,11 +17,28 @@ import Icon from "../ui/icon/icon";
|
|||
})
|
||||
export default class SearchResultInfo extends Vue {
|
||||
|
||||
@Prop({ type: Object, required: true }) result!: SearchResult;
|
||||
@InjectClient() client!: Client;
|
||||
@InjectMapComponents() mapComponents!: MapComponents;
|
||||
@InjectMapContext() mapContext!: MapContext;
|
||||
|
||||
@Prop({ type: Object, required: true }) result!: SearchResult | FileResult;
|
||||
@Prop({ type: Boolean, default: false }) showBackButton!: boolean;
|
||||
|
||||
renderOsmTag = renderOsmTag;
|
||||
|
||||
get isMarker(): boolean {
|
||||
return isMarkerResult(this.result);
|
||||
}
|
||||
|
||||
get isLine(): boolean {
|
||||
return isLineResult(this.result);
|
||||
}
|
||||
|
||||
get types(): Type[] {
|
||||
// Result can be both marker and line
|
||||
return Object.values(this.client.types).filter((type) => (this.isMarker && type.type == "marker") || (this.isLine && type.type == "line"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* function showResultInfoBox(query, results, result, onClose) {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<a v-if="showBackButton" href="javascript:" @click="$emit('back')"><Icon icon="arrow-left"></Icon></a>
|
||||
{{result.short_name}}
|
||||
</h2>
|
||||
<dl>
|
||||
<dl class="fm-search-box-collapse-point">
|
||||
<dt v-if="result.type">Type</dt>
|
||||
<dd v-if="result.type">{{result.type}}</dd>
|
||||
|
||||
|
@ -21,22 +21,16 @@
|
|||
<dd v-html="renderOsmTag(key, value)"></dd>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
<!-- <div class="buttons">
|
||||
{{filteredTypes = (result.isMarker && result.isLine ? client.types : (client.types | fmObjectFilter:{type:result.isMarker ? 'marker' : 'line'})); ""}}
|
||||
<div uib-dropdown keyboard-nav="true" ng-if="!client.readonly && (filteredTypes | fmPropertyCount) > 1" class="dropup">
|
||||
<button id="add-type-button" type="button" class="btn btn-default btn-sm" uib-dropdown-toggle>Add to map <span class="caret"></span></button>
|
||||
<ul class="dropdown-menu" uib-dropdown-menu role="menu" aria-labelledby="add-type-button">
|
||||
<li role="menuitem" ng-repeat="type in filteredTypes"><a href="javascript:" ng-click="addToMap(type)">{{type.name}}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button type="button" ng-if="!client.readonly && (filteredTypes | fmPropertyCount) == 1" ng-repeat="type in filteredTypes" class="btn btn-default btn-sm" ng-click="addToMap(type)">Add to map</button>
|
||||
<div ng-if="result.isMarker" uib-dropdown keyboard-nav="true" class="dropup">
|
||||
<button type="button" class="btn btn-default btn-sm" uib-dropdown-toggle>Use as <span class="caret"></span></button>
|
||||
<ul class="dropdown-menu" uib-dropdown-menu role="menu">
|
||||
<li role="menuitem"><a href="javascript:" ng-click="useForRoute(1)">Route start</a></li>
|
||||
<li role="menuitem"><a href="javascript:" ng-click="useForRoute(2)">Route via</a></li>
|
||||
<li role="menuitem"><a href="javascript:" ng-click="useForRoute(3)">Route destination</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<b-button-toolbar>
|
||||
<b-dropdown v-if="!client.readonly && types.length > 1" text="Add to map" size="sm">
|
||||
<b-dropdown-item v-for="type in types" href="javascript:" @click="$emit('add-to-map', type)">{{type.name}}</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
<b-button v-if="!client.readonly && types.length == 1" @click="$emit('add-to-map', types[0])" size="sm">Add to map</b-button>
|
||||
<b-dropdown v-if="isMarker" text="Use as" size="sm">
|
||||
<b-dropdown-item href="javascript:" @click="$emit('use-as-from')">Route start</b-dropdown-item>
|
||||
<b-dropdown-item href="javascript:" @click="$emit('use-as-via')">Route via</b-dropdown-item>
|
||||
<b-dropdown-item href="javascript:" @click="$emit('use-as-to')">Route destination</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</b-button-toolbar>
|
||||
</div>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<SearchResult | FileResult>;
|
||||
@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<SearchResult | FileResult>, type: Type): Promise<void> {
|
||||
this.$bvToast.hide("fm-search-result-info-add-error");
|
||||
|
||||
try {
|
||||
for (const result of results) {
|
||||
const obj: Partial<MarkerCreate & LineCreate> = {
|
||||
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");
|
||||
}
|
||||
|
||||
}
|
|
@ -3,35 +3,37 @@
|
|||
<b-carousel-slide>
|
||||
<b-alert v-if="(!searchResults || searchResults.length == 0) && (!mapResults || mapResults.length == 0)" show variant="danger">No results have been found.</b-alert>
|
||||
|
||||
<slot name="before"></slot>
|
||||
<div class="fm-search-box-collapse-point">
|
||||
<slot name="before"></slot>
|
||||
|
||||
<b-list-group v-if="mapResults && mapResults.length > 0">
|
||||
<b-list-group-item v-for="result in mapResults" :active="activeResults.includes(result)" v-fm-scroll-into-view="activeResults.includes(result)">
|
||||
<span>
|
||||
<a href="javascript:" @click="handleClick(result, $event)">{{result.name}}</a>
|
||||
{{" "}}
|
||||
<span class="result-type">({{client.types[result.typeId].name}})</span>
|
||||
</span>
|
||||
<a v-if="showZoom" href="javascript:" @click="handleZoom(result, $event)" title="Zoom to result" v-b-tooltip><Icon icon="zoom-in" alt="Zoom"></Icon></a>
|
||||
<a href="javascript:" @click="handleOpen(result, $event)" title="Show details" v-b-tooltip><Icon icon="arrow-right" alt="Details"></Icon></a>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
<b-list-group v-if="mapResults && mapResults.length > 0">
|
||||
<b-list-group-item v-for="result in mapResults" :active="activeResults.includes(result)" v-fm-scroll-into-view="activeResults.includes(result)">
|
||||
<span>
|
||||
<a href="javascript:" @click="handleClick(result, $event)">{{result.name}}</a>
|
||||
{{" "}}
|
||||
<span class="result-type">({{client.types[result.typeId].name}})</span>
|
||||
</span>
|
||||
<a v-if="showZoom" href="javascript:" @click="handleZoom(result, $event)" v-b-tooltip.left="'Zoom to result'"><Icon icon="zoom-in" alt="Zoom"></Icon></a>
|
||||
<a href="javascript:" @click="handleOpen(result, $event)" v-b-tooltip.left="'Show details'"><Icon icon="arrow-right" alt="Details"></Icon></a>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
|
||||
<hr v-if="mapResults && mapResults.length > 0 && searchResults && searchResults.length > 0"/>
|
||||
<hr v-if="mapResults && mapResults.length > 0 && searchResults && searchResults.length > 0"/>
|
||||
|
||||
<b-list-group v-if="searchResults && searchResults.length > 0">
|
||||
<b-list-group-item v-for="result in searchResults" :active="activeResults.includes(result)" v-fm-scroll-into-view="activeResults.includes(result)">
|
||||
<span>
|
||||
<a href="javascript:" @click="handleClick(result, $event)">{{result.display_name}}</a>
|
||||
{{" "}}
|
||||
<span class="result-type" v-if="result.type">({{result.type}})</span>
|
||||
</span>
|
||||
<a v-if="showZoom" href="javascript:" @click="handleZoom(result, $event)" title="Zoom to result" v-b-tooltip><Icon icon="zoom-in" alt="Zoom"></Icon></a>
|
||||
<a href="javascript:" @click="handleOpen(result, $event)" title="Show details" v-b-tooltip><Icon icon="arrow-right" alt="Details"></Icon></a>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
<b-list-group v-if="searchResults && searchResults.length > 0">
|
||||
<b-list-group-item v-for="result in searchResults" :active="activeResults.includes(result)" v-fm-scroll-into-view="activeResults.includes(result)">
|
||||
<span>
|
||||
<a href="javascript:" @click="handleClick(result, $event)">{{result.display_name}}</a>
|
||||
{{" "}}
|
||||
<span class="result-type" v-if="result.type">({{result.type}})</span>
|
||||
</span>
|
||||
<a v-if="showZoom" href="javascript:" @click="handleZoom(result, $event)" v-b-tooltip.left="'Zoom to result'"><Icon icon="zoom-in" alt="Zoom"></Icon></a>
|
||||
<a href="javascript:" @click="handleOpen(result, $event)" v-b-tooltip.right="'Show details'"><Icon icon="arrow-right" alt="Details"></Icon></a>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
|
||||
<slot name="after"></slot>
|
||||
<slot name="after"></slot>
|
||||
</div>
|
||||
|
||||
<!-- <div class="fm-search-buttons" ng-show="searchResults.features.length > 0">
|
||||
<button type="button" class="btn btn-default" ng-model="showAll" ng-click="showAll && zoomToAll()" uib-btn-checkbox ng-show="searchResults.features.length > 1">Show all</button>
|
||||
|
@ -48,7 +50,16 @@
|
|||
</b-carousel-slide>
|
||||
|
||||
<b-carousel-slide>
|
||||
<SearchResultInfo v-if="openResult" :result="openResult" show-back-button @back="closeResult()"></SearchResultInfo>
|
||||
<SearchResultInfo
|
||||
v-if="openResult"
|
||||
:result="openResult"
|
||||
show-back-button
|
||||
@back="closeResult()"
|
||||
@add-to-map="addToMap([openResult], $event)"
|
||||
@use-as-from="useAsFrom(openResult)"
|
||||
@use-as-via="useAsVia(openResult)"
|
||||
@use-as-to="useAsTo(openResult)"
|
||||
></SearchResultInfo>
|
||||
</b-carousel-slide>
|
||||
</b-carousel>
|
||||
</div>
|
|
@ -0,0 +1,45 @@
|
|||
import { Point, SearchResult, Type } from "facilmap-types";
|
||||
import { LineString, MultiLineString, MultiPolygon, Polygon, Position } from "geojson";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function mapSearchResultToType(result: SearchResult, type: Type): Record<string, string> {
|
||||
let keyMap = (keys: string[]) => {
|
||||
let ret: Record<string, string> = {};
|
||||
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: Record<string, string> = {};
|
||||
for(let key in resultDataKeys) {
|
||||
if(fieldKeys[key])
|
||||
ret[fieldKeys[key]] = resultData[resultDataKeys[key]];
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function lineStringToTrackPoints(geometry: LineString | MultiLineString | Polygon | MultiPolygon): Point[] {
|
||||
let coords: Position[][];
|
||||
if (geometry.type == "MultiPolygon") // Take only outer ring of polygons
|
||||
coords = geometry.coordinates.map((coordArr) => coordArr[0]);
|
||||
else if (geometry.type == "MultiLineString")
|
||||
coords = geometry.coordinates;
|
||||
else if (geometry.type == "Polygon")
|
||||
coords = [geometry.coordinates[0]];
|
||||
else
|
||||
coords = [geometry.coordinates];
|
||||
|
||||
return coords.flat().map((latlng) => ({ lat: latlng[1], lon: latlng[0] }));
|
||||
}
|
|
@ -101,7 +101,7 @@ export default class Toolbox extends Vue {
|
|||
}
|
||||
|
||||
importFile(): void {
|
||||
this.$root.$emit("fm-import-file");
|
||||
this.mapContext.$emit("fm-import-file");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -26,9 +26,9 @@
|
|||
<b-nav-item-dropdown text="Tools" right>
|
||||
<!--<b-dropdown-item v-if="!client.readonly" @click="openDialog('copy-pad-dialog')">Copy pad</b-dropdown-item>-->
|
||||
<b-dropdown-item v-if="interactive" href="javascript:" @click="importFile()">Open file</b-dropdown-item>
|
||||
<b-dropdown-item v-if="client.padData" :href="`${client.padData.id}/geojson${filterQuery.q}`" title="GeoJSON files store all map information and can thus be used for map backups and be re-imported without any loss.">Export as GeoJSON</b-dropdown-item>
|
||||
<b-dropdown-item v-if="client.padData" :href="`${client.padData.id}/gpx?useTracks=1${filterQuery.a}`" title="GPX files can be opened with most navigation software. In track mode, any calculated routes are saved in the file.">Export as GPX (tracks)</b-dropdown-item>
|
||||
<b-dropdown-item v-if="client.padData" :href="`${client.padData.id}/gpx?useTracks=0${filterQuery.a}`" title="GPX files can be opened with most navigation software. In route mode, only the start/end/via points are saved in the file, and the navigation software needs to recalculate the routes.">Export as GPX (routes)</b-dropdown-item>
|
||||
<b-dropdown-item v-if="client.padData" :href="`${client.padData.id}/geojson${filterQuery.q}`" v-b-tooltip.left="'GeoJSON files store all map information and can thus be used for map backups and be re-imported without any loss.'">Export as GeoJSON</b-dropdown-item>
|
||||
<b-dropdown-item v-if="client.padData" :href="`${client.padData.id}/gpx?useTracks=1${filterQuery.a}`" v-b-tooltip.left="'GPX files can be opened with most navigation software. In track mode, any calculated routes are saved in the file.'">Export as GPX (tracks)</b-dropdown-item>
|
||||
<b-dropdown-item v-if="client.padData" :href="`${client.padData.id}/gpx?useTracks=0${filterQuery.a}`" v-b-tooltip.left="'GPX files can be opened with most navigation software. In route mode, only the start/end/via points are saved in the file, and the navigation software needs to recalculate the routes.'">Export as GPX (routes)</b-dropdown-item>
|
||||
<b-dropdown-item v-if="client.padData" :href="`${client.padData.id}/table${filterQuery.q}`" target="_blank">Export as table</b-dropdown-item>
|
||||
<b-dropdown-divider v-if="client.padData"></b-dropdown-divider>
|
||||
<b-dropdown-item v-if="client.padData" href="javascript:" v-b-modal.fm-toolbox-edit-filter v-b-toggle.fm-toolbox-sidebar>Filter</b-dropdown-item>
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
flex-grow: 1;
|
||||
flex-basis: 12rem;
|
||||
overflow: hidden;
|
||||
min-height: 6.5rem;
|
||||
|
||||
.heightgraph-toggle, .heightgraph-close-icon {
|
||||
display: none !important;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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({
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
<span class="fm-elevation-stats" :id="`fm-elevation-stats-${id}`">
|
||||
<Icon icon="triangle-top" alt="Ascent"></Icon> {{route.ascent}} m / <Icon icon="triangle-bottom" alt="Descent"></Icon> {{route.descent}} m
|
||||
<b-popover :target="`fm-elevation-stats-${id}`" placement="top" triggers="hover" custom-class="fm-elevation-stats-popover">
|
||||
<span class="fm-elevation-stats">
|
||||
<span>
|
||||
<Icon icon="triangle-top" alt="Ascent"></Icon> {{route.ascent}} m / <Icon icon="triangle-bottom" alt="Descent"></Icon> {{route.descent}} m
|
||||
</span>
|
||||
<b-button :id="`fm-elevation-stats-${id}`" v-b-tooltip="'Show elevation statistics'"><Icon icon="info-circle" alt="Show stats"></Icon></b-button>
|
||||
<b-popover :target="`fm-elevation-stats-${id}`" placement="bottom" triggers="click blur" custom-class="fm-elevation-stats-popover">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-6">Total ascent</dt>
|
||||
<dd class="col-sm-6">{{route.ascent}} m</dd>
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
.fm-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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<MarkerFeature["properties"]> & Partial<LineFeature["properties"]> & {
|
||||
type FmFeatureProperties = Partial<MarkerFeature["properties"]> | Partial<LineFeature["properties"]>;
|
||||
type FeatureProperties = FmFeatureProperties & {
|
||||
tags?: Record<string, string>; // 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})),
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -36,7 +36,7 @@ export default class BboxHandler extends Handler {
|
|||
this.updateBbox(bounds, zoom);
|
||||
}
|
||||
|
||||
handleEmit: EventHandler<ClientEvents, "emit"> = (name, data) => {
|
||||
handleEmitResolve: EventHandler<ClientEvents, "emitResolve"> = (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);
|
||||
}
|
||||
}
|
|
@ -176,6 +176,7 @@ export default class LinesLayer extends FeatureGroup {
|
|||
const style: HighlightableLayerOptions<PolylineOptions> = {
|
||||
color: '#'+line.colour,
|
||||
weight: line.width,
|
||||
raised: false,
|
||||
opacity: 0.35
|
||||
} as any;
|
||||
|
||||
|
|
|
@ -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`);
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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>("Line", padId, { where: { typeId: typeId } });
|
||||
}
|
||||
|
||||
getPadLinesWithPoints(padId: PadId, bboxWithZoom?: BboxWithZoom): Highland.Stream<LineWithTrackPoints> {
|
||||
getPadLinesWithPoints(padId: PadId): Highland.Stream<LineWithTrackPoints> {
|
||||
return this.getPadLines(padId)
|
||||
.flatMap(wrapAsync(async (line): Promise<LineWithTrackPoints> => {
|
||||
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<TrackPoint[]> {
|
||||
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<data.length; i++) {
|
||||
if(i == 0 || data[i-1].idx != data[i].idx-1) // Beginning of segment
|
||||
indexes.push(data[i].idx-1);
|
||||
|
||||
indexes.push(data[i].idx);
|
||||
|
||||
if(i == data.length-1 || data[i+1].idx != data[i].idx+1) // End of segment
|
||||
indexes.push(data[i].idx+1);
|
||||
}
|
||||
|
||||
if(indexes.length == 0)
|
||||
return [ ];
|
||||
|
||||
return this.getLinePointsByIdx(lineId, indexes);
|
||||
}
|
||||
|
||||
async getLinePointsByIdx(lineId: ID, indexes: number[]): Promise<TrackPoint[]> {
|
||||
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<TrackPoint[]> {
|
||||
|
|
|
@ -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 ]);
|
||||
|
|
|
@ -43,7 +43,7 @@ export default class DatabaseRoutes {
|
|||
}, {
|
||||
sequelize: this._db._conn,
|
||||
indexes: [
|
||||
{ fields: [ "routeId" ] }
|
||||
{ fields: [ "routeId", "zoom" ] }
|
||||
],
|
||||
modelName: "Route"
|
||||
});
|
||||
|
|
|
@ -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<Array<DatabaseSearchResult>> {
|
||||
async search(padId: PadId, searchText: string): Promise<Array<FindOnMapResult>> {
|
||||
const objects = (await Promise.all([ "Marker", "Line" ].map(async (kind) => {
|
||||
const model = this._db._conn.model(kind) as ModelCtor<MarkerModel | LineModel>;
|
||||
const objs = await model.findAll<MarkerModel | LineModel>({
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns="http://www.topografix.com/GPX/1/1" creator="FacilMap" version="1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
|
||||
<metadata>
|
||||
<name><%=padData.name%></name>
|
||||
<time><%=time%></time>
|
||||
</metadata>
|
||||
<% for(let marker of markers) { -%>
|
||||
<wpt lat="<%=marker.lat%>" lon="<%=marker.lon%>"<% if(marker.ele != null) { %> ele="<%=marker.ele%>"<% } %>>
|
||||
<name><%=marker.name%></name>
|
||||
<desc><%=dataToText(types[marker.typeId].fields, marker.data)%></desc>
|
||||
</wpt>
|
||||
<% } -%>
|
||||
<% for(let line of lines) { -%>
|
||||
<% let t = (useTracks || line.mode == "track"); -%>
|
||||
<<%=t ? 'trk' : 'rte'%>>
|
||||
<name><%=line.name%></name>
|
||||
<desc><%=dataToText(types[line.typeId].fields, line.data)%></desc>
|
||||
<% if(t) { -%>
|
||||
<trkseg>
|
||||
<% for(let trackPoint of line.trackPoints) { -%>
|
||||
<trkpt lat="<%=trackPoint.lat%>" lon="<%=trackPoint.lon%>"<% if(trackPoint.ele != null) { %> ele="<%=trackPoint.ele%>"<% } %> />
|
||||
<% } -%>
|
||||
</trkseg>
|
||||
<% } else { -%>
|
||||
<% for(let routePoint of line.routePoints) { %>
|
||||
<rtept lat="<%=routePoint.lat%>" lon="<%=routePoint.lon%>" />
|
||||
<% } -%>
|
||||
<% } -%>
|
||||
</<%=t ? 'trk' : 'rte'%>>
|
||||
<% } -%>
|
||||
</gpx>
|
|
@ -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<string, string>) {
|
||||
|
@ -27,32 +23,55 @@ function dataToText(fields: Field[], data: Record<string, string>) {
|
|||
return text.join('\n\n');
|
||||
}
|
||||
|
||||
export async function exportGpx(database: Database, padId: PadId, useTracks: boolean, filter?: string): Promise<string> {
|
||||
const filterFunc = compileExpression(filter);
|
||||
export function exportGpx(database: Database, padId: PadId, useTracks: boolean, filter?: string): Highland.Stream<string> {
|
||||
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([
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<gpx xmlns="http://www.topografix.com/GPX/1/1" creator="FacilMap" version="1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">\n` +
|
||||
`\t<metadata>\n` +
|
||||
`\t\t<name>${quoteHtml(padData.name)}</name>\n` +
|
||||
`\t\t<time>${quoteHtml(new Date().toISOString())}</time>\n` +
|
||||
`\t</metadata>\n`
|
||||
]).concat(markers.map((marker) => (
|
||||
`\t<wpt lat="${quoteHtml(marker.lat)}" lon="${quoteHtml(marker.lon)}"${marker.ele != null ? ` ele="${quoteHtml(marker.ele)}"` : ""}>\n` +
|
||||
`\t\t<name>${quoteHtml(marker.name)}</name>\n` +
|
||||
`\t\t<desc>${quoteHtml(dataToText(types[marker.typeId].fields, marker.data))}</desc>\n` +
|
||||
`\t</wpt>\n`
|
||||
))).concat(lines.map((line) => ((useTracks || line.mode == "track") ? (
|
||||
`\t<trk>\n` +
|
||||
`\t\t<name>${quoteHtml(line.name)}</name>\n` +
|
||||
`\t\t<desc>${dataToText(types[line.typeId].fields, line.data)}</desc>\n` +
|
||||
`\t\t<trkseg>\n` +
|
||||
line.trackPoints.map((trackPoint) => (
|
||||
`\t\t\t<trkpt lat="${quoteHtml(trackPoint.lat)}" lon="${quoteHtml(trackPoint.lon)}"${trackPoint.ele != null ? ` ele="${quoteHtml(trackPoint.ele)}"` : ""} />\n`
|
||||
)).join("") +
|
||||
`\t\t</trkseg>\n` +
|
||||
`\t</trk>\n`
|
||||
) : (
|
||||
`\t<rte>\n` +
|
||||
`\t\t<name>${quoteHtml(line.name)}</name>\n` +
|
||||
`\t\t<desc>${quoteHtml(dataToText(types[line.typeId].fields, line.data))}</desc>\n` +
|
||||
line.routePoints.map((routePoint) => (
|
||||
`\t\t<rtept lat="${quoteHtml(routePoint.lat)}" lon="${quoteHtml(routePoint.lon)}" />\n`
|
||||
)).join("") +
|
||||
`\t</rte>\n`
|
||||
)))).concat([
|
||||
`</gpx>`
|
||||
]);
|
||||
}).flatten();
|
||||
}
|
||||
|
||||
type LineForExport = Partial<Pick<LineWithTrackPoints, "name" | "data" | "mode" | "trackPoints" | "routePoints">>;
|
||||
|
|
|
@ -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<string> {
|
|||
// 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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Events extends Record<keyof Events, any[]>, E extends E
|
|||
|
||||
export type MultipleEvents<Events extends Record<keyof Events, any[]>> = {
|
||||
[E in EventName<Events>]?: Array<Events[E][0]>;
|
||||
};
|
||||
};
|
|
@ -31,7 +31,7 @@ export interface FindOnMapQuery {
|
|||
query: string;
|
||||
}
|
||||
|
||||
export type FindOnMapMarker = Pick<Marker, "id" | "name" | "typeId" | "lat" | "lon"> & { kind: "marker"; similarity: number };
|
||||
export type FindOnMapMarker = Pick<Marker, "id" | "name" | "typeId" | "lat" | "lon" | "symbol"> & { kind: "marker"; similarity: number };
|
||||
export type FindOnMapLine = Pick<Line, "id" | "name" | "typeId" | "left" | "top" | "right" | "bottom"> & { kind: "line"; similarity: number };
|
||||
export type FindOnMapResult = FindOnMapMarker | FindOnMapLine;
|
||||
|
||||
|
|
|
@ -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, """).replace(/'/g, "'");
|
||||
export function quoteHtml(str: any): string {
|
||||
return `${str}`.replace(/&/g, "&").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 {
|
||||
|
|
12
yarn.lock
12
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"
|
||||
|
|
Ładowanie…
Reference in New Issue