pull/147/head
Candid Dauth 2021-03-22 04:51:44 +01:00
rodzic 3dbe7500e8
commit 952f07fcca
32 zmienionych plików z 554 dodań i 420 usunięć

Wyświetl plik

@ -1,53 +0,0 @@
<div class="modal-header">
<button type="button" class="close" ng-click="$dismiss()"><span aria-hidden="true">&times;</span></button>
<h3 class="modal-title">Custom Import</h3>
</div>
<div class="modal-body">
<form class="form-horizontal" ng-submit="save()">
<div uib-alert class="alert-danger" ng-show="error">{{error.message || error}}</div>
<table class="table table-striped">
<thead>
<tr>
<th>Type</th>
<th>Map to…</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="(importTypeId, objectCount) in importTypeCounts">
<td><label for="map-type-{{importTypeId}}">{{results.types[importTypeId].type == 'marker' ? 'Markers' : 'Lines'}} of type “{{results.types[importTypeId].name}}” ({{objectCount}})</label></td>
<td><select id="map-type-{{importTypeId}}" ng-model="mapping[importTypeId]" class="form-control">
<option ng-if="client.writable == 2 && importTypeId" ng-value="'i' + importTypeId">Import type “{{results.types[importTypeId].name}}”</option>
<option ng-repeat="type in client.types" ng-if="type.name == results.types[importTypeId].name && type.type == results.types[importTypeId].type" ng-value="'e' + type.id">Existing type “{{type.name}}”</option>
<option ng-value="false">Do not import</option>
<option disabled>──────────</option>
<option ng-repeat="(typeId, type) in results.types" ng-if="client.writable == 2 && type.type == results.types[importTypeId].type && typeId != importTypeId" ng-value="'i' + typeId">Import type “{{type.name}}”</option>
<option ng-repeat="type in client.types" ng-if="type.type == results.types[importTypeId].type && type.name != results.types[importTypeId].name" ng-value="'e' + type.id">Existing type “{{type.name}}”</option>
</select></td>
</tr>
<tr ng-if="untypedMarkers > 0">
<td><label for="map-untyped-markers">Untyped markers ({{untypedMarkers}})</label></td>
<td><select id="map-untyped-markers" ng-model="mapUntypedMarkers" class="form-control">
<option ng-value="false">Do not import</option>
<option ng-repeat="(typeId, type) in results.types" ng-if="client.writable == 2 && type.type == 'marker'" ng-value="'i' + typeId">Import type “{{type.name}}”</option>
<option ng-repeat="type in client.types" ng-if="type.type == 'marker'" ng-value="'e' + type.id">Existing type “{{type.name}}”</option>
</select></td>
</tr>
<tr ng-if="untypedLines > 0">
<td><label for="map-untyped-lines">Untyped lines/polygons ({{untypedLines}})</label></td>
<td><select id="map-untyped-lines" ng-model="mapUntypedLines" class="form-control">
<option ng-value="false">Do not import</option>
<option ng-repeat="(typeId, type) in results.types" ng-if="client.writable == 2 && type.type == 'line'" ng-value="'i' + typeId">Import type “{{type.name}}”</option>
<option ng-repeat="type in client.types" ng-if="type.type == 'marker'" ng-value="'e' + type.id">Existing type “{{type.name}}”</option>
</select></td>
</tr>
</tbody>
</table>
<button type="submit" class="hidden"></button>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" ng-click="$dismiss()">Cancel</button>
<button type="submit" class="btn btn-primary" ng-click="save()">Import</button>
</div>

Wyświetl plik

@ -1,125 +0,0 @@
import fm from '../app';
fm.app.factory("fmSearchImport", function($uibModal, $rootScope) {
return function(map) {
let importUi = {
openImportDialog(results) {
let scope = $rootScope.$new();
let dialog = $uibModal.open({
template: require("./custom-import.html"),
scope: scope,
controller: "fmSearchCustomImportController",
size: "lg",
resolve: {
map: function() { return map; },
results: function() { return results; },
importUi: function() { return importUi; }
}
});
},
};
return importUi;
}
});
fm.app.controller("fmSearchCustomImportController", function(map, results, $scope, $q, importUi) {
$scope.results = results;
$scope.client = map.client;
$scope.importTypeCounts = {};
$scope.untypedMarkers = 0;
$scope.untypedLines = 0;
for(let feature of results.features) {
if(feature.fmTypeId == null) {
if(feature.isMarker)
$scope.untypedMarkers++;
if(feature.isLine)
$scope.untypedLines++;
} else if($scope.importTypeCounts[feature.fmTypeId] == null)
$scope.importTypeCounts[feature.fmTypeId] = 1;
else
$scope.importTypeCounts[feature.fmTypeId]++;
}
$scope.mapping = {};
for(let importTypeId in results.types) {
for(let typeId in map.client.types) {
if(results.types[importTypeId].name == map.client.types[typeId].name) {
$scope.mapping[importTypeId] = `e${typeId}`;
break;
}
}
if(!$scope.mapping[importTypeId])
$scope.mapping[importTypeId] = `i${importTypeId}`;
}
$scope.mapUntypedMarkers = false;
$scope.mapUntypedLines = false;
$scope.save = function() {
$scope.error = null;
let resolvedMapping = {};
let resolvedUntypedMarkerMapping;
let resolvedUntypedLineMapping;
let createTypes = {};
for(let mapping in $scope.mapping) {
if(!$scope.mapping[mapping])
continue;
let m = $scope.mapping[mapping].match(/^([ei])(.*)$/);
if(m[1] == "e")
resolvedMapping[mapping] = m[2];
else if(!createTypes[m[2]]) {
createTypes[m[2]] = map.client.addType(results.types[m[2]]).then((newType) => {
resolvedMapping[mapping] = newType.id;
});
}
}
if($scope.mapUntypedMarkers) {
let m = $scope.mapUntypedMarkers.match(/^([ei])(.*)$/);
if(m[1] == "e")
resolvedUntypedMarkerMapping = m[2];
else if(!createTypes[m[2]]) {
createTypes[m[2]] = map.client.addType(results.types[m[2]]).then((newType) => {
resolvedUntypedMarkerMapping = newType.id;
});
}
}
if($scope.mapUntypedLines) {
let m = $scope.mapUntypedLines.match(/^([ei])(.*)$/);
if(m[1] == "e")
resolvedUntypedLineMapping = m[2];
else if(!createTypes[m[2]]) {
createTypes[m[2]] = map.client.addType(results.types[m[2]]).then((newType) => {
resolvedUntypedLineMapping = newType.id;
});
}
}
$q.all(createTypes).then(() => {
let createObjects = [];
for(let feature of results.features) {
if(feature.fmTypeId == null) {
if(feature.isMarker && resolvedUntypedMarkerMapping)
createObjects.push(importUi.addResultToMap(feature, map.client.types[resolvedUntypedMarkerMapping]));
if(feature.isLine && resolvedUntypedLineMapping)
createObjects.push(importUi.addResultToMap(feature, map.client.types[resolvedUntypedLineMapping]));
} else if(resolvedMapping[feature.fmTypeId])
createObjects.push(importUi.addResultToMap(feature, map.client.types[resolvedMapping[feature.fmTypeId]]));
}
return $q.all(createObjects);
}).then(() => {
$scope.$close();
}).catch((err) => {
$scope.error = err;
});
};
});

Wyświetl plik

@ -6,7 +6,8 @@
FacilMap aims to be a privacy-friendly open-source alternative to commercial maps that track your data. When you use FacilMap,
anything you do on the map (for example pan/zoom the map, search for something, calculate a route, add a marker) is sent to the
FacilMap server and persisted there only if necessary for the interaction. No personally identifiable information is persisted
(for example your IP address). FacilMap does not set any cookies.
(for example your IP address). FacilMap stores the zoom preferences that you set in the search bar (auto-zoom, etc.) in the
localStorage of your browser, but it does not set any cookies.
</p>
<p>
FacilMap combines multiple third-party services (listed below under Map data) into one versatile map. When you use FacilMap,

