pull/147/head
Candid Dauth 2021-03-21 23:38:48 +01:00
rodzic 1354c37ddf
commit c36a054ff9
61 zmienionych plików z 709 dodań i 457 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -13,6 +13,6 @@
.btn-toolbar {
> * + * {
margin-left: 0.5rem;
margin-left: 0.25rem;
}
}

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,9 @@
.fm-marker-info {
display: flex;
flex-direction: column;
min-height: 0;
.fm-search-box-collapse-point {
min-height: 1.5em;
}
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -35,6 +35,7 @@
.fm-route-suggestions.show {
display: grid !important;
grid-template-columns: auto 1fr;
opacity: 0.6;
&.isPending {
display: flex !important;

Wyświetl plik

@ -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
}
}; */
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -101,7 +101,7 @@ export default class Toolbox extends Vue {
}
importFile(): void {
this.$root.$emit("fm-import-file");
this.mapContext.$emit("fm-import-file");
}
}

Wyświetl plik

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

Wyświetl plik

@ -2,6 +2,7 @@
flex-grow: 1;
flex-basis: 12rem;
overflow: hidden;
min-height: 6.5rem;
.heightgraph-toggle, .heightgraph-close-icon {
display: none !important;

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,4 @@
.fm-icon {
display: inline-block;
vertical-align: middle;
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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[]> {

Wyświetl plik

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

Wyświetl plik

@ -43,7 +43,7 @@ export default class DatabaseRoutes {
}, {
sequelize: this._db._conn,
indexes: [
{ fields: [ "routeId" ] }
{ fields: [ "routeId", "zoom" ] }
],
modelName: "Route"
});

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
export function quoteHtml(str: any): string {
return `${str}`.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
export function quoteRegExp(str: string): string {
return (str+'').replace(/[.?*+^$[\]\\(){}|-]/g, "\\$&");
return `${str}`.replace(/[.?*+^$[\]\\(){}|-]/g, "\\$&");
}
export function generateRandomPadId(length: number = LENGTH): string {

Wyświetl plik

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