Wyświetl plik

@ -1,6 +1,6 @@
import WithRender from "./click-marker.vue";
import Vue from "vue";
import { Component } from "vue-property-decorator";
import { Component, Watch } from "vue-property-decorator";
import { InjectClient, InjectMapComponents, InjectMapContext } from "../../utils/decorators";
import { MapComponents, MapContext } from "../leaflet-map/leaflet-map";
import { LineCreate, MarkerCreate, Point, SearchResult, Type } from "facilmap-types";
@ -23,6 +23,8 @@ export default class ClickMarker extends Vue {
@InjectMapComponents() mapComponents!: MapComponents;
@InjectClient() client!: Client;
lastClick = 0;
results: SearchResult[] = [];
layers!: SearchResultsLayer[]; // Don't make layer objects reactive
@ -43,6 +45,14 @@ export default class ClickMarker extends Vue {
});
}
@Watch("mapContext.selection")
handleSelectionChange(): void {
for (let i = this.results.length - 1; i >= 0; i--) {
if (!this.mapContext.selection.some((item) => item.type == "searchResult" && item.layerId == this.layerIds[i]))
this.close(this.results[i]);
}
}
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])) {
@ -53,11 +63,32 @@ export default class ClickMarker extends Vue {
}
async handleMapClick(pos: Point): Promise<void> {
const results = await this.client.find({
query: `geo:${round(pos.lat, 5)},${round(pos.lon, 5)}?z=${this.mapContext.zoom}`,
loadUrls: false,
elevation: true
});
const now = Date.now();
if (now - this.lastClick < 500) {
// Hacky solution to avoid markers being created when the user double-clicks the map. If multiple clicks happen less than 500 ms from each
// other, all those clicks are ignored.
this.lastClick = now;
return;
}
this.lastClick = now;
const [results] = await Promise.all([
this.client.find({
query: `geo:${round(pos.lat, 5)},${round(pos.lon, 5)}?z=${this.mapContext.zoom}`,
loadUrls: false,
elevation: true
}),
new Promise((resolve) => {
// Specify the minimum time the search will take to allow for some time for the double-click detection
setTimeout(resolve, 500);
})
]);
if (now !== this.lastClick) {
// There has been another click since the one we are reacting to.
return;
}
if (results.length > 0) {
const layer = new SearchResultsLayer([results[0]]).addTo(this.mapComponents.map);
@ -85,6 +116,11 @@ export default class ClickMarker extends Vue {
this.layers.splice(idx, 1);
}
clear(): void {
for (let i = this.results.length - 1; i >= 0; i--)
this.close(this.results[i]);
}
async addToMap(result: SearchResult, type: Type): Promise<void> {
this.$bvToast.hide("fm-click-marker-add-error");

Wyświetl plik

@ -137,6 +137,6 @@ export default class EditType extends Vue {
}
canControl(what: keyof Marker | keyof Line): boolean {
return canControl(this.type, what);
return canControl(this.type, what, null);
}
}

Wyświetl plik

@ -0,0 +1,5 @@
.fm-file-results {
display: flex;
flex-direction: column;
min-height: 0;
}

Wyświetl plik

@ -11,6 +11,7 @@ import Icon from "../ui/icon/icon";
import SearchResults from "../search-results/search-results";
import { displayView } from "facilmap-leaflet";
import { MapComponents } from "../leaflet-map/leaflet-map";
import "./file-results.scss";
type ViewImport = FileResultObject["views"][0];
type TypeImport = FileResultObject["types"][0];
@ -30,6 +31,11 @@ export default class FileResults extends Vue {
@Prop({ type: Number, required: true }) layerId!: number;
@Prop({ type: Object, required: true }) file!: FileResultObject;
/** When clicking a search result, union zoom to it. Normal zoom is done when clicking the zoom button. */
@Prop({ type: Boolean, default: false }) unionZoom!: boolean;
/** When clicking or selecting a search result, zoom to it. */
@Prop({ type: Boolean, default: false }) autoZoom!: boolean;
get hasViews(): boolean {
return this.file.views.length > 0;
}

Wyświetl plik

@ -2,7 +2,9 @@
<SearchResults
:search-results="file.features"
:layer-id="layerId"
@click-result="$emit('click-result', $event)"
:auto-zoom="autoZoom"
:union-zoom="unionZoom"
:custom-types="file.types"
>
<template #before>
<template v-if="hasViews">
@ -37,20 +39,5 @@
</template>
</template>
<!-- <div class="fm-search-buttons" ng-show="file.features.length > 0">
<button type="button" class="btn btn-default" ng-model="showAll" ng-click="showAll && zoomToAll()" uib-btn-checkbox ng-show="file.features.length > 1">Show all</button>
<button type="button" class="btn btn-link" ng-click="reset()"><fm-icon fm-icon="remove" alt="Remove"></fm-icon></button>
<div uib-dropdown keyboard-nav="true" class="pull-right dropup" ng-if="client.padId && !client.readonly">
<button id="search-add-all-button" type="button" class="btn btn-default" uib-dropdown-toggle>Add all to map <span class="caret"></span></button>
<ul class="dropdown-menu" uib-dropdown-menu role="menu" aria-labelledby="search-add-all-button">
<li ng-if="(file.types | fmPropertyCount) > 0" role="menuitem"><a href="javascript:" ng-click="customImport()">Custom type mapping</a></li>
<li ng-if="(file.features | filter:{isMarker: true}).length > 0" role="menuitem" ng-repeat="type in client.types | fmObjectFilter:{type:'marker'}"><a href="javascript:" ng-click="addAllToMap(type)">Add all markers as {{type.name}}</a></li>
<li ng-if="(file.features | filter:{isLine: true}).length > 0" role="menuitem" ng-repeat="type in client.types | fmObjectFilter:{type:'line'}"><a href="javascript:" ng-click="addAllToMap(type)">Add all lines/polygons as {{type.name}}</a></li>
</ul>
</div>
</div> -->
</SearchResults>
</div>

Wyświetl plik

@ -0,0 +1,5 @@
.fm-import-tab.fm-import-tab.fm-import-tab {
padding: 0.5rem;
display: flex;
flex-direction: column;
}

Wyświetl plik

@ -4,14 +4,13 @@ import { Component, Ref } from "vue-property-decorator";
import { InjectMapComponents, InjectMapContext } from "../../utils/decorators";
import { MapComponents, MapContext } from "../leaflet-map/leaflet-map";
import { showErrorToast } from "../../utils/toasts";
import { FileResult, FileResultObject, parseFiles } from "../../utils/files";
import { FileResultObject, parseFiles } from "../../utils/files";
import pluralize from "pluralize";
import Icon from "../ui/icon/icon";
import "./import.scss";
import { SearchResultsLayer } from "facilmap-leaflet";
import { Util } from "leaflet";
import FileResults from "../file-results/file-results";
import { flyTo } from "../../utils/zoom";
@WithRender
@Component({
@ -79,18 +78,6 @@ export default class Import extends Vue {
this.importFiles(event.dataTransfer?.files);
}
zoomToResult(result: FileResult): void {
const layer = this.layers.find((layer, idx) => this.files[idx].features.includes(result));
if (!layer)
return;
const featureLayer = layer.getLayers().find((l) => l._fmSearchResult === result) as any;
if (!featureLayer)
return;
flyTo(this.mapComponents.map, { bounds: featureLayer.getBounds() });
}
async importFiles(files: FileList | undefined): Promise<void> {
this.$bvToast.hide("fm-import-error");

Wyświetl plik

@ -1,7 +1,7 @@
<div>
<input type="file" multiple="multiple" class="d-none" ref="fileInput" @change="importFiles(fileInput.files)">
<portal to="fm-search-box">
<b-tab v-for="(file, idx) in files" :id="`fm-import-tab-${idx}`">
<b-tab v-for="(file, idx) in files" :id="`fm-import-tab-${idx}`" class="fm-import-tab">
<template #title>
<span class="closeable-tab-title">
<span>{{file.title}}</span>
@ -11,7 +11,7 @@
<FileResults
:file="file"
:layer-id="layerIds[idx]"
@click-result="zoomToResult"
auto-zoom
></FileResults>
</b-tab>
</portal>

Wyświetl plik

@ -33,8 +33,9 @@ export default class SearchBox extends Vue {
cardHeader!: HTMLElement;
tab = 0;
panStartTop: number | null = null;
restoreTop: number | null = null;
tabHistory = [0];
panStartHeight: number | null = null;
restoreHeight: number | null = null;
resizeStartHeight: number | null = null;
resizeStartWidth: number | null = null;
hasFocus = false;
@ -70,37 +71,44 @@ export default class SearchBox extends Vue {
}
handlePanStart(event: any): void {
this.restoreTop = null;
this.panStartTop = parseInt($(this.searchBox).css("flex-basis"));
this.restoreHeight = null;
this.panStartHeight = parseInt($(this.searchBox).css("flex-basis"));
}
handlePanMove(event: any): void {
if (this.isNarrow && this.panStartTop != null && event.srcEvent.type != "pointercancel")
$(this.searchBox).stop().css("flexBasis", `${this.getSanitizedTop(this.panStartTop - event.deltaY)}px`);
if (this.isNarrow && this.panStartHeight != null && event.srcEvent.type != "pointercancel")
$(this.searchBox).stop().css("flexBasis", `${this.getSanitizedHeight(this.panStartHeight - event.deltaY)}px`);
}
handlePanEnd(): void {
this.mapComponents.map.invalidateSize({ animate: true });
this.mapComponents.map.invalidateSize({ pan: false });
}
getTopFromBottom(bottom: number): number {
return (this.searchBox.offsetParent as HTMLElement).offsetHeight - bottom;
getSanitizedHeight(height: number): number {
const maxHeight = (this.searchBox.offsetParent as HTMLElement).offsetHeight - 45;
return Math.max(0, Math.min(maxHeight, height));
}
getSanitizedTop(top: number): number {
const maxTop = (this.searchBox.offsetParent as HTMLElement).offsetHeight - 45;
return Math.max(0, Math.min(maxTop, top));
}
handleActivateTab(): void {
this.restoreTop = null;
handleActivateTab(idx: number): void {
this.restoreHeight = null;
this.tabHistory = [
...this.tabHistory.filter((tab) => tab != idx),
idx
];
}
handleChanged(newTabs: BTab[], oldTabs: BTab[]): void {
if (this.restoreTop != null && newTabs.length < oldTabs.length) {
$(this.searchBox).animate({ top: this.restoreTop });
this.restoreTop = null;
if (this.restoreHeight != null && newTabs.length < oldTabs.length) {
$(this.searchBox).animate({ flexBasis: this.restoreHeight }, () => {
this.mapComponents.map.invalidateSize({ pan: false });
});
this.restoreHeight = null;
}
const lastActiveTab = this.tabHistory[this.tabHistory.length - 1];
this.tabHistory = this.tabHistory.map((idx) => newTabs.indexOf(oldTabs[idx])).filter((idx) => idx != -1);
if (!newTabs.includes(oldTabs[lastActiveTab]))
this.tab = this.tabHistory[this.tabHistory.length - 1];
}
handleShowTab(id: string, expand = true): void {
@ -110,11 +118,12 @@ export default class SearchBox extends Vue {
if (this.isNarrow && expand) {
setTimeout(() => {
const maxTop = this.getSanitizedTop(this.getTopFromBottom(300));
const currentTop = this.searchBox.offsetTop;
if (currentTop > maxTop) {
this.restoreTop = currentTop;
$(this.searchBox).animate({ top: maxTop });
const currentHeight = parseInt($(this.searchBox).css("flex-basis"));
if (currentHeight < 120) {
this.restoreHeight = currentHeight;
$(this.searchBox).animate({ flexBasis: 120 }, () => {
this.mapComponents.map.invalidateSize({ pan: false });
});
}
}, 0);
}
@ -146,4 +155,13 @@ export default class SearchBox extends Vue {
this.searchBoxContext.$emit("resizereset");
}
handleFocusIn(e: FocusEvent): void {
if ((e.target as HTMLElement).closest("input,textarea"))
this.hasFocus = true;
}
handleFocusOut(e: FocusEvent): void {
this.hasFocus = false;
}
}

Wyświetl plik

@ -1,4 +1,4 @@
<b-card no-body ref="searchBox" class="fm-search-box" :class="{ isNarrow, hasFocus }" @focusin="hasFocus = true" @focusout="hasFocus = false">
<b-card no-body ref="searchBox" class="fm-search-box" :class="{ isNarrow, hasFocus }" @focusin="handleFocusIn" @focusout="handleFocusOut">
<b-tabs card align="center" v-model="tab" ref="tabsComponent" @changed="handleChanged" @activate-tab="handleActivateTab" no-fade>
<SearchFormTab></SearchFormTab>
<RouteFormTab></RouteFormTab>

Wyświetl plik

@ -1,4 +1,4 @@
#fm-search-form-tab {
.fm-search-form-tab.fm-search-form-tab.fm-search-form-tab {
padding: 0.5rem;
display: flex;
flex-direction: column;

Wyświetl plik

@ -1,3 +1,3 @@
<b-tab title="Search" id="fm-search-form-tab">
<b-tab title="Search" id="fm-search-form-tab" class="fm-search-form-tab">
<SearchForm></SearchForm>
</b-tab>

Wyświetl plik

@ -10,9 +10,11 @@ import { showErrorToast } from "../../utils/toasts";
import { FindOnMapResult, SearchResult } from "facilmap-types";
import SearchResults from "../search-results/search-results";
import context from "../context";
import { combineZoomDestinations, flyTo, getZoomDestinationForMapResult, getZoomDestinationForResults, getZoomDestinationForSearchResult } from "../../utils/zoom";
import { flyTo, getZoomDestinationForMapResult, getZoomDestinationForResults, getZoomDestinationForSearchResult } from "../../utils/zoom";
import { MapComponents, MapContext } from "../leaflet-map/leaflet-map";
import { Util } from "leaflet";
import { isMapResult } from "../../utils/search";
import storage from "../../utils/storage";
@WithRender
@Component({
@ -29,8 +31,6 @@ export default class SearchForm extends Vue {
autofocus = !context.isNarrow && context.autofocus;
searchString = "";
loadedSearchString = "";
autoZoom = true;
zoomToAll = false;
searchCounter = 0;
layerId: number = null as any;
@ -41,6 +41,22 @@ export default class SearchForm extends Vue {
this.layerId = Util.stamp(this.mapComponents.searchResultsLayer);
}
get autoZoom(): boolean {
return storage.autoZoom;
}
set autoZoom(autoZoom: boolean) {
storage.autoZoom = autoZoom;
}
get zoomToAll(): boolean {
return storage.zoomToAll;
}
set zoomToAll(zoomToAll: boolean) {
storage.zoomToAll = zoomToAll;
}
async search(): Promise<void> {
this.searchInput.blur();
@ -120,26 +136,8 @@ export default class SearchForm extends Vue {
this.mapComponents.searchResultsLayer.setResults([]);
};
handleClickResult(result: SearchResult | FindOnMapResult): void {
if (this.autoZoom) {
if (this.zoomToAll)
this.unionZoomToResult(result);
else
this.zoomToResult(result);
}
}
zoomToResult(result: SearchResult | FindOnMapResult): void {
const dest = "kind" in result ? getZoomDestinationForMapResult(result) : getZoomDestinationForSearchResult(result);
if (dest)
flyTo(this.mapComponents.map, dest);
}
unionZoomToResult(result: SearchResult | FindOnMapResult): void {
// Zoom to item, keep current map bounding box in view
let dest = "kind" in result ? getZoomDestinationForMapResult(result) : getZoomDestinationForSearchResult(result);
if (dest)
dest = combineZoomDestinations([dest, { bounds: this.mapComponents.map.getBounds() }]);
const dest = isMapResult(result) ? getZoomDestinationForMapResult(result) : getZoomDestinationForSearchResult(result);
if (dest)
flyTo(this.mapComponents.map, dest);
}
@ -163,19 +161,6 @@ export default class SearchForm extends Vue {
}
/* TODO
scope.addAllToMap = function(type) {
for(let result of scope.searchResults.features) {
if((type.type == "marker" && result.isMarker) || (type.type == "line" && result.isLine))
scope.addResultToMap(result, type, true);
}
};
scope.customImport = function() {
importUi.openImportDialog(scope.searchResults);
};
var layerGroup = L.featureGroup([]).addTo(map.map);
function getZoomDestination(result, unionZoom) {
let forBounds = (bounds) => ([
bounds.getCenter(),
@ -201,88 +186,10 @@ export default class SearchForm extends Vue {
}
}
function prepareResults(results) {
for(let result of results) {
if((result.lat != null && result.lon != null) || result.geojson && result.geojson.type == "Point")
result.isMarker = true;
if([ "LineString", "MultiLineString", "Polygon", "MultiPolygon" ].indexOf(result.geojson && result.geojson.type) != -1)
result.isLine = true;
}
}
function renderSearchResults() {
if(scope.searchResults && scope.searchResults.features.length > 0) {
prepareResults(scope.searchResults.features);
scope.searchResults.features.forEach(function(result) {
renderResult(scope.submittedSearchString, scope.searchResults.features, result, false, layerGroup);
});
}
}
function clearRenders() {
layerGroup.clearLayers();
if(scope.searchResults) {
scope.searchResults.features.forEach((result) => {
result.marker = null;
result.layer = null;
});
}
}
var queryUi = searchUi.queryUi = {
show: function() {
el.show();
},
hide: function() {
scope.reset();
el.hide();
},
search: function(query, noZoom, showAll) {
if(query != null)
scope.searchString = query;
if(showAll != null)
scope.showAll = showAll;
scope.search(noZoom);
},
showFiles: function(files) {
scope.submittedSearchString = "";
scope.showAll = true;
scope.searchResults = filesUi.parseFiles(files);
scope.mapResults = null;
renderSearchResults();
scope.zoomToAll();
},
getSubmittedSearch: function() {
return scope.submittedSearchString;
},
isZoomedToSubmittedSearch: function() {
if(scope.searchResults && scope.searchResults.features.length > 0) {
let [center, zoom] = getZoomDestination();
return map.map.getZoom() == zoom && fmUtils.pointsEqual(map.map.getCenter(), center, map.map);
}
},
hasResults: function() {
return !!scope.searchResults;
}
};
scope.$on("$destroy", () => {
scope.reset();
searchUi.searchUi = null;
});
var filesUi = fmSearchFiles(map);
var importUi = fmSearchImport(map);
}
};
}); */

Wyświetl plik

@ -20,9 +20,8 @@
v-if="searchResults || mapResults"
:search-results="searchResults"
:map-results="mapResults"
:show-zoom="!autoZoom || zoomToAll"
:auto-zoom="autoZoom"
:union-zoom="zoomToAll"
:layer-id="layerId"
@click-result="handleClickResult"
@zoom-result="zoomToResult"
></SearchResults>
</div>

Wyświetl plik

@ -10,6 +10,7 @@ import "./search-result-info.scss";
import { FileResult } from "../../utils/files";
import { MapComponents, MapContext } from "../leaflet-map/leaflet-map";
import { isLineResult, isMarkerResult } from "../../utils/search";
import { flyTo, getZoomDestinationForSearchResult } from "../../utils/zoom";
@WithRender
@Component({
@ -39,6 +40,12 @@ export default class SearchResultInfo extends Vue {
return Object.values(this.client.types).filter((type) => (this.isMarker && type.type == "marker") || (this.isLine && type.type == "line"));
}
zoomToResult(): void {
const dest = getZoomDestinationForSearchResult(this.result);
if (dest)
flyTo(this.mapComponents.map, dest);
}
}
/* function showResultInfoBox(query, results, result, onClose) {

Wyświetl plik

@ -23,10 +23,13 @@
</dl>
<b-button-toolbar>
<b-button v-b-tooltip="'Zoom to search result'" @click="zoomToResult()" size="sm"><Icon icon="zoom-in" alt="Zoom to search result"></Icon></b-button>
<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>

Wyświetl plik

@ -36,4 +36,8 @@
.fm-search-box-collapse-point {
min-height: 3em;
}
.btn-toolbar {
margin-top: 0.5rem;
}
}

Wyświetl plik

@ -1,7 +1,7 @@
import WithRender from "./search-results.vue";
import Vue from "vue";
import { Component, Prop, Watch } from "vue-property-decorator";
import { FindOnMapResult, LineCreate, MarkerCreate, SearchResult, Type } from "facilmap-types";
import { FindOnMapResult, ID, LineCreate, MarkerCreate, SearchResult, Type } from "facilmap-types";
import "./search-results.scss";
import Icon from "../ui/icon/icon";
import Client from "facilmap-client";
@ -11,14 +11,17 @@ 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 { FileResult, FileResultObject } from "../../utils/files";
import { showErrorToast } from "../../utils/toasts";
import { lineStringToTrackPoints, mapSearchResultToType } from "./utils";
import { isFileResult, isSearchResult } from "../../utils/search";
import { isFileResult, isLineResult, isMapResult, isMarkerResult } from "../../utils/search";
import { combineZoomDestinations, flyTo, getZoomDestinationForMapResult, getZoomDestinationForResults, getZoomDestinationForSearchResult } from "../../utils/zoom";
import { mapValues, pickBy, uniq } from "lodash";
import FormModal from "../ui/form-modal/form-modal";
@WithRender
@Component({
components: { Icon, SearchResultInfo }
components: { FormModal, Icon, SearchResultInfo }
})
export default class SearchResults extends Vue {
@ -28,8 +31,12 @@ export default class SearchResults extends Vue {
@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;
/** When clicking a search result, union zoom to it. Normal zoom is done when clicking the zoom button. */
@Prop({ type: Boolean, default: false }) unionZoom!: boolean;
/** When clicking or selecting a search result, zoom to it. */
@Prop({ type: Boolean, default: false }) autoZoom!: boolean;
@Prop({ type: Object, default: () => ({}) }) customTypes!: FileResultObject["types"];
activeTab = 0;
@ -37,14 +44,18 @@ export default class SearchResults extends Vue {
return context.isNarrow;
}
get openResult(): SearchResult | undefined {
if (this.activeResults.length == 1 && !("kind" in this.activeResults[0]))
get showZoom(): boolean {
return !this.autoZoom || this.unionZoom;
}
get openResult(): SearchResult | FileResult | undefined {
if (this.activeResults.length == 1 && !isMapResult(this.activeResults[0]))
return this.activeResults[0];
else
return undefined;
}
get activeResults(): Array<SearchResult | FindOnMapResult> {
get activeResults(): Array<SearchResult | FileResult | FindOnMapResult> {
return [
...(this.searchResults || []).filter((result) => this.mapContext.selection.some((item) => item.type == "searchResult" && item.result === result)),
...(this.mapResults || []).filter((result) => {
@ -58,30 +69,71 @@ export default class SearchResults extends Vue {
];
}
get isAllSelected(): boolean {
return !this.searchResults?.some((result) => !this.activeResults.includes(result));
}
get activeSearchResults(): Array<SearchResult | FileResult> {
return this.activeResults.filter((result) => !isMapResult(result)) as any;
}
get activeMarkerSearchResults(): Array<SearchResult | FileResult> {
return this.activeSearchResults.filter((result) => isMarkerResult(result)) as any;
}
get activeLineSearchResults(): Array<SearchResult | FileResult> {
return this.activeSearchResults.filter((result) => isLineResult(result)) as any;
}
get markerTypes(): Array<Type> {
return Object.values(this.client.types).filter((type) => type.type == "marker");
}
get lineTypes(): Array<Type> {
return Object.values(this.client.types).filter((type) => type.type == "line");
}
get hasCustomTypes(): boolean {
return Object.keys(this.customTypes).length > 0;
}
closeResult(): void {
this.activeTab = 0;
}
@Watch("openResult")
handleOpenResultChange(openResult: SearchResult | undefined): void {
handleOpenResultChange(openResult: SearchResult | FileResult | 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);
handleClick(result: SearchResult | FileResult | FindOnMapResult, event: MouseEvent): void {
const toggle = event.ctrlKey || event.shiftKey;
this.selectResult(result, toggle);
if (this.autoZoom)
this.zoomToSelectedResults(this.unionZoom || toggle);
}
handleZoom(result: SearchResult | FindOnMapResult, event: MouseEvent): void {
this.$emit('zoom-result', result);
zoomToSelectedResults(unionZoom: boolean): void {
let dest = getZoomDestinationForResults(this.activeResults);
if (dest && unionZoom)
dest = combineZoomDestinations([dest, { bounds: this.mapComponents.map.getBounds() }]);
if (dest)
flyTo(this.mapComponents.map, dest);
}
handleOpen(result: SearchResult | FindOnMapResult, event: MouseEvent): void {
zoomToResult(result: SearchResult | FileResult | FindOnMapResult): void {
const dest = isMapResult(result) ? getZoomDestinationForMapResult(result) : getZoomDestinationForSearchResult(result);
if (dest)
flyTo(this.mapComponents.map, dest);
}
handleOpen(result: SearchResult | FileResult | FindOnMapResult, event: MouseEvent): void {
this.selectResult(result, false);
setTimeout(async () => {
if ("kind" in result) {
if (isMapResult(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);
@ -90,19 +142,35 @@ export default class SearchResults extends Vue {
}, 0);
}
selectResult(result: SearchResult | FindOnMapResult, toggle: boolean): void {
const item: SelectedItem = "kind" in result ? { type: result.kind, id: result.id } : { type: "searchResult", result, layerId: this.layerId };
selectResult(result: SearchResult | FileResult | FindOnMapResult, toggle: boolean): void {
const item: SelectedItem = isMapResult(result) ? { type: result.kind, id: result.id } : { type: "searchResult", result, layerId: this.layerId };
if (toggle)
this.mapComponents.selectionHandler.toggleItem(item);
else
this.mapComponents.selectionHandler.setSelectedItems([item]);
}
async addToMap(results: Array<SearchResult | FileResult>, type: Type): Promise<void> {
toggleSelectAll(): void {
if (!this.searchResults)
return;
if (this.isAllSelected)
this.mapComponents.selectionHandler.setSelectedItems([]);
else {
this.mapComponents.selectionHandler.setSelectedItems(this.searchResults.map((result) => ({ type: "searchResult", result, layerId: this.layerId })));
if (this.autoZoom)
this.zoomToSelectedResults(true);
}
}
async _addToMap(data: Array<{ result: SearchResult | FileResult; type: Type }>): Promise<boolean> {
this.$bvToast.hide("fm-search-result-info-add-error");
try {
for (const result of results) {
const selection: SelectedItem[] = [];
for (const { result, type } of data) {
const obj: Partial<MarkerCreate & LineCreate> = {
name: result.short_name
};
@ -122,7 +190,7 @@ export default class SearchResults extends Vue {
typeId: type.id
});
this.mapComponents.selectionHandler.setSelectedItems([{ type: "marker", id: marker.id }], true);
selection.push({ type: "marker", id: marker.id });
} else if(type.type == "line") {
if (obj.routePoints) {
const line = await this.client.addLine({
@ -131,7 +199,7 @@ export default class SearchResults extends Vue {
typeId: type.id
});
this.mapComponents.selectionHandler.setSelectedItems([{ type: "line", id: line.id }], true);
selection.push({ type: "line", id: line.id });
} else {
const trackPoints = lineStringToTrackPoints(result.geojson as any);
const line = await this.client.addLine({
@ -142,15 +210,24 @@ export default class SearchResults extends Vue {
mode: "track"
});
this.mapComponents.selectionHandler.setSelectedItems([{ type: "line", id: line.id }], true);
selection.push({ type: "line", id: line.id });
}
}
}
this.mapComponents.selectionHandler.setSelectedItems(selection, true);
return true;
} catch (err) {
showErrorToast(this, "fm-search-result-info-add-error", "Error adding to map", err);
return false;
}
}
async addToMap(results: Array<SearchResult | FileResult>, type: Type): Promise<void> {
this._addToMap(results.map((result) => ({ result, type })));
}
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}`);
@ -171,4 +248,130 @@ export default class SearchResults extends Vue {
this.useAs(result, "fm-route-set-to");
}
////////////////////////////////////////////////////////////////////
// Custom mapping
////////////////////////////////////////////////////////////////////
customMapping: Record<ID, false | string> = {};
untypedMarkerMapping: false | string = false;
untypedLineMapping: false | string = false;
isCustomImportSaving = false;
get activeFileResults(): Array<FileResult> {
return this.activeResults.filter(isFileResult);
}
get activeFileResultsByType(): Record<ID, Array<FileResult>> {
return mapValues(this.customTypes, (type, id) => this.activeFileResults.filter((result) => result.fmTypeId != null && `${result.fmTypeId}` == `${id}`));
}
get untypedMarkers(): Array<FileResult> {
return this.activeFileResults.filter((result) => (result.fmTypeId == null || !this.customTypes[result.fmTypeId]) && isMarkerResult(result));
}
get untypedLines(): Array<FileResult> {
return this.activeFileResults.filter((result) => (result.fmTypeId == null || !this.customTypes[result.fmTypeId]) && isLineResult(result));
}
get customMappingOptions(): Record<ID, Array<{ value: string | false; text: string; disabled?: boolean }>> {
return mapValues(pickBy(this.customTypes, (customType, customTypeId) => this.activeFileResultsByType[customTypeId as any].length > 0), (customType, customTypeId) => {
const options: Array<{ value: string | false; text: string; disabled?: boolean }> = [];
for (const type of Object.values(this.client.types)) {
if (type.name == customType.name && type.type == customType.type)
options.push({ value: `e${type.id}`, text: `Existing type “${type.name}` });
}
if (this.client.writable == 2)
options.push({ value: `i${customTypeId}`, text: `Import type “${customType.name}` });
options.push({ value: false, text: "Do not import" });
options.push({ value: false, text: "──────────", disabled: true });
for (const type of Object.values(this.client.types)) {
if (type.name != customType.name && type.type == customType.type)
options.push({ value: `e${type.id}`, text: `Existing type “${type.name}` });
}
for (const customTypeId2 of Object.keys(this.customTypes)) {
const customType2 = this.customTypes[customTypeId2 as any];
if (this.client.writable == 2 && customType2.type == customType.type && customTypeId2 != customTypeId)
options.push({ value: `i${customTypeId2}`, text: `Import type “${customType2.name}` });
}
return options;
});
}
get untypedMarkerMappingOptions(): Array<{ value: string | false; text: string }> {
const options: Array<{ value: string | false; text: string }> = [];
options.push({ value: false, text: "Do not import" });
for (const customTypeId of Object.keys(this.customTypes)) {
const customType = this.customTypes[customTypeId as any];
if (this.client.writable && customType.type == "marker")
options.push({ value: `i${customTypeId}`, text: `Import type “${customType.name}` });
}
for (const type of Object.values(this.client.types)) {
if (type.type == "marker")
options.push({ value: `e${type.id}`, text: `Existing type “${type.name}` });
}
return options;
}
get untypedLineMappingOptions(): Array<{ value: string | false; text: string }> {
const options: Array<{ value: string | false; text: string }> = [];
options.push({ value: false, text: "Do not import" });
for (const customTypeId of Object.keys(this.customTypes)) {
const customType = this.customTypes[customTypeId as any];
if (this.client.writable && customType.type == "line")
options.push({ value: `i${customTypeId}`, text: `Import type “${customType.name}` });
}
for (const type of Object.values(this.client.types)) {
if (type.type == "line")
options.push({ value: `e${type.id}`, text: `Existing type “${type.name}` });
}
return options;
}
initializeCustomImport(): void {
this.customMapping = mapValues(this.customMappingOptions, (options) => options[0].value);
this.untypedMarkerMapping = false;
this.untypedLineMapping = false;
}
async customImport(): Promise<void> {
this.$bvToast.hide("fm-search-result-info-add-error");
try {
const resolvedMapping: Record<string, Type> = {};
for (const id of uniq([...Object.values(this.customMapping), this.untypedMarkerMapping, this.untypedLineMapping])) {
if (id !== false) {
const m = id.match(/^([ei])(.*)$/);
if (m && m[1] == "e")
resolvedMapping[id] = this.client.types[m[2] as any];
else if (m && m[1] == "i")
resolvedMapping[id] = await this.client.addType(this.customTypes[m[2] as any]);
}
}
const add = this.activeFileResults.flatMap((result) => {
const id = (result.fmTypeId && this.customMapping[result.fmTypeId]) ? this.customMapping[result.fmTypeId] : isMarkerResult(result) ? this.untypedMarkerMapping : this.untypedLineMapping;
return id !== false && resolvedMapping[id] ? [{ result, type: resolvedMapping[id] }] : [];
});
if (await this._addToMap(add))
this.$bvModal.hide("fm-search-results-custom-import");
} catch(err) {
showErrorToast(this, "fm-search-result-info-add-error", "Error importing to map", err);
}
}
}

Wyświetl plik

@ -13,7 +13,7 @@
{{" "}}
<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 v-if="showZoom" href="javascript:" @click="zoomToResult(result)" 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>
@ -27,7 +27,7 @@
{{" "}}
<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 v-if="showZoom" href="javascript:" @click="zoomToResult(result)" 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>
@ -35,18 +35,22 @@
<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>
<button type="button" class="btn btn-link" ng-click="reset()"><fm-icon fm-icon="remove" alt="Remove"></fm-icon></button>
<div uib-dropdown keyboard-nav="true" class="pull-right dropup" ng-if="client.padId && !client.readonly">
<button id="search-add-all-button" type="button" class="btn btn-default" uib-dropdown-toggle>Add all to map <span class="caret"></span></button>
<ul class="dropdown-menu" uib-dropdown-menu role="menu" aria-labelledby="search-add-all-button">
<li ng-if="(searchResults.types | fmPropertyCount) > 0" role="menuitem"><a href="javascript:" ng-click="customImport()">Custom type mapping</a></li>
<li ng-if="(searchResults.features | filter:{isMarker: true}).length > 0" role="menuitem" ng-repeat="type in client.types | fmObjectFilter:{type:'marker'}"><a href="javascript:" ng-click="addAllToMap(type)">Add all markers as {{type.name}}</a></li>
<li ng-if="(searchResults.features | filter:{isLine: true}).length > 0" role="menuitem" ng-repeat="type in client.types | fmObjectFilter:{type:'line'}"><a href="javascript:" ng-click="addAllToMap(type)">Add all lines/polygons as {{type.name}}</a></li>
</ul>
</div>
</div> -->
<b-button-toolbar v-if="client.padId && !client.readonly && searchResults && searchResults.length > 0">
<b-button @click="toggleSelectAll" :pressed="isAllSelected">Select all</b-button>
<b-dropdown v-if="client.padId && !client.readonly" :disabled="activeSearchResults.length == 0" :text="`Add selected item${activeSearchResults.length == 1 ? '' : 's'} to map`">
<template v-if="activeMarkerSearchResults.length > 0 && markerTypes.length ">
<b-dropdown-item v-for="type in markerTypes" href="javascript:" @click="addToMap(activeMarkerSearchResults, type)">Marker items as {{type.name}}</b-dropdown-item>
</template>
<template v-if="activeLineSearchResults.length > 0 && lineTypes.length ">
<b-dropdown-item v-for="type in lineTypes" href="javascript:" @click="addToMap(activeLineSearchResults, type)">Line/polygon items as {{type.name}}</b-dropdown-item>
</template>
<template v-if="hasCustomTypes">
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item href="javascript:" v-b-modal.fm-search-results-custom-import>Custom type mapping</b-dropdown-item>
</template>
</b-dropdown>
</b-button-toolbar>
</b-carousel-slide>
<b-carousel-slide>
@ -62,4 +66,39 @@
></SearchResultInfo>
</b-carousel-slide>
</b-carousel>
<FormModal
id="fm-search-results-custom-import"
title="Custom Import"
dialog-class="fm-search-results-custom-import"
:is-saving="isCustomImportSaving"
is-create
ok-title="Import"
@submit="customImport"
@show="initializeCustomImport"
>
<b-table-simple striped hover>
<b-thead>
<b-tr>
<b-th>Type</b-th>
<b-th>Map to</b-th>
</b-tr>
</b-thead>
<b-tbody>
<b-tr v-for="(options, importTypeId) in customMappingOptions">
<b-td><label :for="`map-type-${importTypeId}`">{{customTypes[importTypeId].type == 'marker' ? 'Markers' : 'Lines'}} of type {{customTypes[importTypeId].name}} ({{activeFileResultsByType[importTypeId].length}})</label></b-td>
<b-td><b-form-select :id="`map-type-${importTypeId}`" v-model="customMapping[importTypeId]" :options="options"></b-form-select></b-td>
</b-tr>
<b-tr v-if="untypedMarkers.length > 0">
<b-td><label for="map-untyped-markers">Untyped markers ({{untypedMarkers}})</label></b-td>
<b-td><b-form-select id="map-untyped-markers" v-model="untypedMarkerMapping" :options="untypedMarkerMappingOptions"></b-form-select></b-td>
</b-tr>
<b-tr v-if="untypedLines.length > 0">
<b-td><label for="map-untyped-lines">Untyped lines/polygons ({{untypedLines}})</label></b-td>
<b-td><b-form-select id="map-untyped-lines" v-model="untypedLineMapping" :options="untypedLineMappingOptions"></b-form-select></b-td>
</b-tr>
</b-tbody>
</b-table-simple>
</FormModal>
</div>

Wyświetl plik

@ -4,12 +4,12 @@
<Sidebar id="fm-toolbox-sidebar">
<b-nav-item v-if="!client.padId && interactive" href="javascript:" v-b-modal.fm-toolbox-create-pad v-b-toggle.fm-toolbox-sidebar>Start collaborative map</b-nav-item>
<b-nav-item-dropdown v-if="!client.readonly && client.padData" text="Add" :disabled="!!mapContext.interaction" right>
<b-dropdown-item v-for="type in client.types" :disabled="!!mapContext.interaction" href="javascript:" @click="addObject(type)">{{type.name}}</b-dropdown-item>
<b-dropdown-item v-for="type in client.types" :disabled="!!mapContext.interaction" href="javascript:" @click="addObject(type)" v-b-toggle.fm-toolbox-sidebar>{{type.name}}</b-dropdown-item>
<b-dropdown-divider v-if="client.writable == 2"></b-dropdown-divider>
<b-dropdown-item v-if="client.writable == 2" :disabled="!!mapContext.interaction" href="javascript:" v-b-modal.fm-toolbox-manage-types>Manage types</b-dropdown-item>
<b-dropdown-item v-if="client.writable == 2" :disabled="!!mapContext.interaction" href="javascript:" v-b-modal.fm-toolbox-manage-types v-b-toggle.fm-toolbox-sidebar>Manage types</b-dropdown-item>
</b-nav-item-dropdown>
<b-nav-item-dropdown v-if="client.padData && (!client.readonly || Object.keys(client.views).length > 0)" text="Views" right>
<b-dropdown-item v-for="view in client.views" href="javascript:" @click="displayView(view)">{{view.name}}</b-dropdown-item>
<b-dropdown-item v-for="view in client.views" href="javascript:" @click="displayView(view)" v-b-toggle.fm-toolbox-sidebar>{{view.name}}</b-dropdown-item>
<b-dropdown-divider v-if="client.writable == 2"></b-dropdown-divider>
<b-dropdown-item v-if="client.writable == 2" href="javascript:" v-b-modal.fm-toolbox-save-view v-b-toggle.fm-toolbox-sidebar>Save current view</b-dropdown-item>
<b-dropdown-item v-if="client.writable == 2" href="javascript:" v-b-modal.fm-toolbox-manage-views v-b-toggle.fm-toolbox-sidebar>Manage views</b-dropdown-item>
@ -25,7 +25,7 @@
</b-nav-item-dropdown>
<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="interactive" href="javascript:" @click="importFile()" v-b-toggle.fm-toolbox-sidebar>Open file</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>

Wyświetl plik

@ -7,7 +7,6 @@
:no-close-on-esc="noCancel" :no-close-on-backdrop="noCancel" :hide-header-close="noCancel" :ok-only="noCancel"
:busy="isSaving"
:ok-disabled="!isCreate && !isModified"
:ok-title="okTitle || (isCreate ? 'Create' : 'Save')"
@ok.prevent="observer.handleSubmit(handleSubmit)"
@show="$emit('show')"
scrollable
@ -29,7 +28,7 @@
</b-button>
<b-button v-if="noCancel || isModified || isCreate" variant="primary" @click="ok" :disabled="isSaving">
<b-spinner small v-if="isSaving"></b-spinner>
{{isCreate ? "Create" : "Save"}}
{{okTitle || (isCreate ? 'Create' : 'Save')}}
</b-button>
</template>
</b-modal>

Wyświetl plik

@ -41,6 +41,9 @@ export default class SelectionHandler extends Handler {
_linesLayer: LinesLayer;
_searchResultLayers: SearchResultsLayer[];
_mapFocusTime: number | undefined = undefined;
_mapInteraction: number = 0;
constructor(map: Map, markersLayer: MarkersLayer, linesLayer: LinesLayer, searchResultsLayer: SearchResultsLayer) {
super(map);
@ -55,6 +58,10 @@ export default class SelectionHandler extends Handler {
for (const layer of this._searchResultLayers)
layer.on("click", this.handleClickSearchResult);
this._map.on("click", this.handleClickMap);
this._map.on("fmInteractionStart", this.handleMapInteractionStart);
this._map.on("fmInteractionEnd", this.handleMapInteractionEnd);
this._map.getContainer().addEventListener("focusin", this.handleMapFocusIn);
this._map.getContainer().addEventListener("focusout", this.handleMapFocusOut);
}
removeHooks(): void {
@ -63,6 +70,10 @@ export default class SelectionHandler extends Handler {
for (const layer of this._searchResultLayers)
layer.off("click", this.handleClickSearchResult);
this._map.off("click", this.handleClickMap);
this._map.off("fmInteractionStart", this.handleMapInteractionStart);
this._map.off("fmInteractionEnd", this.handleMapInteractionEnd);
this._map.getContainer().removeEventListener("focusin", this.handleMapFocusIn);
this._map.getContainer().removeEventListener("focusout", this.handleMapFocusOut);
}
addSearchResultLayer(layer: SearchResultsLayer): void {
@ -82,6 +93,11 @@ export default class SelectionHandler extends Handler {
layer.off("click", this.handleClickSearchResult);
this._searchResultLayers.splice(idx, 1);
const layerId = Util.stamp(layer);
const without = this._selection.filter((item) => item.type != "searchResult" || item.layerId != layerId);
if (without.length != this._selection.length)
this.setSelectedItems(without);
}
getSelection(): SelectedItem[] {
@ -130,6 +146,9 @@ export default class SelectionHandler extends Handler {
}
handleClickItem(item: SelectedItem, e: LeafletEvent): void {
if (this._mapInteraction)
return;
DomEvent.stopPropagation(e);
if ((e.originalEvent as any).ctrlKey || (e.originalEvent as any).shiftKey)
this.toggleItem(item, true);
@ -153,14 +172,35 @@ export default class SelectionHandler extends Handler {
}
handleClickMap = (e: LeafletEvent): void => {
if (this._mapInteraction)
return;
if (!(e.originalEvent as any).ctrlKey && !(e.originalEvent as any).shiftKey) {
if (this._selection.length == 0)
this.fire("fmMapClick", e);
else
if (this._selection.length == 0) {
// Focus event is fired before click event. Delay so that our click handlers knows whether the map was focused before it was clicked.
if (this._mapFocusTime && Date.now() - this._mapFocusTime > 500)
this.fire("fmMapClick", e);
} else
this.setSelectedItems([]);
}
}
handleMapFocusIn = (): void => {
this._mapFocusTime = Date.now();
}
handleMapFocusOut = (): void => {
this._mapFocusTime = undefined;
}
handleMapInteractionStart = (): void => {
this._mapInteraction++;
}
handleMapInteractionEnd = (): void => {
this._mapInteraction--;
}
}
export default interface HashHandler extends Evented {}

Wyświetl plik

@ -0,0 +1,33 @@
import Vue from "vue";
export interface Storage {
zoomToAll: boolean;
autoZoom: boolean;
}
const storage = Vue.observable({
zoomToAll: false,
autoZoom: true
});
export default storage;
try {
const val = localStorage.getItem("facilmap");
if (val) {
const parsed = JSON.parse(val);
storage.zoomToAll = !!parsed.zoomToAll;
storage.autoZoom = !!parsed.autoZoom;
}
} catch (err) {
console.error("Error reading local storage", err);
}
const watcher = new Vue({ data: { storage } });
watcher.$watch("storage", () => {
try {
localStorage.setItem("facilmap", JSON.stringify(storage));
} catch (err) {
console.error("Error saving to local storage", err);
}
}, { deep: true });

Wyświetl plik

@ -21,8 +21,8 @@ export function mergeObject<T extends Record<keyof any, any>>(oldObject: T | und
}
}
export function canControl(type: Type, what: keyof Marker | keyof Line, ignoreField?: Field): boolean {
if((type as any)[what+"Fixed"] && ignoreField != null)
export function canControl(type: Type, what: keyof Marker | keyof Line, ignoreField?: Field | null): boolean {
if((type as any)[what+"Fixed"] && ignoreField !== null)
return false;
const idx = "control"+what.charAt(0).toUpperCase() + what.slice(1);

Wyświetl plik

@ -3,6 +3,8 @@ import { fmToLeafletBbox, HashQuery } from "facilmap-leaflet";
import Client, { RouteWithTrackPoints } from "facilmap-client";
import { SelectedItem } from "./selection";
import { FindOnMapLine, FindOnMapMarker, FindOnMapResult, Line, Marker, SearchResult } from "facilmap-types";
import { Geometry } from "geojson";
import { isMapResult } from "./search";
export type ZoomDestination = {
center?: LatLng;
@ -10,6 +12,21 @@ export type ZoomDestination = {
bounds?: LatLngBounds;
};
export function getZoomDestinationForGeoJSON(geojson: Geometry): ZoomDestination | undefined {
if (geojson.type == "GeometryCollection")
return combineZoomDestinations(geojson.geometries.map((geo) => getZoomDestinationForGeoJSON(geo)));
else if (geojson.type == "Point")
return { center: latLng(geojson.coordinates[1], geojson.coordinates[0]) };
else if (geojson.type == "LineString" || geojson.type == "MultiPoint")
return combineZoomDestinations(geojson.coordinates.map((pos) => ({ center: latLng(pos[1], pos[0]) })));
else if (geojson.type == "Polygon" || geojson.type == "MultiLineString")
return combineZoomDestinations(geojson.coordinates.flat().map((pos) => ({ center: latLng(pos[1], pos[0]) })));
else if (geojson.type == "MultiPolygon")
return combineZoomDestinations(geojson.coordinates.flat().flat().map((pos) => ({ center: latLng(pos[1], pos[0]) })));
else
return undefined;
}
export function getZoomDestinationForMarker(marker: Marker | FindOnMapMarker): ZoomDestination {
return { center: latLng(marker.lat, marker.lon), zoom: 15 };
}
@ -29,10 +46,17 @@ export function getZoomDestinationForRoute(route: RouteWithTrackPoints): ZoomDes
export function getZoomDestinationForSearchResult(result: SearchResult): ZoomDestination | undefined {
const dest: ZoomDestination = {};
if (result.boundingbox)
dest.bounds = latLngBounds([[result.boundingbox[0], result.boundingbox[3]], [result.boundingbox[1], result.boundingbox[2]]]);
else if (result.geojson)
dest.bounds = getZoomDestinationForGeoJSON(result.geojson)?.bounds;
if (result.lat && result.lon)
dest.center = latLng(Number(result.lat), Number(result.lon));
else if (result.geojson)
dest.center = getZoomDestinationForGeoJSON(result.geojson)?.center;
if (result.zoom != null)
dest.zoom = result.zoom;
@ -48,12 +72,12 @@ export function getZoomDestinationForMapResult(result: FindOnMapResult): ZoomDes
export function getZoomDestinationForResults(results: Array<SearchResult | FindOnMapResult>): ZoomDestination | undefined {
return combineZoomDestinations(results
.map((result) => (("kind" in result) ? getZoomDestinationForMapResult(result) : getZoomDestinationForSearchResult(result)))
.map((result) => (isMapResult(result) ? getZoomDestinationForMapResult(result) : getZoomDestinationForSearchResult(result)))
.filter((result) => !!result) as ZoomDestination[]
);
}
export function combineZoomDestinations(destinations: ZoomDestination[]): ZoomDestination | undefined {
export function combineZoomDestinations(destinations: Array<ZoomDestination | undefined>): ZoomDestination | undefined {
if (destinations.length == 0)
return undefined;
else if (destinations.length == 1)
@ -61,7 +85,8 @@ export function combineZoomDestinations(destinations: ZoomDestination[]): ZoomDe
const bounds = latLngBounds(undefined as any);
for (const destination of destinations) {
bounds.extend((destination.bounds || destination.center)!);
if (destination)
bounds.extend((destination.bounds || destination.center)!);
}
return { bounds };
}
@ -71,7 +96,7 @@ export function normalizeZoomDestination(map: Map, destination: ZoomDestination)
if (result.center == null)
result.center = destination.bounds!.getCenter();
if (result.zoom == null)
result.zoom = Math.min(15, map.getBoundsZoom(result.bounds!));
result.zoom = result.bounds ? Math.min(15, map.getBoundsZoom(result.bounds)) : 15;
return result as any;
}

Wyświetl plik

@ -1,5 +1,5 @@
import { Point } from "facilmap-types";
import { Layer, LeafletMouseEvent, Map } from "leaflet";
import { DomEvent, Layer, LeafletMouseEvent, Map } from "leaflet";
import "./click-listener.scss";
class TransparentLayer extends Layer {
@ -44,6 +44,7 @@ export function addClickListener(map: Map, listener: ClickListener, moveListener
cancel();
e.originalEvent.preventDefault();
DomEvent.stopPropagation(e);
listener({ lat: e.latlng.lat, lon: e.latlng.lng });
};

Wyświetl plik

@ -111,7 +111,7 @@ export default class LinesLayer extends FeatureGroup {
trackPoints: [ ]
};
let handler: ClickListenerHandle;
let handler: ClickListenerHandle | undefined = undefined;
const addPoint = (pos: Point) => {
line.routePoints.push(pos);
@ -121,6 +121,7 @@ export default class LinesLayer extends FeatureGroup {
};
const handleClick = (pos: Point) => {
handler = undefined;
if(line.routePoints.length > 0 && pos.lon == line.routePoints[line.routePoints.length-1].lon && pos.lat == line.routePoints[line.routePoints.length-1].lat)
finishLine(true);
else
@ -135,7 +136,8 @@ export default class LinesLayer extends FeatureGroup {
}
const finishLine = async (save: boolean) => {
handler.cancel();
if (handler)
handler.cancel();
this._deleteLine(line);
delete this._endDrawLine;

Wyświetl plik

@ -30,6 +30,7 @@ interface ShapeInfo {
width: number;
baseX: number;
baseY: number;
scale: number;
}
const MARKER_SHAPES: Partial<Record<Shape, ShapeInfo>> = {
@ -47,7 +48,8 @@ const MARKER_SHAPES: Partial<Record<Shape, ShapeInfo>> = {
height: 36,
width: 26,
baseX: 13,
baseY: 36
baseY: 36,
scale: 1
},
circle: {
svg: (
@ -63,7 +65,8 @@ const MARKER_SHAPES: Partial<Record<Shape, ShapeInfo>> = {
height: 36,
width: 36,
baseX: 18,
baseY: 18
baseY: 18,
scale: 0.85
}
};
@ -186,8 +189,8 @@ export function getMarkerHtml(colour: string, height: number, symbol?: Symbol, s
}
export function getMarkerIcon(colour: Colour, height: number, symbol?: Symbol, shape?: Shape, highlight = false): Icon {
const scale = height / 31;
const shapeObj = (shape && MARKER_SHAPES[shape]) || MARKER_SHAPES.drop!;
const scale = shapeObj.scale * height / shapeObj.height;
return L.icon({
iconUrl: getMarkerUrl(colour, height, symbol, shape, highlight),
iconSize: [shapeObj.width*scale, shapeObj.height*scale],

Wyświetl plik

@ -1,3 +1,5 @@
import { Geometry } from "geojson";
export type SearchResultType = string;
export interface SearchResult {
@ -9,7 +11,7 @@ export interface SearchResult {
lon?: number;
zoom?: number;
extratags?: Record<string, string>;
geojson?: GeoJSON.GeoJSON;
geojson?: Geometry;
icon?: string;
type: SearchResultType;
id?: string;