kopia lustrzana https://github.com/FacilMap/facilmap
Porównaj commity
24 Commity
4bfe3ff4c2
...
4941d77e98
Autor | SHA1 | Data |
---|---|---|
Candid Dauth | 4941d77e98 | |
Candid Dauth | e2b0f11c92 | |
Candid Dauth | 6044bf117c | |
Candid Dauth | f56f860812 | |
Candid Dauth | 5a407e8395 | |
Candid Dauth | 86789a9af8 | |
Candid Dauth | 3c1b59c280 | |
Candid Dauth | 0e22ed24b2 | |
Candid Dauth | 02d4ab0c42 | |
Candid Dauth | 6fa3502f58 | |
Candid Dauth | 13dd7f6afc | |
Candid Dauth | d20356fa6e | |
Candid Dauth | b9a85a1185 | |
Candid Dauth | 3b2ff2dfc9 | |
Candid Dauth | 25c79e4a73 | |
Candid Dauth | 07c059237f | |
Candid Dauth | 9cdfa4cef9 | |
Candid Dauth | b1414d3428 | |
Candid Dauth | 7ef26a46ab | |
Candid Dauth | 028cd7216b | |
Candid Dauth | 2cc372f486 | |
Candid Dauth | 3562065aec | |
Candid Dauth | 73bb22b1be | |
Candid Dauth | 33642c546d |
|
@ -49,8 +49,9 @@ jobs:
|
|||
-
|
||||
name: Start integration test components
|
||||
run: |
|
||||
if ! docker compose -f ./integration-tests/docker-compose.yml up --wait; then
|
||||
status="$?"
|
||||
docker compose -f ./integration-tests/docker-compose.yml up --wait
|
||||
status="$?"
|
||||
if (( status != 0 )); then
|
||||
docker compose -f ./integration-tests/docker-compose.yml logs
|
||||
exit "$status"
|
||||
fi
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { io, type ManagerOptions, type Socket as SocketIO, type SocketOptions } from "socket.io-client";
|
||||
import type { Bbox, BboxWithZoom, CRU, EventHandler, EventName, FindOnMapQuery, FindPadsQuery, FindPadsResult, FindQuery, GetPadQuery, HistoryEntry, ID, Line, LineExportRequest, LineTemplateRequest, LineToRouteCreate, SocketEvents, Marker, MultipleEvents, ObjectWithId, PadData, PadId, PagedResults, SocketRequest, SocketRequestName, SocketResponse, Route, RouteClear, RouteCreate, RouteExportRequest, RouteInfo, RouteRequest, SearchResult, SocketVersion, TrackPoint, Type, View, Writable, SocketClientToServerEvents, SocketServerToClientEvents } from "facilmap-types";
|
||||
import type { Bbox, BboxWithZoom, CRU, EventHandler, EventName, FindOnMapQuery, FindPadsQuery, FindPadsResult, FindQuery, GetPadQuery, HistoryEntry, ID, Line, LineExportRequest, LineTemplateRequest, LineToRouteCreate, SocketEvents, Marker, MultipleEvents, ObjectWithId, PadData, PadId, PagedResults, SocketRequest, SocketRequestName, SocketResponse, Route, RouteClear, RouteCreate, RouteExportRequest, RouteInfo, RouteRequest, SearchResult, SocketVersion, TrackPoint, Type, View, Writable, SocketClientToServerEvents, SocketServerToClientEvents, LineTemplate } from "facilmap-types";
|
||||
|
||||
export interface ClientEvents extends SocketEvents<SocketVersion.V2> {
|
||||
connect: [];
|
||||
|
@ -448,7 +448,7 @@ export default class Client {
|
|||
return await this._emit("deleteMarker", data);
|
||||
}
|
||||
|
||||
async getLineTemplate(data: LineTemplateRequest): Promise<Line> {
|
||||
async getLineTemplate(data: LineTemplateRequest): Promise<LineTemplate> {
|
||||
return await this._emit("getLineTemplate", data);
|
||||
}
|
||||
|
||||
|
|
|
@ -138,7 +138,6 @@ Search for places. Does not persist anything on the server, simply serves as a p
|
|||
* `data` (object): An object with the following properties:
|
||||
* `query` (string): The query string
|
||||
* `loadUrls` (boolean): Whether to return the file if `query` is a URL
|
||||
* `elevation` (boolean): Whether to find out the elevation of the result(s). Will make the search significantly slower.
|
||||
* **Returns:** A promise that is resolved with the following value:
|
||||
* If `data.query` is a URL to a GPX/KML/OSM/GeoJSON file and `loadUrls` is `true`, a string with the content of the file.
|
||||
* Otherwise an array of [SearchResults](./types.md#searchresult).
|
||||
|
|
|
@ -83,6 +83,7 @@ their `idx` property.
|
|||
* `legend1`, `legend2` (string): Markdown free text to be shown above and below the legend
|
||||
* `defaultViewId` (number): The ID of the default view (if any)
|
||||
* `defaultView` ([view](#view)): A copy of the default view object (set by the server)
|
||||
* `createDefaultTypes` (boolean): On creation of a map, set this to false to not create one marker and one line type.
|
||||
|
||||
## View
|
||||
|
||||
|
@ -108,6 +109,7 @@ their `idx` property.
|
|||
* `id` (number): The ID of this type
|
||||
* `name` (string): The name of this type
|
||||
* `type` (string): `marker` or `line`
|
||||
* `idx` (number): The sorting position of this type. When a list of types is shown to the user, it must be ordered by this value. If types were deleted or reordered, there may be gaps in the sequence of indexes, but no two types on the same map can ever have the same index. When setting this as part of a type creation/update, other types with a same/higher index will have their index increased to be moved down the list.
|
||||
* `defaultColour`, `defaultSize`, `defaultSymbol`, `defaultShape`, `defaultWidth`, `defaultStroke`, `defaultMode` (string/number): Default values for the
|
||||
different object properties
|
||||
* `colourFixed`, `sizeFixed`, `symbolFixed`, `shapeFixed`, `widthFixed`, `strokeFixed`, `modeFixed` (boolean): Whether those values are fixed and
|
||||
|
|
|
@ -25,6 +25,8 @@ The config of the FacilMap server can be set either by using environment variabl
|
|||
| `CUSTOM_CSS_FILE` | | | The path of a CSS file that should be included ([see more details below](#custom-css-file)). |
|
||||
| `NOMINATIM_URL` | | `https://nominatim.openstreetmap.org` | The URL to the Nominatim server (used to search for places). |
|
||||
| `OPEN_ELEVATION_URL` | | `https://api.open-elevation.com` | The URL to the Open Elevation server (used to look up the elevation for markers). |
|
||||
| `OPEN_ELEVATION_THROTTLE_MS` | | `1000` | The minimum time between two requests to the Open Elevation API. Set to `0` if you are using your own self-hosted instance of Open Elevation. |
|
||||
| `OPEN_ELEVATION_MAX_BATCH_SIZE` | | `200` | The maximum number of points to resolve in one request through the Open Elevation API. Set this to `1000` if you are using your own self-hosted Open Elevation instance. |
|
||||
|
||||
FacilMap makes use of several third-party services that require you to register (for free) and generate an API key:
|
||||
* Mapbox and OpenRouteService are used for calculating routes. Mapbox is used for basic routes, OpenRouteService is used when custom route mode settings are made. If these API keys are not defined, calculating routes will fail.
|
||||
|
|
|
@ -66,6 +66,7 @@
|
|||
"mitt": "^3.0.1",
|
||||
"osmtogeojson": "^3.0.0-beta.5",
|
||||
"p-debounce": "^4.0.0",
|
||||
"p-throttle": "^6.1.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"popper-max-size-modifier": "^0.2.0",
|
||||
"qrcode.vue": "^3.4.1",
|
||||
|
|
|
@ -1,63 +1,90 @@
|
|||
<script setup lang="ts">
|
||||
import type { SearchResult } from "facilmap-types";
|
||||
import { round } from "facilmap-utils";
|
||||
import { find, getElevationForPoint, getFallbackLonLatResult, round } from "facilmap-utils";
|
||||
import { SearchResultsLayer } from "facilmap-leaflet";
|
||||
import SearchResultInfo from "./search-result-info.vue";
|
||||
import { Util } from "leaflet";
|
||||
import { computed, markRaw, nextTick, readonly, ref, shallowReactive, toRef, watch } from "vue";
|
||||
import { computed, markRaw, nextTick, reactive, readonly, ref, toRef, watch, type Raw } from "vue";
|
||||
import { useEventListener } from "../utils/utils";
|
||||
import SearchBoxTab from "./search-box/search-box-tab.vue"
|
||||
import { injectContextRequired, requireClientContext, requireMapContext, requireSearchBoxContext } from "./facil-map-context-provider/facil-map-context-provider.vue";
|
||||
import { injectContextRequired, requireMapContext, requireSearchBoxContext } from "./facil-map-context-provider/facil-map-context-provider.vue";
|
||||
import type { WritableClickMarkerTabContext } from "./facil-map-context-provider/click-marker-tab-context";
|
||||
import { useToasts } from "./ui/toasts/toasts.vue";
|
||||
|
||||
const toasts = useToasts();
|
||||
|
||||
const context = injectContextRequired();
|
||||
const mapContext = requireMapContext(context);
|
||||
const client = requireClientContext(context);
|
||||
const searchBoxContext = requireSearchBoxContext(context);
|
||||
|
||||
let lastClick = 0;
|
||||
type Tab = {
|
||||
id: number;
|
||||
result: SearchResult;
|
||||
layer: Raw<SearchResultsLayer>;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const activeResults = ref<SearchResult[]>([]);
|
||||
const layers = shallowReactive<SearchResultsLayer[]>([]);
|
||||
const tabs = ref<Tab[]>([]);
|
||||
|
||||
useEventListener(mapContext, "open-selection", handleOpenSelection);
|
||||
|
||||
const layerIds = computed(() => layers.map((layer) => Util.stamp(layer)));
|
||||
const layerIds = computed(() => tabs.value.map((tab) => Util.stamp(tab.layer)));
|
||||
|
||||
watch(() => mapContext.value.selection, () => {
|
||||
for (let i = activeResults.value.length - 1; i >= 0; i--) {
|
||||
for (let i = tabs.value.length - 1; i >= 0; i--) {
|
||||
if (!mapContext.value.selection.some((item) => item.type == "searchResult" && item.layerId == layerIds.value[i]))
|
||||
close(activeResults.value[i]);
|
||||
close(tabs.value[i]);
|
||||
}
|
||||
});
|
||||
|
||||
let idCounter = 1;
|
||||
|
||||
const clickMarkerTabContext = ref<WritableClickMarkerTabContext>({
|
||||
async openClickMarker(point) {
|
||||
const now = Date.now();
|
||||
lastClick = now;
|
||||
const result = reactive(getFallbackLonLatResult({ lat: point.lat, lon: point.lon, zoom: mapContext.value.zoom }));
|
||||
|
||||
const results = await client.value.find({
|
||||
query: `geo:${round(point.lat, 5)},${round(point.lon, 5)}?z=${mapContext.value.zoom}`,
|
||||
loadUrls: false,
|
||||
elevation: true
|
||||
const layer = markRaw(new SearchResultsLayer([result]).addTo(mapContext.value.components.map));
|
||||
mapContext.value.components.selectionHandler.addSearchResultLayer(layer);
|
||||
|
||||
const tab = reactive<Tab>({
|
||||
id: idCounter++,
|
||||
result,
|
||||
layer,
|
||||
isLoading: true
|
||||
});
|
||||
|
||||
if (now !== lastClick) {
|
||||
// There has been another click since the one we are reacting to.
|
||||
return;
|
||||
}
|
||||
tabs.value.push(tab);
|
||||
|
||||
if (results.length > 0) {
|
||||
const layer = new SearchResultsLayer([results[0]]).addTo(mapContext.value.components.map);
|
||||
mapContext.value.components.selectionHandler.addSearchResultLayer(layer);
|
||||
mapContext.value.components.selectionHandler.setSelectedItems([{ type: "searchResult", result, layerId: Util.stamp(layer) }]);
|
||||
|
||||
activeResults.value.push(results[0]);
|
||||
layers.push(markRaw(layer));
|
||||
await nextTick();
|
||||
searchBoxContext.value.activateTab(`fm${context.id}-click-marker-tab-${tabs.value.length - 1}`, { expand: true });
|
||||
|
||||
mapContext.value.components.selectionHandler.setSelectedItems([{ type: "searchResult", result: results[0], layerId: Util.stamp(layer) }]);
|
||||
(async () => {
|
||||
const results = await mapContext.value.runOperation(async () => await find(`geo:${round(point.lat, 5)},${round(point.lon, 5)}?z=${mapContext.value.zoom}`));
|
||||
|
||||
await nextTick();
|
||||
searchBoxContext.value.activateTab(`fm${context.id}-click-marker-tab-${activeResults.value.length - 1}`, { expand: true });
|
||||
if (results.length > 0) {
|
||||
tab.result = { ...results[0], elevation: tab.result.elevation };
|
||||
}
|
||||
|
||||
tab.isLoading = false;
|
||||
})().catch((err) => {
|
||||
toasts.showErrorToast(`find-error-${tab.id}`, "Error looking up point", err);
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const elevation = await getElevationForPoint(point);
|
||||
if (elevation != null) {
|
||||
tab.result.elevation = elevation;
|
||||
}
|
||||
})().catch((err) => {
|
||||
console.warn("Error fetching click marker elevation", err);
|
||||
});
|
||||
},
|
||||
|
||||
closeLastClickMarker() {
|
||||
if (tabs.value.length > 0) {
|
||||
close(tabs.value[tabs.value.length - 1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -73,27 +100,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
function close(result: SearchResult): void {
|
||||
const idx = activeResults.value.indexOf(result);
|
||||
function close(tab: Tab): void {
|
||||
const idx = tabs.value.indexOf(tab);
|
||||
if (idx == -1)
|
||||
return;
|
||||
|
||||
mapContext.value.components.selectionHandler.removeSearchResultLayer(layers[idx]);
|
||||
layers[idx].remove();
|
||||
activeResults.value.splice(idx, 1);
|
||||
layers.splice(idx, 1);
|
||||
toasts.hideToast(`find-error-${tab.id}`);
|
||||
mapContext.value.components.selectionHandler.removeSearchResultLayer(tab.layer);
|
||||
tab.layer.remove();
|
||||
tabs.value.splice(idx, 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="(result, idx) in activeResults" :key="result.id">
|
||||
<template v-for="(tab, idx) in tabs" :key="tab.id">
|
||||
<SearchBoxTab
|
||||
:id="`fm${context.id}-click-marker-tab-${idx}`"
|
||||
:title="result.short_name"
|
||||
:title="tab.result.short_name"
|
||||
isCloseable
|
||||
@close="close(result)"
|
||||
@close="close(tab)"
|
||||
>
|
||||
<SearchResultInfo :result="result"></SearchResultInfo>
|
||||
<SearchResultInfo :result="tab.result" :isLoading="tab.isLoading"></SearchResultInfo>
|
||||
</SearchBoxTab>
|
||||
</template>
|
||||
</template>
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { filterHasError } from "facilmap-utils";
|
||||
import { filterHasError, getOrderedTypes } from "facilmap-utils";
|
||||
import ModalDialog from "./ui/modal-dialog.vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { injectContextRequired, requireClientContext, requireMapContext } from "./facil-map-context-provider/facil-map-context-provider.vue";
|
||||
|
@ -16,7 +16,7 @@
|
|||
const modalRef = ref<InstanceType<typeof ModalDialog>>();
|
||||
const filter = ref(mapContext.value.filter ?? "");
|
||||
|
||||
const types = computed(() => Object.values(client.value.types));
|
||||
const types = computed(() => getOrderedTypes(client.value.types));
|
||||
|
||||
function validateFilter(filter: string) {
|
||||
return filterHasError(filter)?.message;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { lineValidator, type ID } from "facilmap-types";
|
||||
import { canControl, mergeObject } from "facilmap-utils";
|
||||
import { canControl, getOrderedTypes, mergeObject } from "facilmap-utils";
|
||||
import { getUniqueId, getZodValidator, validateRequired } from "../utils/utils";
|
||||
import { cloneDeep, isEqual, omit } from "lodash-es";
|
||||
import ModalDialog from "./ui/modal-dialog.vue";
|
||||
|
@ -37,7 +37,7 @@
|
|||
|
||||
const isModified = computed(() => !isEqual(line.value, originalLine.value));
|
||||
|
||||
const types = computed(() => Object.values(client.value.types).filter((type) => type.type === "line"));
|
||||
const types = computed(() => getOrderedTypes(client.value.types).filter((type) => type.type === "line"));
|
||||
|
||||
const resolvedCanControl = computed(() => canControl(client.value.types[line.value.typeId]));
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { markerValidator, type ID } from "facilmap-types";
|
||||
import { canControl, mergeObject } from "facilmap-utils";
|
||||
import { canControl, getOrderedTypes, mergeObject } from "facilmap-utils";
|
||||
import { getUniqueId, getZodValidator, validateRequired } from "../utils/utils";
|
||||
import { cloneDeep, isEqual } from "lodash-es";
|
||||
import ModalDialog from "./ui/modal-dialog.vue";
|
||||
|
@ -36,7 +36,7 @@
|
|||
|
||||
const isModified = computed(() => !isEqual(marker.value, client.value.markers[props.markerId]));
|
||||
|
||||
const types = computed(() => Object.values(client.value.types).filter((type) => type.type === "marker"));
|
||||
const types = computed(() => getOrderedTypes(client.value.types).filter((type) => type.type === "marker"));
|
||||
|
||||
const resolvedCanControl = computed(() => canControl(client.value.types[marker.value.typeId]));
|
||||
|
||||
|
|
|
@ -416,7 +416,7 @@
|
|||
v-model="type.fields"
|
||||
tag="tbody"
|
||||
handle=".fm-drag-handle"
|
||||
itemKey="(field: any) => type.fields.indexOf(field)"
|
||||
:itemKey="(field: any) => type.fields.indexOf(field)"
|
||||
>
|
||||
<template #item="{ element: field }">
|
||||
<tr>
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
import { useToasts } from "./ui/toasts/toasts.vue";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import type { CustomSubmitEvent } from "./ui/validated-form/validated-form.vue";
|
||||
import { getOrderedTypes } from "facilmap-utils";
|
||||
|
||||
const toasts = useToasts();
|
||||
|
||||
|
@ -24,6 +25,8 @@
|
|||
|
||||
const id = getUniqueId("fm-export-map");
|
||||
|
||||
const orderedTypes = computed(() => getOrderedTypes(client.value.types));
|
||||
|
||||
const modalRef = ref<InstanceType<typeof ModalDialog>>();
|
||||
|
||||
const copyRef = ref<InstanceType<typeof CopyToClipboardInput>>();
|
||||
|
@ -41,7 +44,7 @@
|
|||
"Distance",
|
||||
"Line time",
|
||||
// TODO: Include only types not currently filtered
|
||||
...Object.values(client.value.types).flatMap((type) => type.fields.map((field) => field.name))
|
||||
...orderedTypes.value.flatMap((type) => type.fields.map((field) => field.name))
|
||||
]));
|
||||
|
||||
const routeTypeOptions = {
|
||||
|
@ -287,7 +290,7 @@
|
|||
>
|
||||
<template #default="slotProps">
|
||||
<select class="form-select" v-model="typeId" :id="`${id}-type-select`" :ref="slotProps.inputRef">
|
||||
<option v-for="(type, typeId) of client.types" :key="typeId" :value="typeId">{{type.name}}</option>
|
||||
<option v-for="type of orderedTypes" :key="type.id" :value="type.id">{{type.name}}</option>
|
||||
</select>
|
||||
<div class="invalid-tooltip">
|
||||
{{slotProps.validationError}}
|
||||
|
|
|
@ -2,6 +2,7 @@ import type { Point } from "facilmap-types";
|
|||
|
||||
export interface WritableClickMarkerTabContext {
|
||||
openClickMarker(point: Point): Promise<void>;
|
||||
closeLastClickMarker(): void;
|
||||
}
|
||||
|
||||
export type ClickMarkerTabContext = Readonly<WritableClickMarkerTabContext>;
|
|
@ -47,6 +47,8 @@ export type MapContextData = {
|
|||
components: MapComponents;
|
||||
loaded: boolean;
|
||||
fatalError: string | undefined;
|
||||
/** Increase mapContext.loading while the given async function is running. */
|
||||
runOperation: <R>(operation: () => Promise<R>) => Promise<R>;
|
||||
};
|
||||
|
||||
export type WritableMapContext = MapContextData & Emitter<MapContextEvents>;
|
||||
|
|
|
@ -245,6 +245,10 @@ function useSelectionHandler(map: Ref<Map>, context: FacilMapContext, mapContext
|
|||
void context.components.clickMarkerTab?.openClickMarker({ lat: event.latlng.lat, lon: event.latlng.lng });
|
||||
});
|
||||
|
||||
selectionHandler.on("fmLongClickAbort", () => {
|
||||
context.components.clickMarkerTab?.closeLastClickMarker();
|
||||
});
|
||||
|
||||
return selectionHandler;
|
||||
},
|
||||
(selectionHandler, onCleanup) => {
|
||||
|
@ -343,7 +347,15 @@ export async function useMapContext(context: FacilMapContext, mapRef: Ref<HTMLEl
|
|||
overpassCustom: "",
|
||||
overpassMessage: undefined,
|
||||
loaded: false,
|
||||
fatalError: undefined
|
||||
fatalError: undefined,
|
||||
runOperation: async (operation) => {
|
||||
try {
|
||||
mapContextWithoutComponents.loading++;
|
||||
return await operation();
|
||||
} finally {
|
||||
mapContextWithoutComponents.loading--;
|
||||
}
|
||||
}
|
||||
} satisfies Omit<MapContextData, 'components'>));
|
||||
|
||||
const mapContext: WritableMapContext = Object.assign(mapContextWithoutComponents, {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { ID, Shape, Stroke, Symbol, Type } from "facilmap-types";
|
||||
import { symbolList } from "facilmap-leaflet";
|
||||
import { isBright } from "facilmap-utils";
|
||||
import { getOrderedTypes, isBright } from "facilmap-utils";
|
||||
import type { FacilMapContext } from "../facil-map-context-provider/facil-map-context";
|
||||
import { requireClientContext, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
|
||||
|
||||
|
@ -34,9 +34,7 @@ export function getLegendItems(context: FacilMapContext): LegendType[] {
|
|||
const mapContext = requireMapContext(context).value;
|
||||
|
||||
const legendItems: LegendType[] = [ ];
|
||||
for (const i in client.types) {
|
||||
const type = client.types[i];
|
||||
|
||||
for (const type of getOrderedTypes(client.types)) {
|
||||
if(!type.showInLegend)
|
||||
continue;
|
||||
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { ID, Type } from "facilmap-types";
|
||||
import EditTypeDialog from "./edit-type-dialog/edit-type-dialog.vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, watchEffect } from "vue";
|
||||
import { useToasts } from "./ui/toasts/toasts.vue";
|
||||
import { showConfirm } from "./ui/alert.vue";
|
||||
import ModalDialog from "./ui/modal-dialog.vue";
|
||||
import { injectContextRequired, requireClientContext } from "./facil-map-context-provider/facil-map-context-provider.vue";
|
||||
import DropdownMenu from "./ui/dropdown-menu.vue";
|
||||
import { getOrderedTypes } from "facilmap-utils";
|
||||
import Draggable from "vuedraggable";
|
||||
import Icon from "./ui/icon.vue";
|
||||
|
||||
const context = injectContextRequired();
|
||||
const client = requireClientContext(context);
|
||||
|
@ -16,10 +19,11 @@
|
|||
hidden: [];
|
||||
}>();
|
||||
|
||||
const isMoving = ref<ID>();
|
||||
const isDeleting = ref<Record<ID, boolean>>({ });
|
||||
const editDialogTypeId = ref<ID | "createMarkerType" | "createLineType">();
|
||||
|
||||
const isBusy = computed(() => Object.values(isDeleting.value).some((v) => v));
|
||||
const isBusy = computed(() => Object.values(isDeleting.value).some((v) => v) || isMoving.value != null);
|
||||
|
||||
async function deleteType(type: Type): Promise<void> {
|
||||
toasts.hideToast(`fm${context.id}-manage-types-delete-${type.id}`);
|
||||
|
@ -42,6 +46,30 @@
|
|||
delete isDeleting.value[type.id];
|
||||
}
|
||||
}
|
||||
|
||||
const orderedTypes = ref<Type[]>([]);
|
||||
watchEffect(() => {
|
||||
if (isMoving.value == null) {
|
||||
orderedTypes.value = getOrderedTypes(client.value.types);
|
||||
}
|
||||
});
|
||||
|
||||
const handleDrag = toasts.toastErrors(async (e: any) => {
|
||||
if (e.moved) {
|
||||
isMoving.value = e.moved.element.id;
|
||||
|
||||
try {
|
||||
// This handler is called when orderedTypes is already reordered
|
||||
const newIdx = e.moved.newIndex === 0 ? 0 : (orderedTypes.value[e.moved.newIndex - 1].idx + 1);
|
||||
await client.value.editType({
|
||||
id: e.moved.element.id,
|
||||
idx: newIdx
|
||||
});
|
||||
} finally {
|
||||
isMoving.value = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -60,29 +88,45 @@
|
|||
<th>Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="type in client.types" :key="type.id">
|
||||
<td class="text-break">{{type.name}}</td>
|
||||
<td>{{type.type}}</td>
|
||||
<td class="td-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="isDeleting[type.id]"
|
||||
@click="editDialogTypeId = type.id"
|
||||
>Edit</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="deleteType(type)"
|
||||
class="btn btn-secondary"
|
||||
:disabled="isDeleting[type.id]"
|
||||
>
|
||||
<div v-if="isDeleting[type.id]" class="spinner-border spinner-border-sm"></div>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<Draggable
|
||||
v-model="orderedTypes"
|
||||
tag="tbody"
|
||||
handle=".fm-drag-handle"
|
||||
itemKey="id"
|
||||
@change="handleDrag"
|
||||
>
|
||||
<template #item="{ element: type }">
|
||||
<tr>
|
||||
<td class="text-break">{{type.name}}</td>
|
||||
<td>{{type.type}}</td>
|
||||
<td class="td-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="isDeleting[type.id]"
|
||||
@click="editDialogTypeId = type.id"
|
||||
>Edit</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="deleteType(type)"
|
||||
class="btn btn-secondary"
|
||||
:disabled="isDeleting[type.id] || isMoving != null"
|
||||
>
|
||||
<div v-if="isDeleting[type.id]" class="spinner-border spinner-border-sm"></div>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary fm-drag-handle"
|
||||
:disabled="isDeleting[type.id] || isMoving != null"
|
||||
>
|
||||
<div v-if="isMoving === type.id" class="spinner-border spinner-border-sm"></div>
|
||||
<Icon v-else icon="resize-vertical" alt="Reorder"></Icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</Draggable>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import type { ID, View } from "facilmap-types";
|
||||
import { displayView } from "facilmap-leaflet";
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, watchEffect } from "vue";
|
||||
import { useToasts } from "./ui/toasts/toasts.vue";
|
||||
import { showConfirm } from "./ui/alert.vue";
|
||||
import ModalDialog from "./ui/modal-dialog.vue";
|
||||
import { injectContextRequired, requireClientContext, requireMapContext } from "./facil-map-context-provider/facil-map-context-provider.vue";
|
||||
import { getOrderedViews } from "facilmap-utils";
|
||||
import Draggable from "vuedraggable";
|
||||
import Icon from "./ui/icon.vue";
|
||||
|
||||
const context = injectContextRequired();
|
||||
const client = requireClientContext(context);
|
||||
|
@ -17,10 +20,11 @@
|
|||
}>();
|
||||
|
||||
const isSavingDefaultView = ref<ID>();
|
||||
const isMoving = ref<ID>();
|
||||
const isDeleting = ref(new Set<ID>());
|
||||
|
||||
const isBusy = computed(() => {
|
||||
return isSavingDefaultView.value != null || isDeleting.value.size > 0;
|
||||
return isSavingDefaultView.value != null || isDeleting.value.size > 0 || isMoving.value != null;
|
||||
});
|
||||
|
||||
function display(view: View): void {
|
||||
|
@ -61,6 +65,30 @@
|
|||
isDeleting.value.delete(view.id);
|
||||
}
|
||||
};
|
||||
|
||||
const orderedViews = ref<View[]>([]);
|
||||
watchEffect(() => {
|
||||
if (isMoving.value == null) {
|
||||
orderedViews.value = getOrderedViews(client.value.views);
|
||||
}
|
||||
});
|
||||
|
||||
const handleDrag = toasts.toastErrors(async (e: any) => {
|
||||
if (e.moved) {
|
||||
isMoving.value = e.moved.element.id;
|
||||
|
||||
try {
|
||||
// This handler is called when orderedViews is already reordered
|
||||
const newIdx = e.moved.newIndex === 0 ? 0 : (orderedViews.value[e.moved.newIndex - 1].idx + 1);
|
||||
await client.value.editView({
|
||||
id: e.moved.element.id,
|
||||
idx: newIdx
|
||||
});
|
||||
} finally {
|
||||
isMoving.value = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -72,39 +100,55 @@
|
|||
@hidden="emit('hidden')"
|
||||
>
|
||||
<table class="table table-striped table-hover">
|
||||
<tbody>
|
||||
<tr v-for="view in client.views" :key="view.id">
|
||||
<td
|
||||
class="text-break"
|
||||
:class="{
|
||||
'font-weight-bold': client.padData?.defaultView && view.id == client.padData.defaultView.id
|
||||
}"
|
||||
>
|
||||
<a href="javascript:" @click="display(view)">{{view.name}}</a>
|
||||
</td>
|
||||
<td class="td-buttons text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
v-show="!client.padData?.defaultView || view.id !== client.padData.defaultView.id"
|
||||
@click="makeDefault(view)"
|
||||
:disabled="!!isSavingDefaultView || isDeleting.has(view.id)"
|
||||
<Draggable
|
||||
v-model="orderedViews"
|
||||
tag="tbody"
|
||||
handle=".fm-drag-handle"
|
||||
itemKey="id"
|
||||
@change="handleDrag"
|
||||
>
|
||||
<template #item="{ element: view }">
|
||||
<tr>
|
||||
<td
|
||||
class="text-break"
|
||||
:class="{
|
||||
'font-weight-bold': client.padData?.defaultView && view.id == client.padData.defaultView.id
|
||||
}"
|
||||
>
|
||||
<div v-if="isSavingDefaultView == view.id" class="spinner-border spinner-border-sm"></div>
|
||||
Make default
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="deleteView(view)"
|
||||
:disabled="isDeleting.has(view.id) || isSavingDefaultView == view.id"
|
||||
>
|
||||
<div v-if="isDeleting.has(view.id)" class="spinner-border spinner-border-sm"></div>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<a href="javascript:" @click="display(view)">{{view.name}}</a>
|
||||
</td>
|
||||
<td class="td-buttons text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
v-show="!client.padData?.defaultView || view.id !== client.padData.defaultView.id"
|
||||
@click="makeDefault(view)"
|
||||
:disabled="!!isSavingDefaultView || isDeleting.has(view.id)"
|
||||
>
|
||||
<div v-if="isSavingDefaultView == view.id" class="spinner-border spinner-border-sm"></div>
|
||||
Make default
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="deleteView(view)"
|
||||
:disabled="isDeleting.has(view.id) || isSavingDefaultView == view.id || isMoving != null"
|
||||
>
|
||||
<div v-if="isDeleting.has(view.id)" class="spinner-border spinner-border-sm"></div>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary fm-drag-handle"
|
||||
:disabled="isDeleting.has(view.id) || isMoving != null"
|
||||
>
|
||||
<div v-if="isMoving === view.id" class="spinner-border spinner-border-sm"></div>
|
||||
<Icon v-else icon="resize-vertical" alt="Reorder"></Icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</Draggable>
|
||||
</table>
|
||||
</ModalDialog>
|
||||
</template>
|
|
@ -89,12 +89,7 @@
|
|||
</h2>
|
||||
<dl class="fm-search-box-collapse-point fm-search-box-dl">
|
||||
<dt class="pos">Coordinates</dt>
|
||||
<dd class="pos"><Coordinates :point="marker"></Coordinates></dd>
|
||||
|
||||
<template v-if="marker.ele != null">
|
||||
<dt class="elevation">Elevation</dt>
|
||||
<dd class="elevation">{{marker.ele}} m</dd>
|
||||
</template>
|
||||
<dd class="pos"><Coordinates :point="marker" :ele="marker.ele"></Coordinates></dd>
|
||||
|
||||
<template v-for="field in client.types[marker.typeId].fields" :key="field.name">
|
||||
<dt>{{field.name}}</dt>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
import { useToasts } from "./ui/toasts/toasts.vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { getUniqueId, getZodValidator, validateRequired } from "../utils/utils";
|
||||
import { round } from "facilmap-utils";
|
||||
import { formatCoordinates } from "facilmap-utils";
|
||||
import { injectContextRequired, requireClientContext, requireMapContext } from "./facil-map-context-provider/facil-map-context-provider.vue";
|
||||
import ValidatedField from "./ui/validated-form/validated-field.vue";
|
||||
import { viewValidator } from "facilmap-types";
|
||||
|
@ -100,7 +100,7 @@
|
|||
class="form-control-plaintext"
|
||||
readonly
|
||||
:id="`${id}-topleft-input`"
|
||||
:value="`${round(mapContext.bounds.getNorth(), 5)}, ${round(mapContext.bounds.getWest(), 5)}`"
|
||||
:value="formatCoordinates({ lat: mapContext.bounds.getNorth(), lon: mapContext.bounds.getWest() })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -112,7 +112,7 @@
|
|||
class="form-control-plaintext"
|
||||
readonly
|
||||
:id="`${id}-bottomright-input`"
|
||||
:value="`${round(mapContext.bounds.getSouth(), 5)}, ${round(mapContext.bounds.getEast(), 5)}`"
|
||||
:value="formatCoordinates({ lat: mapContext.bounds.getSouth(), lon: mapContext.bounds.getEast() })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import Icon from "../ui/icon.vue";
|
||||
import { isSearchId } from "facilmap-utils";
|
||||
import { find, getElevationForPoint, isSearchId, parseUrlQuery } from "facilmap-utils";
|
||||
import { useToasts } from "../ui/toasts/toasts.vue";
|
||||
import type { FindOnMapResult, SearchResult } from "facilmap-types";
|
||||
import SearchResults from "../search-results/search-results.vue";
|
||||
|
@ -11,7 +11,7 @@
|
|||
import type { HashQuery } from "facilmap-leaflet";
|
||||
import { type FileResultObject, parseFiles } from "../../utils/files";
|
||||
import FileResults from "../file-results.vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import DropdownMenu from "../ui/dropdown-menu.vue";
|
||||
import { injectContextRequired, requireClientContext, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
|
||||
|
||||
|
@ -87,8 +87,10 @@
|
|||
const query = searchString.value;
|
||||
loadingSearchString.value = searchString.value;
|
||||
|
||||
const url = parseUrlQuery(query);
|
||||
|
||||
const [newSearchResults, newMapResults] = await Promise.all([
|
||||
client.value.find({ query, loadUrls: true, elevation: true }),
|
||||
url ? client.value.find({ query, loadUrls: true }) : mapContext.value.runOperation(async () => await find(query)),
|
||||
client.value.padData ? client.value.findOnMap({ query }) : undefined
|
||||
]);
|
||||
|
||||
|
@ -106,13 +108,28 @@
|
|||
if(typeof newSearchResults == "string") {
|
||||
searchResults.value = undefined;
|
||||
mapResults.value = undefined;
|
||||
fileResult.value = await parseFiles([ newSearchResults ]);
|
||||
fileResult.value = await mapContext.value.runOperation(async () => await parseFiles([ newSearchResults ]));
|
||||
mapContext.value.components.searchResultsLayer.setResults(fileResult.value.features);
|
||||
} else {
|
||||
searchResults.value = newSearchResults;
|
||||
const reactiveResults = reactive(newSearchResults);
|
||||
searchResults.value = reactiveResults;
|
||||
mapContext.value.components.searchResultsLayer.setResults(newSearchResults);
|
||||
mapResults.value = newMapResults ?? undefined;
|
||||
fileResult.value = undefined;
|
||||
|
||||
const points = newSearchResults.filter((res) => (res.lon && res.lat));
|
||||
if(points.length > 0) {
|
||||
(async () => {
|
||||
const elevations = await Promise.all(points.map(async (point) => {
|
||||
return await getElevationForPoint({ lat: Number(point.lat), lon: Number(point.lon) });
|
||||
}));
|
||||
elevations.forEach((elevation, i) => {
|
||||
reactiveResults[i].elevation = elevation;
|
||||
});
|
||||
})().catch((err) => {
|
||||
console.warn("Error fetching search result elevations", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch(err) {
|
||||
toasts.showErrorToast(`fm${context.id}-search-form-error`, "Search error", err);
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
searchResults?: SearchResult[];
|
||||
/** If specified, will be passed to the route form as suggestions when using the "Use as" menu */
|
||||
mapResults?: FindOnMapResult[];
|
||||
isLoading?: boolean;
|
||||
}>(), {
|
||||
showBackButton: false
|
||||
});
|
||||
|
@ -63,6 +64,11 @@
|
|||
{{result.short_name}}
|
||||
</h2>
|
||||
<dl class="fm-search-box-collapse-point fm-search-box-dl">
|
||||
<template v-if="result.lat != null && result.lon != null">
|
||||
<dt class="pos">Coordinates</dt>
|
||||
<dd class="pos"><Coordinates :point="result as Point" :ele="result.elevation"></Coordinates></dd>
|
||||
</template>
|
||||
|
||||
<template v-if="result.type">
|
||||
<dt>Type</dt>
|
||||
<dd class="text-break">{{result.type}}</dd>
|
||||
|
@ -73,22 +79,18 @@
|
|||
<dd class="text-break">{{result.address}}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="result.type != 'coordinates' && result.lat != null && result.lon != null">
|
||||
<dt>Coordinates</dt>
|
||||
<dd><Coordinates :point="result as Point"></Coordinates></dd>
|
||||
</template>
|
||||
|
||||
<template v-if="result.elevation != null">
|
||||
<dt>Elevation</dt>
|
||||
<dd>{{result.elevation}} m</dd>
|
||||
</template>
|
||||
|
||||
<template v-for="(value, key) in result.extratags" :key="key">
|
||||
<dt>{{key}}</dt>
|
||||
<dd class="text-break" v-html="renderOsmTag(key, value)"></dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<template v-if="props.isLoading">
|
||||
<div class="d-flex justify-content-center mb-3">
|
||||
<div class="spinner-border"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="btn-toolbar">
|
||||
<ZoomToObjectButton
|
||||
v-if="zoomDestination"
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import { useToasts } from "../ui/toasts/toasts.vue";
|
||||
import { injectContextRequired, requireClientContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
|
||||
import { type LineWithTags, type MarkerWithTags, addToMap, searchResultToLineWithTags, searchResultToMarkerWithTags } from "../../utils/add";
|
||||
import { getOrderedTypes } from "facilmap-utils";
|
||||
|
||||
const context = injectContextRequired();
|
||||
const client = requireClientContext(context);
|
||||
|
@ -34,11 +35,13 @@
|
|||
|
||||
type Option = { key: string; value: string | false; text: string; disabled?: boolean };
|
||||
|
||||
const orderedTypes = computed(() => getOrderedTypes(client.value.types));
|
||||
|
||||
const customMappingOptions = computed(() => {
|
||||
return mapValues(pickBy(props.customTypes, (customType, customTypeId) => activeFileResultsByType.value[customTypeId as any].length > 0), (customType, customTypeId): Option[] => {
|
||||
const recommendedOptions: Option[] = [];
|
||||
|
||||
for (const type of Object.values(client.value.types)) {
|
||||
for (const type of orderedTypes.value) {
|
||||
if (type.name == customType.name && type.type == customType.type)
|
||||
recommendedOptions.push({ key: `e${type.id}`, value: `e${type.id}`, text: `Existing type “${type.name}”` });
|
||||
}
|
||||
|
@ -51,7 +54,7 @@
|
|||
|
||||
const otherOptions: Option[] = [];
|
||||
|
||||
for (const type of Object.values(client.value.types)) {
|
||||
for (const type of orderedTypes.value) {
|
||||
if (type.name != customType.name && type.type == customType.type)
|
||||
otherOptions.push({ key: `e${type.id}`, value: `e${type.id}`, text: `Existing type “${type.name}”` });
|
||||
}
|
||||
|
@ -94,7 +97,7 @@
|
|||
options.push({ key: `i${customTypeId}`, value: `i${customTypeId}`, text: `Import type “${customType.name}”` });
|
||||
}
|
||||
|
||||
for (const type of Object.values(client.value.types)) {
|
||||
for (const type of orderedTypes.value) {
|
||||
if (type.type == "marker")
|
||||
options.push({ key: `e${type.id}`, value: `e${type.id}`, text: `Existing type “${type.name}”` });
|
||||
}
|
||||
|
@ -112,7 +115,7 @@
|
|||
options.push({ key: `i${customTypeId}`, value: `i${customTypeId}`, text: `Import type “${customType.name}”` });
|
||||
}
|
||||
|
||||
for (const type of Object.values(client.value.types)) {
|
||||
for (const type of orderedTypes.value) {
|
||||
if (type.type == "line")
|
||||
options.push({ key: `e${type.id}`, value: `e${type.id}`, text: `Existing type “${type.name}”` });
|
||||
}
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import type { Type } from "facilmap-types";
|
||||
import { drawLine, drawMarker } from "../../utils/draw";
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import ManageTypesDialog from "../manage-types-dialog.vue";
|
||||
import vLinkDisabled from "../../utils/link-disabled";
|
||||
import { useToasts } from "../ui/toasts/toasts.vue";
|
||||
import DropdownMenu from "../ui/dropdown-menu.vue";
|
||||
import { injectContextRequired, requireClientContext, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
|
||||
import { sleep } from "facilmap-utils";
|
||||
import { getOrderedTypes, sleep } from "facilmap-utils";
|
||||
|
||||
const emit = defineEmits<{
|
||||
"hide-sidebar": [];
|
||||
|
@ -30,6 +30,8 @@
|
|||
await drawLine(type, context, toasts);
|
||||
}
|
||||
}
|
||||
|
||||
const orderedTypes = computed(() => getOrderedTypes(client.value.types));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -42,7 +44,7 @@
|
|||
menuClass="dropdown-menu-end"
|
||||
label="Add"
|
||||
>
|
||||
<li v-for="type in client.types" :key="type.id">
|
||||
<li v-for="type in orderedTypes" :key="type.id">
|
||||
<a
|
||||
class="dropdown-item"
|
||||
v-link-disabled="mapContext.interaction"
|
||||
|
|
|
@ -3,9 +3,10 @@
|
|||
import type { View } from "facilmap-types";
|
||||
import SaveViewDialog from "../save-view-dialog.vue";
|
||||
import ManageViewsDialog from "../manage-views-dialog.vue";
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import DropdownMenu from "../ui/dropdown-menu.vue";
|
||||
import { injectContextRequired, requireClientContext, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
|
||||
import { getOrderedViews } from "facilmap-utils";
|
||||
|
||||
const context = injectContextRequired();
|
||||
const client = requireClientContext(context);
|
||||
|
@ -20,6 +21,8 @@
|
|||
| "manage-views"
|
||||
>();
|
||||
|
||||
const orderedViews = computed(() => getOrderedViews(client.value.views));
|
||||
|
||||
function doDisplayView(view: View): void {
|
||||
displayView(mapContext.value.components.map, view, { overpassLayer: mapContext.value.components.overpassLayer });
|
||||
}
|
||||
|
@ -34,7 +37,7 @@
|
|||
menuClass="dropdown-menu-end"
|
||||
label="Views"
|
||||
>
|
||||
<li v-for="view in client.views" :key="view.id">
|
||||
<li v-for="view in orderedViews" :key="view.id">
|
||||
<a
|
||||
class="dropdown-item"
|
||||
href="javascript:"
|
||||
|
@ -43,7 +46,7 @@
|
|||
>{{view.name}}</a>
|
||||
</li>
|
||||
|
||||
<li v-if="client.writable == 2 && Object.keys(client.views).length > 0">
|
||||
<li v-if="client.writable == 2 && orderedViews.length > 0">
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
|
||||
|
@ -56,7 +59,7 @@
|
|||
>Save current view</a>
|
||||
</li>
|
||||
|
||||
<li v-if="client.writable == 2 && Object.keys(client.views).length > 0">
|
||||
<li v-if="client.writable == 2 && orderedViews.length > 0">
|
||||
<a
|
||||
class="dropdown-item"
|
||||
href="javascript:"
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import { type LineWithTags, type MarkerWithTags, addToMap } from '../../utils/add';
|
||||
import type { ButtonSize } from '../../utils/bootstrap';
|
||||
import DropdownMenu from "./dropdown-menu.vue";
|
||||
import { getOrderedTypes } from 'facilmap-utils';
|
||||
|
||||
const context = injectContextRequired();
|
||||
const client = requireClientContext(context);
|
||||
|
@ -35,11 +36,11 @@
|
|||
});
|
||||
|
||||
const markerTypes = computed(() => {
|
||||
return Object.values(client.value.types).filter((type) => type.type == "marker");
|
||||
return getOrderedTypes(client.value.types).filter((type) => type.type == "marker");
|
||||
});
|
||||
|
||||
const lineTypes = computed(() => {
|
||||
return Object.values(client.value.types).filter((type) => type.type == "line");
|
||||
return getOrderedTypes(client.value.types).filter((type) => type.type == "line");
|
||||
});
|
||||
|
||||
async function add(callback: () => Promise<SelectedItem[]>): Promise<void> {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import type { Point } from "facilmap-types";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { round } from "facilmap-utils";
|
||||
import { formatCoordinates } from "facilmap-utils";
|
||||
import Icon from "./icon.vue";
|
||||
import { computed } from "vue";
|
||||
import { useToasts } from "./toasts/toasts.vue";
|
||||
|
@ -11,9 +11,10 @@
|
|||
|
||||
const props = defineProps<{
|
||||
point: Point;
|
||||
ele?: number | null;
|
||||
}>();
|
||||
|
||||
const formattedCoordinates = computed(() => `${round(props.point.lat, 5)}, ${round(props.point.lon, 5)}`);
|
||||
const formattedCoordinates = computed(() => formatCoordinates(props.point));
|
||||
|
||||
function copy(): void {
|
||||
copyToClipboard(formattedCoordinates.value);
|
||||
|
@ -32,6 +33,9 @@
|
|||
>
|
||||
<Icon icon="copy" alt="Copy to clipboard"></Icon>
|
||||
</button>
|
||||
<span v-if="props.ele != null" v-tooltip="'Elevation'">
|
||||
({{props.ele}} m)
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
@ -40,8 +44,11 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
button, button + * {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0 0.25rem;
|
||||
line-height: 1;
|
||||
font-size: 0.85em;
|
||||
|
|
|
@ -10,14 +10,16 @@ export function drawMarker(type: Type, context: FacilMapContext, toasts: ToastCo
|
|||
const clickListener = addClickListener(mapContext.value.components.map, async (point) => {
|
||||
toasts.hideToast("fm-draw-add-marker");
|
||||
|
||||
try {
|
||||
const selection = await addToMap(context, [
|
||||
{ marker: { lat: point.lat, lon: point.lon }, type }
|
||||
]);
|
||||
if (point) {
|
||||
try {
|
||||
const selection = await addToMap(context, [
|
||||
{ marker: { lat: point.lat, lon: point.lon }, type }
|
||||
]);
|
||||
|
||||
mapContext.value.components.selectionHandler.setSelectedItems(selection, true);
|
||||
} catch (err) {
|
||||
toasts.showErrorToast("fm-draw-add-marker", "Error adding marker", err);
|
||||
mapContext.value.components.selectionHandler.setSelectedItems(selection, true);
|
||||
} catch (err) {
|
||||
toasts.showErrorToast("fm-draw-add-marker", "Error adding marker", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -27,7 +29,6 @@ export function drawMarker(type: Type, context: FacilMapContext, toasts: ToastCo
|
|||
{
|
||||
label: "Cancel",
|
||||
onClick: () => {
|
||||
toasts.hideToast("fm-draw-add-marker");
|
||||
clickListener.cancel();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -93,6 +93,8 @@ export default class SelectionHandler extends Handler {
|
|||
this._map.on("click", this.handleClickMap);
|
||||
this._map.on("fmInteractionStart", this.handleMapInteractionStart);
|
||||
this._map.on("fmInteractionEnd", this.handleMapInteractionEnd);
|
||||
|
||||
this._map.getContainer().addEventListener("click", this.handleMapClickCapture, { capture: true });
|
||||
this._map.getContainer().addEventListener("mousedown", this.handleMapMouseDown);
|
||||
this._map.getContainer().addEventListener("touchstart", this.handleMapMouseDown);
|
||||
}
|
||||
|
@ -106,6 +108,7 @@ export default class SelectionHandler extends Handler {
|
|||
this._map.off("click", this.handleClickMap);
|
||||
this._map.off("fmInteractionStart", this.handleMapInteractionStart);
|
||||
this._map.off("fmInteractionEnd", this.handleMapInteractionEnd);
|
||||
this._map.getContainer().removeEventListener("click", this.handleMapClickCapture, { capture: true });
|
||||
this._map.getContainer().removeEventListener("mousedown", this.handleMapMouseDown);
|
||||
this._map.getContainer().removeEventListener("touchstart", this.handleMapMouseDown);
|
||||
}
|
||||
|
@ -221,21 +224,38 @@ export default class SelectionHandler extends Handler {
|
|||
this.setSelectedItems([]);
|
||||
}
|
||||
|
||||
handleMapClickCapture = (e: MouseEvent): void => {
|
||||
if (this._isLongClick) {
|
||||
// Prevent click on map object under mouse cursor
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
handleMapMouseDown = (e: MouseEvent | TouchEvent): void => {
|
||||
if ("button" in e && e.button != null && e.button != 0) // Only react to left click
|
||||
return;
|
||||
if ("touches" in e && e.touches && e.touches.length != 1)
|
||||
return;
|
||||
if (this._mapInteraction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pos: Point = this._map.mouseEventToContainerPoint(("touches" in e ? e.touches[0] : e) as any);
|
||||
const timeout = setTimeout(() => {
|
||||
let fired = false;
|
||||
let timeout = setTimeout(() => {
|
||||
this._isLongClick = true;
|
||||
this.fire("fmLongClick", { latlng: this._map.mouseEventToLatLng(("touches" in e ? e.touches[0] : e) as any) });
|
||||
fired = true;
|
||||
}, 500);
|
||||
|
||||
const handleMouseMove = (e: any) => {
|
||||
if(pos.distanceTo(this._map.mouseEventToContainerPoint(("touches" in e ? e.touches[0] : e) as any)) > (this._map.dragging as any)._draggable.options.clickTolerance)
|
||||
if(pos.distanceTo(this._map.mouseEventToContainerPoint(("touches" in e ? e.touches[0] : e) as any)) > (this._map.dragging as any)._draggable.options.clickTolerance) {
|
||||
clear();
|
||||
|
||||
if (fired) {
|
||||
this.fire("fmLongClickAbort");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: any) => {
|
||||
|
|
|
@ -1,4 +1,12 @@
|
|||
import type { InjectedConfig } from "facilmap-utils";
|
||||
import { setConfig, type InjectedConfig } from "facilmap-utils";
|
||||
|
||||
const config: InjectedConfig = JSON.parse(document.querySelector("meta[name=fmConfig]")!.getAttribute("content")!);
|
||||
|
||||
setConfig({
|
||||
nominatimUrl: config.nominatimUrl,
|
||||
openElevationApiUrl: config.openElevationApiUrl,
|
||||
openElevationThrottleMs: config.openElevationThrottleMs,
|
||||
openElevationMaxBatchSize: config.openElevationMaxBatchSize
|
||||
});
|
||||
|
||||
export default config;
|
||||
|
|
|
@ -129,7 +129,7 @@ test("Create line (using custom values)", async () => {
|
|||
typeId: lineType.id,
|
||||
name: "Test line",
|
||||
mode: "track",
|
||||
colour: "0000ff",
|
||||
colour: "00ff00",
|
||||
width: 10,
|
||||
stroke: "dotted",
|
||||
data: {
|
||||
|
@ -230,7 +230,7 @@ test("Edit line", async () => {
|
|||
typeId: secondType.id,
|
||||
name: "Test line",
|
||||
mode: "track",
|
||||
colour: "0000ff",
|
||||
colour: "00ff00",
|
||||
width: 10,
|
||||
stroke: "dotted" as const,
|
||||
data: {
|
||||
|
@ -593,6 +593,140 @@ test("Socket v1 line name", async () => {
|
|||
}
|
||||
});
|
||||
|
||||
// getLineTemplate
|
||||
// exportLine
|
||||
// findOnMap
|
||||
test("Export line", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, {}, async (createPadData, padData) => {
|
||||
const lineType = Object.values(client.types).find((t) => t.type === "line")!;
|
||||
|
||||
const line = await client.addLine({
|
||||
routePoints: [
|
||||
{ lat: 6, lon: 6 },
|
||||
{ lat: 14, lon: 14 }
|
||||
],
|
||||
typeId: lineType.id
|
||||
});
|
||||
|
||||
expect((await client.exportLine({ id: line.id, format: "gpx-trk" })).replace(/<time>[^<]*<\/time>/, "<time></time>")).toEqual(
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns="http://www.topografix.com/GPX/1/1" creator="FacilMap" version="1.1" xmlns:osmand="https://osmand.net" 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>
|
||||
<time></time>
|
||||
<name>Untitled line</name>
|
||||
<extensions>
|
||||
<osmand:desc></osmand:desc>
|
||||
</extensions>
|
||||
</metadata>
|
||||
<extensions>
|
||||
<osmand:color>#0000ff</osmand:color>
|
||||
<osmand:width>4</osmand:width>
|
||||
</extensions>
|
||||
<trk>
|
||||
<name>Untitled line</name>
|
||||
<desc></desc>
|
||||
<trkseg>
|
||||
<trkpt lat="6" lon="6" />
|
||||
<trkpt lat="14" lon="14" />
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>`);
|
||||
|
||||
expect((await client.exportLine({ id: line.id, format: "gpx-rte" })).replace(/<time>[^<]*<\/time>/, "<time></time>")).toEqual(
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns="http://www.topografix.com/GPX/1/1" creator="FacilMap" version="1.1" xmlns:osmand="https://osmand.net" 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>
|
||||
<time></time>
|
||||
<name>Untitled line</name>
|
||||
<extensions>
|
||||
<osmand:desc></osmand:desc>
|
||||
</extensions>
|
||||
</metadata>
|
||||
<extensions>
|
||||
<osmand:color>#0000ff</osmand:color>
|
||||
<osmand:width>4</osmand:width>
|
||||
</extensions>
|
||||
<rte>
|
||||
<name>Untitled line</name>
|
||||
<desc></desc>
|
||||
<rtept lat="6" lon="6" />
|
||||
<rtept lat="14" lon="14" />
|
||||
</rte>
|
||||
</gpx>`);
|
||||
});
|
||||
});
|
||||
|
||||
test("Export line (track)", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, {}, async (createPadData, padData) => {
|
||||
const lineType = Object.values(client.types).find((t) => t.type === "line")!;
|
||||
|
||||
const line = await client.addLine({
|
||||
routePoints: [
|
||||
{ lat: 6, lon: 6 },
|
||||
{ lat: 12, lon: 12 }
|
||||
],
|
||||
typeId: lineType.id,
|
||||
name: "Test line",
|
||||
mode: "track",
|
||||
colour: "00ff00",
|
||||
width: 10,
|
||||
stroke: "dotted",
|
||||
data: {
|
||||
test: "value"
|
||||
},
|
||||
trackPoints: [
|
||||
{ lat: 6, lon: 6 },
|
||||
{ lat: 14, lon: 14 },
|
||||
{ lat: 12, lon: 12 }
|
||||
]
|
||||
});
|
||||
|
||||
expect((await client.exportLine({ id: line.id, format: "gpx-trk" })).replace(/<time>[^<]*<\/time>/, "<time></time>")).toEqual(
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns="http://www.topografix.com/GPX/1/1" creator="FacilMap" version="1.1" xmlns:osmand="https://osmand.net" 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>
|
||||
<time></time>
|
||||
<name>Test line</name>
|
||||
<extensions>
|
||||
<osmand:desc></osmand:desc>
|
||||
</extensions>
|
||||
</metadata>
|
||||
<extensions>
|
||||
<osmand:color>#00ff00</osmand:color>
|
||||
<osmand:width>10</osmand:width>
|
||||
</extensions>
|
||||
<trk>
|
||||
<name>Test line</name>
|
||||
<desc></desc>
|
||||
<trkseg>
|
||||
<trkpt lat="6" lon="6" />
|
||||
<trkpt lat="14" lon="14" />
|
||||
<trkpt lat="12" lon="12" />
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>`);
|
||||
|
||||
expect((await client.exportLine({ id: line.id, format: "gpx-rte" })).replace(/<time>[^<]*<\/time>/, "<time></time>")).toEqual(
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns="http://www.topografix.com/GPX/1/1" creator="FacilMap" version="1.1" xmlns:osmand="https://osmand.net" 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>
|
||||
<time></time>
|
||||
<name>Test line</name>
|
||||
<extensions>
|
||||
<osmand:desc></osmand:desc>
|
||||
</extensions>
|
||||
</metadata>
|
||||
<extensions>
|
||||
<osmand:color>#00ff00</osmand:color>
|
||||
<osmand:width>10</osmand:width>
|
||||
</extensions>
|
||||
<rte>
|
||||
<name>Test line</name>
|
||||
<desc></desc>
|
||||
<rtept lat="6" lon="6" />
|
||||
<rtept lat="12" lon="12" />
|
||||
</rte>
|
||||
</gpx>`);
|
||||
});
|
||||
});
|
|
@ -30,7 +30,8 @@ test("Create marker (using default values)", async () => {
|
|||
const marker = await client1.addMarker({
|
||||
lat: 10,
|
||||
lon: 10,
|
||||
typeId: markerType.id
|
||||
typeId: markerType.id,
|
||||
ele: null
|
||||
});
|
||||
|
||||
const expectedMarker = {
|
||||
|
@ -45,7 +46,7 @@ test("Create marker (using default values)", async () => {
|
|||
symbol: "",
|
||||
shape: "",
|
||||
data: {},
|
||||
ele: expect.any(Number)
|
||||
ele: null
|
||||
} satisfies Marker;
|
||||
|
||||
expect(marker).toEqual(expectedMarker);
|
||||
|
@ -119,7 +120,8 @@ test("Edit marker", async () => {
|
|||
const createdMarker = await client1.addMarker({
|
||||
lat: 10,
|
||||
lon: 10,
|
||||
typeId: markerType.id
|
||||
typeId: markerType.id,
|
||||
ele: null
|
||||
});
|
||||
|
||||
const secondType = await client1.addType({
|
||||
|
@ -194,7 +196,8 @@ test("Delete marker", async () => {
|
|||
const createdMarker = await client1.addMarker({
|
||||
lat: 10,
|
||||
lon: 10,
|
||||
typeId: markerType.id
|
||||
typeId: markerType.id,
|
||||
ele: null
|
||||
});
|
||||
|
||||
const onDeleteMarker1 = vi.fn();
|
||||
|
@ -236,7 +239,8 @@ test("Get marker", async () => {
|
|||
const marker = await client1.addMarker({
|
||||
lat: 10,
|
||||
lon: 10,
|
||||
typeId: markerType.id
|
||||
typeId: markerType.id,
|
||||
ele: null
|
||||
});
|
||||
|
||||
const expectedMarker = {
|
||||
|
@ -251,7 +255,7 @@ test("Get marker", async () => {
|
|||
symbol: "",
|
||||
shape: "",
|
||||
data: {},
|
||||
ele: expect.any(Number)
|
||||
ele: null
|
||||
} satisfies Marker;
|
||||
|
||||
expect(await client2.getMarker({ id: marker.id })).toEqual(expectedMarker);
|
||||
|
@ -271,7 +275,8 @@ test("Find marker", async () => {
|
|||
lat: 10,
|
||||
lon: 10,
|
||||
typeId: markerType.id,
|
||||
symbol: "a"
|
||||
symbol: "a",
|
||||
ele: null
|
||||
});
|
||||
|
||||
const expectedResult: FindOnMapMarker = {
|
||||
|
@ -322,7 +327,8 @@ test("Try to update marker with line type", async () => {
|
|||
const marker = await client.addMarker({
|
||||
lat: 10,
|
||||
lon: 10,
|
||||
typeId: markerType.id
|
||||
typeId: markerType.id,
|
||||
ele: null
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
|
@ -375,7 +381,8 @@ test("Try to update marker with marker type from other pad", async () => {
|
|||
const marker = await client1.addMarker({
|
||||
lat: 10,
|
||||
lon: 10,
|
||||
typeId: markerType1.id
|
||||
typeId: markerType1.id,
|
||||
ele: null
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
|
@ -424,7 +431,8 @@ test("Socket v1 marker name", async () => {
|
|||
const marker = await emit(socket1, "addMarker", {
|
||||
lat: 10,
|
||||
lon: 10,
|
||||
typeId: markerType.id
|
||||
typeId: markerType.id,
|
||||
ele: null
|
||||
});
|
||||
|
||||
const expectedMarker = {
|
||||
|
@ -439,7 +447,7 @@ test("Socket v1 marker name", async () => {
|
|||
symbol: "",
|
||||
shape: "",
|
||||
data: {},
|
||||
ele: expect.any(Number)
|
||||
ele: null
|
||||
} satisfies Marker;
|
||||
|
||||
expect(marker).toEqual(expectedMarker);
|
||||
|
|
|
@ -0,0 +1,331 @@
|
|||
import { expect, test, vi } from "vitest";
|
||||
import { createTemporaryPad, openClient } from "../utils";
|
||||
|
||||
test("New marker is created with dropdown styles", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "marker",
|
||||
fields: [
|
||||
{
|
||||
name: "Dropdown",
|
||||
type: "dropdown",
|
||||
controlColour: true,
|
||||
controlSize: true,
|
||||
controlSymbol: true,
|
||||
controlShape: true,
|
||||
options: [
|
||||
{ value: "Value 1", colour: "00ffff", size: 60, symbol: "z", shape: "rectangle" },
|
||||
{ value: "Value 2", colour: "00ff00", size: 50, symbol: "a", shape: "circle" }
|
||||
],
|
||||
default: "Value 2"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const marker = await client.addMarker({
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
typeId: type.id,
|
||||
colour: "ffffff",
|
||||
size: 20,
|
||||
symbol: "b",
|
||||
shape: "drop"
|
||||
});
|
||||
|
||||
expect(marker).toMatchObject({
|
||||
colour: "00ff00",
|
||||
size: 50,
|
||||
symbol: "a",
|
||||
shape: "circle",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("New line is created with dropdown styles", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "line",
|
||||
fields: [
|
||||
{
|
||||
name: "Dropdown",
|
||||
type: "dropdown",
|
||||
controlColour: true,
|
||||
controlWidth: true,
|
||||
controlStroke: true,
|
||||
options: [
|
||||
{ value: "Value 1", colour: "00ffff", width: 11, stroke: "dashed" },
|
||||
{ value: "Value 2", colour: "00ff00", width: 10, stroke: "dotted" }
|
||||
],
|
||||
default: "Value 2"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const line = await client.addLine({
|
||||
routePoints: [
|
||||
{ lat: 0, lon: 0 },
|
||||
{ lat: 1, lon: 1 }
|
||||
],
|
||||
typeId: type.id,
|
||||
colour: "ffffff",
|
||||
width: 20,
|
||||
stroke: "dashed"
|
||||
});
|
||||
|
||||
expect(line).toMatchObject({
|
||||
colour: "00ff00",
|
||||
width: 10,
|
||||
stroke: "dotted"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Line template uses dropdown styles", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "line",
|
||||
fields: [
|
||||
{
|
||||
name: "Dropdown",
|
||||
type: "dropdown",
|
||||
controlColour: true,
|
||||
controlWidth: true,
|
||||
controlStroke: true,
|
||||
options: [
|
||||
{ value: "Value 1", colour: "00ffff", width: 11, stroke: "dashed" },
|
||||
{ value: "Value 2", colour: "00ff00", width: 10, stroke: "dotted" }
|
||||
],
|
||||
default: "Value 2"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const lineTemplate = await client.getLineTemplate({
|
||||
typeId: type.id
|
||||
});
|
||||
|
||||
expect(lineTemplate).toEqual({
|
||||
typeId: type.id,
|
||||
name: "",
|
||||
colour: "00ff00",
|
||||
width: 10,
|
||||
stroke: "dotted",
|
||||
mode: "",
|
||||
data: {}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Marker update is overridden by dropdown styles", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "marker",
|
||||
fields: [
|
||||
{
|
||||
name: "Dropdown",
|
||||
type: "dropdown",
|
||||
controlColour: true,
|
||||
controlSize: true,
|
||||
controlSymbol: true,
|
||||
controlShape: true,
|
||||
options: [
|
||||
{ value: "Value 1", colour: "00ffff", size: 60, symbol: "z", shape: "rectangle" },
|
||||
{ value: "Value 2", colour: "00ff00", size: 50, symbol: "a", shape: "circle" }
|
||||
],
|
||||
default: "Value 2"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const marker = await client.addMarker({
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
typeId: type.id,
|
||||
data: {
|
||||
"Dropdown": "Value 1"
|
||||
}
|
||||
});
|
||||
|
||||
const markerUpdate = await client.editMarker({
|
||||
id: marker.id,
|
||||
colour: "ffffff",
|
||||
size: 20,
|
||||
symbol: "b",
|
||||
shape: "drop"
|
||||
});
|
||||
|
||||
expect(markerUpdate).toMatchObject({
|
||||
colour: "00ffff",
|
||||
size: 60,
|
||||
symbol: "z",
|
||||
shape: "rectangle",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Line update is overridden by dropdown styles", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "line",
|
||||
fields: [
|
||||
{
|
||||
name: "Dropdown",
|
||||
type: "dropdown",
|
||||
controlColour: true,
|
||||
controlWidth: true,
|
||||
controlStroke: true,
|
||||
options: [
|
||||
{ value: "Value 1", colour: "00ffff", width: 11, stroke: "dashed" },
|
||||
{ value: "Value 2", colour: "00ff00", width: 10, stroke: "dotted" }
|
||||
],
|
||||
default: "Value 2"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const line = await client.addLine({
|
||||
routePoints: [
|
||||
{ lat: 0, lon: 0 },
|
||||
{ lat: 1, lon: 1 }
|
||||
],
|
||||
typeId: type.id,
|
||||
data: {
|
||||
"Dropdown": "Value 1"
|
||||
}
|
||||
});
|
||||
|
||||
const lineUpdate = await client.editLine({
|
||||
id: line.id,
|
||||
colour: "ffff00",
|
||||
width: 20,
|
||||
stroke: ""
|
||||
});
|
||||
|
||||
expect(lineUpdate).toMatchObject({
|
||||
colour: "00ffff",
|
||||
width: 11,
|
||||
stroke: "dashed"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("New dropdown styles are applied to existing markers", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "marker"
|
||||
});
|
||||
|
||||
await client.updateBbox({ top: 1, right: 1, bottom: -1, left: -1, zoom: 0 }); // To have marker in bbox
|
||||
|
||||
const marker = await client.addMarker({
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
typeId: type.id,
|
||||
colour: "ffffff",
|
||||
size: 20,
|
||||
symbol: "b",
|
||||
shape: "drop"
|
||||
});
|
||||
|
||||
const onMarker = vi.fn();
|
||||
client.on("marker", onMarker);
|
||||
|
||||
await client.editType({
|
||||
id: type.id,
|
||||
fields: [
|
||||
{
|
||||
name: "Dropdown",
|
||||
type: "dropdown",
|
||||
controlColour: true,
|
||||
controlSize: true,
|
||||
controlSymbol: true,
|
||||
controlShape: true,
|
||||
options: [
|
||||
{ value: "Value 1", colour: "00ffff", size: 60, symbol: "z", shape: "rectangle" },
|
||||
{ value: "Value 2", colour: "00ff00", size: 50, symbol: "a", shape: "circle" }
|
||||
],
|
||||
default: "Value 2"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(onMarker).toBeCalledTimes(1);
|
||||
|
||||
expect(client.markers[marker.id]).toMatchObject({
|
||||
colour: "00ff00",
|
||||
size: 50,
|
||||
symbol: "a",
|
||||
shape: "circle",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("New dropdown styles are applied to existing lines", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "line"
|
||||
});
|
||||
|
||||
const line = await client.addLine({
|
||||
routePoints: [
|
||||
{ lat: 0, lon: 0 },
|
||||
{ lat: 1, lon: 1 }
|
||||
],
|
||||
typeId: type.id,
|
||||
colour: "ffffff",
|
||||
width: 20,
|
||||
stroke: "dashed",
|
||||
mode: ""
|
||||
});
|
||||
|
||||
const onLine = vi.fn();
|
||||
client.on("line", onLine);
|
||||
|
||||
await client.editType({
|
||||
id: type.id,
|
||||
fields: [
|
||||
{
|
||||
name: "Dropdown",
|
||||
type: "dropdown",
|
||||
controlColour: true,
|
||||
controlWidth: true,
|
||||
controlStroke: true,
|
||||
options: [
|
||||
{ value: "Value 1", colour: "00ffff", width: 11, stroke: "dashed" },
|
||||
{ value: "Value 2", colour: "00ff00", width: 10, stroke: "dotted" }
|
||||
],
|
||||
default: "Value 2"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(onLine).toBeCalledTimes(1);
|
||||
|
||||
expect(client.lines[line.id]).toMatchObject({
|
||||
colour: "00ff00",
|
||||
width: 10,
|
||||
stroke: "dotted"
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,292 @@
|
|||
import { expect, test, vi } from "vitest";
|
||||
import { createTemporaryPad, openClient } from "../utils";
|
||||
|
||||
test("Rename field (marker type)", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async (createPadData, padData, result) => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "marker",
|
||||
fields: [
|
||||
{ name: "Field 1", type: "input" },
|
||||
{ name: "Field 2", type: "input" }
|
||||
]
|
||||
});
|
||||
|
||||
await client.updateBbox({ top: 1, right: 1, bottom: -1, left: -1, zoom: 0 }); // To have marker in bbox
|
||||
|
||||
const marker = await client.addMarker({
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
typeId: type.id,
|
||||
data: {
|
||||
"Field 1": "value 1",
|
||||
"Field 2": "value 2"
|
||||
}
|
||||
});
|
||||
|
||||
const onMarker = vi.fn();
|
||||
client.on("marker", onMarker);
|
||||
|
||||
await client.editType({
|
||||
id: type.id,
|
||||
fields: [
|
||||
{ oldName: "Field 1", name: "Field 1 new", type: "input" },
|
||||
{ name: "Field 2", type: "input" }
|
||||
]
|
||||
});
|
||||
|
||||
expect(onMarker).toBeCalledTimes(1);
|
||||
|
||||
expect(client.markers[marker.id].data).toEqual({
|
||||
"Field 1 new": "value 1",
|
||||
"Field 2": "value 2"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Rename field (line type)", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async (createPadData, padData, result) => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "line",
|
||||
fields: [
|
||||
{ name: "Field 1", type: "input" },
|
||||
{ name: "Field 2", type: "input" }
|
||||
]
|
||||
});
|
||||
|
||||
const line = await client.addLine({
|
||||
routePoints: [
|
||||
{ lat: 0, lon: 0 },
|
||||
{ lat: 1, lon: 1 }
|
||||
],
|
||||
typeId: type.id,
|
||||
data: {
|
||||
"Field 1": "value 1",
|
||||
"Field 2": "value 2"
|
||||
}
|
||||
});
|
||||
|
||||
const onLine = vi.fn();
|
||||
client.on("line", onLine);
|
||||
|
||||
await client.editType({
|
||||
id: type.id,
|
||||
fields: [
|
||||
{ oldName: "Field 1", name: "Field 1 new", type: "input" },
|
||||
{ name: "Field 2", type: "input" }
|
||||
]
|
||||
});
|
||||
|
||||
expect(onLine).toBeCalledTimes(1);
|
||||
|
||||
expect(client.lines[line.id].data).toEqual({
|
||||
"Field 1 new": "value 1",
|
||||
"Field 2": "value 2"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Rename dropdown option (marker type)", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async (createPadData, padData, result) => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "marker",
|
||||
fields: [
|
||||
{ name: "Dropdown", type: "dropdown", options: [ { value: "Option 1" }, { value: "Option 2" } ] },
|
||||
]
|
||||
});
|
||||
|
||||
await client.updateBbox({ top: 1, right: 1, bottom: -1, left: -1, zoom: 0 }); // To have marker in bbox
|
||||
|
||||
const marker1 = await client.addMarker({
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
typeId: type.id,
|
||||
data: {
|
||||
"Dropdown": "Option 1"
|
||||
}
|
||||
});
|
||||
|
||||
const marker2 = await client.addMarker({
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
typeId: type.id,
|
||||
data: {
|
||||
"Dropdown": "Option 2"
|
||||
}
|
||||
});
|
||||
|
||||
const onMarker = vi.fn();
|
||||
client.on("marker", onMarker);
|
||||
|
||||
await client.editType({
|
||||
id: type.id,
|
||||
fields: [
|
||||
{ name: "Dropdown", type: "dropdown", options: [ { value: "Option 1" }, { oldValue: "Option 2", value: "Option 2 new" } ] }
|
||||
]
|
||||
});
|
||||
|
||||
expect(onMarker).toBeCalledTimes(1);
|
||||
|
||||
expect(client.markers[marker1.id].data).toEqual({
|
||||
"Dropdown": "Option 1"
|
||||
});
|
||||
expect(client.markers[marker2.id].data).toEqual({
|
||||
"Dropdown": "Option 2 new"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Rename dropdown option (line type)", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async (createPadData, padData, result) => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "line",
|
||||
fields: [
|
||||
{ name: "Dropdown", type: "dropdown", options: [ { value: "Option 1" }, { value: "Option 2" } ] },
|
||||
]
|
||||
});
|
||||
|
||||
const line1 = await client.addLine({
|
||||
routePoints: [
|
||||
{ lat: 0, lon: 0 },
|
||||
{ lat: 1, lon: 1 }
|
||||
],
|
||||
typeId: type.id,
|
||||
data: {
|
||||
"Dropdown": "Option 1"
|
||||
}
|
||||
});
|
||||
|
||||
const line2 = await client.addLine({
|
||||
routePoints: [
|
||||
{ lat: 0, lon: 0 },
|
||||
{ lat: 1, lon: 1 }
|
||||
],
|
||||
typeId: type.id,
|
||||
data: {
|
||||
"Dropdown": "Option 2"
|
||||
}
|
||||
});
|
||||
|
||||
const onLine = vi.fn();
|
||||
client.on("line", onLine);
|
||||
|
||||
await client.editType({
|
||||
id: type.id,
|
||||
fields: [
|
||||
{ name: "Dropdown", type: "dropdown", options: [ { value: "Option 1" }, { oldValue: "Option 2", value: "Option 2 new" } ] }
|
||||
]
|
||||
});
|
||||
|
||||
expect(onLine).toBeCalledTimes(1);
|
||||
|
||||
expect(client.lines[line1.id].data).toEqual({
|
||||
"Dropdown": "Option 1"
|
||||
});
|
||||
expect(client.lines[line2.id].data).toEqual({
|
||||
"Dropdown": "Option 2 new"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Create type with duplicate fields", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
await expect(async () => {
|
||||
await client.addType({
|
||||
name: "Test type",
|
||||
type: "marker",
|
||||
fields: [
|
||||
{ name: "Field 1", type: "input" },
|
||||
{ name: "Field 1", type: "textarea" }
|
||||
]
|
||||
});
|
||||
}).rejects.toThrowError("Field names must be unique.");
|
||||
});
|
||||
});
|
||||
|
||||
test("Update type with duplicate fields", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "marker",
|
||||
fields: [
|
||||
{ name: "Field 1", type: "input" },
|
||||
{ name: "Field 2", type: "textarea" }
|
||||
]
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
await client.editType({
|
||||
id: type.id,
|
||||
fields: [
|
||||
{ name: "Field 1", type: "input" },
|
||||
{ name: "Field 1", type: "textarea" }
|
||||
]
|
||||
});
|
||||
}).rejects.toThrowError("Field names must be unique.");
|
||||
});
|
||||
});
|
||||
|
||||
test("Create type with duplicate dropdown values", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
await expect(async () => {
|
||||
await client.addType({
|
||||
name: "Test type",
|
||||
type: "marker",
|
||||
fields: [
|
||||
{
|
||||
name: "Dropdown",
|
||||
type: "dropdown",
|
||||
options: [
|
||||
{ value: "Value 1" },
|
||||
{ value: "Value 1" }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
}).rejects.toThrowError("Dropdown option values must be unique.");
|
||||
});
|
||||
});
|
||||
|
||||
test("Update type with duplicate dropdown values", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "marker"
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
await client.editType({
|
||||
id: type.id,
|
||||
fields: [
|
||||
{
|
||||
name: "Dropdown",
|
||||
type: "dropdown",
|
||||
options: [
|
||||
{ value: "Value 1" },
|
||||
{ value: "Value 1" }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
}).rejects.toThrowError("Dropdown option values must be unique.");
|
||||
});
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
import { expect, test } from "vitest";
|
||||
import { createTemporaryPad, openClient } from "../utils";
|
||||
|
||||
test("Reorder types", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async (padData) => {
|
||||
const type1 = await client.addType({
|
||||
name: "Test type 1",
|
||||
type: "marker"
|
||||
});
|
||||
|
||||
expect(type1.idx).toEqual(0);
|
||||
|
||||
const type2 = await client.addType({
|
||||
name: "Test type 2",
|
||||
type: "line",
|
||||
idx: 3
|
||||
});
|
||||
|
||||
expect(type2.idx).toEqual(3);
|
||||
|
||||
const type3 = await client.addType({
|
||||
name: "Test type 3",
|
||||
type: "marker",
|
||||
idx: 0 // Should move type1 down, but not type2 (since there is a gap)
|
||||
});
|
||||
expect(type3.idx).toEqual(0);
|
||||
expect(client.types[type1.id].idx).toEqual(1);
|
||||
expect(client.types[type2.id].idx).toEqual(3);
|
||||
|
||||
const updatedType1 = await client.editType({
|
||||
id: type1.id,
|
||||
idx: 0 // Should move type3 down, but not type2 (since there is a gap)
|
||||
});
|
||||
expect(updatedType1.idx).toEqual(0);
|
||||
expect(client.types[type2.id].idx).toEqual(3);
|
||||
expect(client.types[type3.id].idx).toEqual(1);
|
||||
|
||||
const newUpdatedType1 = await client.editType({
|
||||
id: type1.id,
|
||||
idx: 3 // Should move type2 down but leave type3 untouched
|
||||
});
|
||||
expect(newUpdatedType1.idx).toEqual(3);
|
||||
expect(client.types[type2.id].idx).toEqual(4);
|
||||
expect(client.types[type3.id].idx).toEqual(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,339 @@
|
|||
import { expect, test, vi } from "vitest";
|
||||
import { createTemporaryPad, openClient } from "../utils";
|
||||
|
||||
test("New marker is created with default settings", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "marker",
|
||||
defaultColour: "00ff00",
|
||||
defaultSize: 50,
|
||||
defaultSymbol: "a",
|
||||
defaultShape: "circle"
|
||||
});
|
||||
|
||||
const marker = await client.addMarker({
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
typeId: type.id
|
||||
});
|
||||
|
||||
expect(marker).toMatchObject({
|
||||
colour: "00ff00",
|
||||
size: 50,
|
||||
symbol: "a",
|
||||
shape: "circle",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("New line is created with default settings", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "line",
|
||||
defaultColour: "00ff00",
|
||||
defaultWidth: 10,
|
||||
defaultStroke: "dotted",
|
||||
defaultMode: "straight",
|
||||
});
|
||||
|
||||
const line = await client.addLine({
|
||||
routePoints: [
|
||||
{ lat: 0, lon: 0 },
|
||||
{ lat: 1, lon: 1 }
|
||||
],
|
||||
typeId: type.id
|
||||
});
|
||||
|
||||
expect(line).toMatchObject({
|
||||
colour: "00ff00",
|
||||
width: 10,
|
||||
stroke: "dotted",
|
||||
mode: "straight"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Line template uses default settings", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "line",
|
||||
defaultColour: "00ff00",
|
||||
defaultWidth: 10,
|
||||
defaultStroke: "dotted",
|
||||
defaultMode: "straight",
|
||||
});
|
||||
|
||||
const lineTemplate = await client.getLineTemplate({
|
||||
typeId: type.id
|
||||
});
|
||||
|
||||
expect(lineTemplate).toEqual({
|
||||
typeId: type.id,
|
||||
name: "",
|
||||
colour: "00ff00",
|
||||
width: 10,
|
||||
stroke: "dotted",
|
||||
mode: "straight",
|
||||
data: {}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("New marker is created with fixed settings", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "marker",
|
||||
defaultColour: "00ff00",
|
||||
colourFixed: true,
|
||||
defaultSize: 50,
|
||||
sizeFixed: true,
|
||||
defaultSymbol: "a",
|
||||
symbolFixed: true,
|
||||
defaultShape: "circle",
|
||||
shapeFixed: true
|
||||
});
|
||||
|
||||
const marker = await client.addMarker({
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
typeId: type.id,
|
||||
colour: "ffffff",
|
||||
size: 20,
|
||||
symbol: "b",
|
||||
shape: "drop"
|
||||
});
|
||||
|
||||
expect(marker).toMatchObject({
|
||||
colour: "00ff00",
|
||||
size: 50,
|
||||
symbol: "a",
|
||||
shape: "circle",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("New line is created with fixed settings", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "line",
|
||||
defaultColour: "00ff00",
|
||||
colourFixed: true,
|
||||
defaultWidth: 10,
|
||||
widthFixed: true,
|
||||
defaultStroke: "dotted",
|
||||
strokeFixed: true,
|
||||
defaultMode: "straight",
|
||||
modeFixed: true
|
||||
});
|
||||
|
||||
const line = await client.addLine({
|
||||
routePoints: [
|
||||
{ lat: 0, lon: 0 },
|
||||
{ lat: 1, lon: 1 }
|
||||
],
|
||||
typeId: type.id,
|
||||
colour: "ffffff",
|
||||
width: 20,
|
||||
stroke: "dashed",
|
||||
mode: ""
|
||||
});
|
||||
|
||||
expect(line).toMatchObject({
|
||||
colour: "00ff00",
|
||||
width: 10,
|
||||
stroke: "dotted",
|
||||
mode: "straight"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Marker update is overridden by fixed settings", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "marker",
|
||||
defaultColour: "00ff00",
|
||||
colourFixed: true,
|
||||
defaultSize: 50,
|
||||
sizeFixed: true,
|
||||
defaultSymbol: "a",
|
||||
symbolFixed: true,
|
||||
defaultShape: "circle",
|
||||
shapeFixed: true
|
||||
});
|
||||
|
||||
const marker = await client.addMarker({
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
typeId: type.id
|
||||
});
|
||||
|
||||
const markerUpdate = await client.editMarker({
|
||||
id: marker.id,
|
||||
colour: "ffffff",
|
||||
size: 20,
|
||||
symbol: "b",
|
||||
shape: "drop"
|
||||
});
|
||||
|
||||
expect(markerUpdate).toMatchObject({
|
||||
colour: "00ff00",
|
||||
size: 50,
|
||||
symbol: "a",
|
||||
shape: "circle",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Line is overridden by fixed settings", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "line",
|
||||
defaultColour: "00ff00",
|
||||
colourFixed: true,
|
||||
defaultWidth: 10,
|
||||
widthFixed: true,
|
||||
defaultStroke: "dotted",
|
||||
strokeFixed: true,
|
||||
defaultMode: "straight",
|
||||
modeFixed: true
|
||||
});
|
||||
|
||||
const line = await client.addLine({
|
||||
routePoints: [
|
||||
{ lat: 0, lon: 0 },
|
||||
{ lat: 1, lon: 1 }
|
||||
],
|
||||
typeId: type.id
|
||||
});
|
||||
|
||||
const lineUpdate = await client.editLine({
|
||||
id: line.id,
|
||||
colour: "ffffff",
|
||||
width: 20,
|
||||
stroke: "dashed",
|
||||
mode: ""
|
||||
});
|
||||
|
||||
expect(lineUpdate).toMatchObject({
|
||||
colour: "00ff00",
|
||||
width: 10,
|
||||
stroke: "dotted",
|
||||
mode: "straight"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("New fixed marker styles are applied to existing markers", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "marker"
|
||||
});
|
||||
|
||||
await client.updateBbox({ top: 1, right: 1, bottom: -1, left: -1, zoom: 0 }); // To have marker in bbox
|
||||
|
||||
const marker = await client.addMarker({
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
typeId: type.id,
|
||||
colour: "ffffff",
|
||||
size: 20,
|
||||
symbol: "b",
|
||||
shape: "drop"
|
||||
});
|
||||
|
||||
const onMarker = vi.fn();
|
||||
client.on("marker", onMarker);
|
||||
|
||||
await client.editType({
|
||||
id: type.id,
|
||||
defaultColour: "00ff00",
|
||||
colourFixed: true,
|
||||
defaultSize: 50,
|
||||
sizeFixed: true,
|
||||
defaultSymbol: "a",
|
||||
symbolFixed: true,
|
||||
defaultShape: "circle",
|
||||
shapeFixed: true
|
||||
});
|
||||
|
||||
expect(onMarker).toBeCalledTimes(1);
|
||||
|
||||
expect(client.markers[marker.id]).toMatchObject({
|
||||
colour: "00ff00",
|
||||
size: 50,
|
||||
symbol: "a",
|
||||
shape: "circle",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("New fixed line styles are applied to existing lines", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async () => {
|
||||
const type = await client.addType({
|
||||
name: "Test type",
|
||||
type: "line"
|
||||
});
|
||||
|
||||
const line = await client.addLine({
|
||||
routePoints: [
|
||||
{ lat: 0, lon: 0 },
|
||||
{ lat: 1, lon: 1 }
|
||||
],
|
||||
typeId: type.id,
|
||||
colour: "ffffff",
|
||||
width: 20,
|
||||
stroke: "dashed",
|
||||
mode: ""
|
||||
});
|
||||
|
||||
const onLine = vi.fn();
|
||||
client.on("line", onLine);
|
||||
|
||||
await client.editType({
|
||||
id: type.id,
|
||||
defaultColour: "00ff00",
|
||||
colourFixed: true,
|
||||
defaultWidth: 10,
|
||||
widthFixed: true,
|
||||
defaultStroke: "dotted",
|
||||
strokeFixed: true,
|
||||
defaultMode: "straight",
|
||||
modeFixed: true
|
||||
});
|
||||
|
||||
expect(onLine).toBeCalledTimes(1);
|
||||
|
||||
expect(client.lines[line.id]).toMatchObject({
|
||||
colour: "00ff00",
|
||||
width: 10,
|
||||
stroke: "dotted",
|
||||
mode: "straight"
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,481 @@
|
|||
import { expect, test, vi } from "vitest";
|
||||
import { createTemporaryPad, openClient, retry } from "../utils";
|
||||
import { CRU, type Type } from "facilmap-types";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
|
||||
test("Default types are added", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
const onType = vi.fn();
|
||||
client.on("type", onType);
|
||||
|
||||
await createTemporaryPad(client, {}, async (createPadData, padData, result) => {
|
||||
expect(result.type?.length).toBe(2);
|
||||
|
||||
const expectedTypes = [
|
||||
{
|
||||
fields: [
|
||||
{ name: "Description", type: "textarea" }
|
||||
],
|
||||
id: result.type![0].id,
|
||||
name: 'Marker',
|
||||
type: 'marker',
|
||||
idx: 0,
|
||||
defaultColour: 'ff0000',
|
||||
colourFixed: false,
|
||||
defaultSize: 30,
|
||||
sizeFixed: false,
|
||||
defaultSymbol: '',
|
||||
symbolFixed: false,
|
||||
defaultShape: '',
|
||||
shapeFixed: false,
|
||||
defaultWidth: 4,
|
||||
widthFixed: false,
|
||||
defaultStroke: '',
|
||||
strokeFixed: false,
|
||||
defaultMode: '',
|
||||
modeFixed: false,
|
||||
showInLegend: false,
|
||||
padId: padData.id
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{ name: "Description", type: "textarea" }
|
||||
],
|
||||
id: result.type![1].id,
|
||||
name: 'Line',
|
||||
type: 'line',
|
||||
idx: 1,
|
||||
defaultColour: '0000ff',
|
||||
colourFixed: false,
|
||||
defaultSize: 30,
|
||||
sizeFixed: false,
|
||||
defaultSymbol: '',
|
||||
symbolFixed: false,
|
||||
defaultShape: '',
|
||||
shapeFixed: false,
|
||||
defaultWidth: 4,
|
||||
widthFixed: false,
|
||||
defaultStroke: '',
|
||||
strokeFixed: false,
|
||||
defaultMode: '',
|
||||
modeFixed: false,
|
||||
showInLegend: false,
|
||||
padId: padData.id
|
||||
}
|
||||
] satisfies Array<Type>;
|
||||
|
||||
expect(result.type).toEqual(expectedTypes);
|
||||
|
||||
expect(onType).toBeCalledTimes(expectedTypes.length);
|
||||
for (const type of expectedTypes) {
|
||||
expect(onType).toBeCalledWith(type);
|
||||
}
|
||||
|
||||
expect(client.types).toEqual(Object.fromEntries(expectedTypes.map((t) => [t.id, t])));
|
||||
});
|
||||
});
|
||||
|
||||
test("Default types are not added", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async (createPadData, padData, result) => {
|
||||
expect(result.type).toEqual([]);
|
||||
expect(client.types).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
test("Create type (marker, default settings)", async () => {
|
||||
const client1 = await openClient();
|
||||
|
||||
await createTemporaryPad(client1, { createDefaultTypes: false }, async (createPadData, padData, result) => {
|
||||
const client2 = await openClient(padData.id);
|
||||
|
||||
const onType1 = vi.fn();
|
||||
client1.on("type", onType1);
|
||||
|
||||
const onType2 = vi.fn();
|
||||
client2.on("type", onType2);
|
||||
|
||||
const type = {
|
||||
name: "Test type",
|
||||
type: "marker"
|
||||
} satisfies Type<CRU.CREATE>;
|
||||
|
||||
const typeResult = await client1.addType(type);
|
||||
|
||||
const expectedType: Type = {
|
||||
...type,
|
||||
id: typeResult.id,
|
||||
padId: padData.id,
|
||||
idx: 0,
|
||||
defaultColour: "ff0000",
|
||||
colourFixed: false,
|
||||
defaultSize: 30,
|
||||
sizeFixed: false,
|
||||
defaultSymbol: "",
|
||||
symbolFixed: false,
|
||||
defaultShape: "",
|
||||
shapeFixed: false,
|
||||
defaultWidth: 4,
|
||||
widthFixed: false,
|
||||
defaultStroke: "",
|
||||
strokeFixed: false,
|
||||
defaultMode: "",
|
||||
modeFixed: false,
|
||||
showInLegend: false,
|
||||
fields: [
|
||||
{ name: "Description", type: "textarea" }
|
||||
]
|
||||
};
|
||||
|
||||
expect(typeResult).toEqual(expectedType);
|
||||
|
||||
await retry(async () => {
|
||||
expect(onType1).toBeCalledTimes(1);
|
||||
expect(onType2).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(onType1).toHaveBeenNthCalledWith(1, expectedType);
|
||||
expect(cloneDeep(client1.types)).toEqual({
|
||||
[expectedType.id]: expectedType
|
||||
});
|
||||
|
||||
expect(onType2).toHaveBeenNthCalledWith(1, expectedType);
|
||||
expect(cloneDeep(client2.types)).toEqual({
|
||||
[expectedType.id]: expectedType
|
||||
});
|
||||
|
||||
const client3 = await openClient(padData.id);
|
||||
expect(cloneDeep(client3.types)).toEqual({
|
||||
[expectedType.id]: expectedType
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Create type (line, default settings)", async () => {
|
||||
const client1 = await openClient();
|
||||
|
||||
const onType = vi.fn();
|
||||
client1.on("type", onType);
|
||||
|
||||
await createTemporaryPad(client1, { createDefaultTypes: false }, async (createPadData, padData, result) => {
|
||||
const client2 = await openClient(padData.id);
|
||||
|
||||
const onType1 = vi.fn();
|
||||
client1.on("type", onType1);
|
||||
|
||||
const onType2 = vi.fn();
|
||||
client2.on("type", onType2);
|
||||
|
||||
const type = {
|
||||
name: "Test type",
|
||||
type: "line"
|
||||
} satisfies Type<CRU.CREATE>;
|
||||
|
||||
const typeResult = await client1.addType(type);
|
||||
|
||||
const expectedType: Type = {
|
||||
...type,
|
||||
id: typeResult.id,
|
||||
padId: padData.id,
|
||||
idx: 0,
|
||||
defaultColour: "0000ff",
|
||||
colourFixed: false,
|
||||
defaultSize: 30,
|
||||
sizeFixed: false,
|
||||
defaultSymbol: "",
|
||||
symbolFixed: false,
|
||||
defaultShape: "",
|
||||
shapeFixed: false,
|
||||
defaultWidth: 4,
|
||||
widthFixed: false,
|
||||
defaultStroke: "",
|
||||
strokeFixed: false,
|
||||
defaultMode: "",
|
||||
modeFixed: false,
|
||||
showInLegend: false,
|
||||
fields: [
|
||||
{ name: "Description", type: "textarea" }
|
||||
]
|
||||
};
|
||||
|
||||
expect(typeResult).toEqual(expectedType);
|
||||
|
||||
await retry(async () => {
|
||||
expect(onType1).toBeCalledTimes(1);
|
||||
expect(onType2).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(onType1).toHaveBeenNthCalledWith(1, expectedType);
|
||||
expect(cloneDeep(client1.types)).toEqual({
|
||||
[expectedType.id]: expectedType
|
||||
});
|
||||
|
||||
expect(onType2).toHaveBeenNthCalledWith(1, expectedType);
|
||||
expect(cloneDeep(client2.types)).toEqual({
|
||||
[expectedType.id]: expectedType
|
||||
});
|
||||
|
||||
const client3 = await openClient(padData.id);
|
||||
expect(cloneDeep(client3.types)).toEqual({
|
||||
[expectedType.id]: expectedType
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Create type (custom settings)", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
const onType = vi.fn();
|
||||
client.on("type", onType);
|
||||
|
||||
await createTemporaryPad(client, { createDefaultTypes: false }, async (createPadData, padData, result) => {
|
||||
const onType = vi.fn();
|
||||
client.on("type", onType);
|
||||
|
||||
const type = {
|
||||
name: "Test type",
|
||||
type: "marker",
|
||||
idx: 4,
|
||||
defaultColour: "00ff00",
|
||||
colourFixed: true,
|
||||
defaultSize: 35,
|
||||
sizeFixed: true,
|
||||
defaultSymbol: "a",
|
||||
symbolFixed: true,
|
||||
defaultShape: "star",
|
||||
shapeFixed: true,
|
||||
defaultWidth: 10,
|
||||
widthFixed: true,
|
||||
defaultStroke: "dotted",
|
||||
strokeFixed: true,
|
||||
defaultMode: "car",
|
||||
modeFixed: true,
|
||||
showInLegend: true,
|
||||
fields: [
|
||||
{ name: "Test field", type: "input" }
|
||||
]
|
||||
} satisfies Type<CRU.CREATE>;
|
||||
|
||||
const typeResult = await client.addType(type);
|
||||
|
||||
const expectedType: Type = {
|
||||
...type,
|
||||
id: typeResult.id,
|
||||
padId: padData.id
|
||||
};
|
||||
|
||||
expect(typeResult).toEqual(expectedType);
|
||||
|
||||
await retry(async () => {
|
||||
expect(onType).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(onType).toHaveBeenNthCalledWith(1, expectedType);
|
||||
expect(cloneDeep(client.types)).toEqual({
|
||||
[expectedType.id]: expectedType
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Update type", async () => {
|
||||
const client1 = await openClient();
|
||||
|
||||
const onType = vi.fn();
|
||||
client1.on("type", onType);
|
||||
|
||||
await createTemporaryPad(client1, { createDefaultTypes: false }, async (createPadData, padData, result) => {
|
||||
const createdType = await client1.addType({
|
||||
name: "Test type",
|
||||
type: "marker"
|
||||
});
|
||||
|
||||
const client2 = await openClient(padData.id);
|
||||
|
||||
const onType1 = vi.fn();
|
||||
client1.on("type", onType1);
|
||||
|
||||
const onType2 = vi.fn();
|
||||
client2.on("type", onType2);
|
||||
|
||||
const update = {
|
||||
id: createdType.id,
|
||||
name: "Test type 2",
|
||||
idx: 4,
|
||||
defaultColour: "00ff00",
|
||||
colourFixed: true,
|
||||
defaultSize: 35,
|
||||
sizeFixed: true,
|
||||
defaultSymbol: "a",
|
||||
symbolFixed: true,
|
||||
defaultShape: "star",
|
||||
shapeFixed: true,
|
||||
defaultWidth: 10,
|
||||
widthFixed: true,
|
||||
defaultStroke: "dotted",
|
||||
strokeFixed: true,
|
||||
defaultMode: "car",
|
||||
modeFixed: true,
|
||||
showInLegend: true,
|
||||
fields: [
|
||||
{ name: "Test field", type: "input" }
|
||||
]
|
||||
} satisfies Type<CRU.UPDATE>;
|
||||
|
||||
const typeResult = await client1.editType(update);
|
||||
|
||||
const expectedType: Type = {
|
||||
...update,
|
||||
padId: padData.id,
|
||||
type: "marker"
|
||||
};
|
||||
|
||||
expect(typeResult).toEqual(expectedType);
|
||||
|
||||
await retry(async () => {
|
||||
expect(onType1).toBeCalledTimes(1);
|
||||
expect(onType2).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(onType1).toHaveBeenNthCalledWith(1, expectedType);
|
||||
expect(cloneDeep(client1.types)).toEqual({
|
||||
[expectedType.id]: expectedType
|
||||
});
|
||||
|
||||
expect(onType2).toHaveBeenNthCalledWith(1, expectedType);
|
||||
expect(cloneDeep(client2.types)).toEqual({
|
||||
[expectedType.id]: expectedType
|
||||
});
|
||||
|
||||
const client3 = await openClient(padData.id);
|
||||
expect(cloneDeep(client3.types)).toEqual({
|
||||
[expectedType.id]: expectedType
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Delete type", async () => {
|
||||
const client1 = await openClient();
|
||||
|
||||
await createTemporaryPad(client1, { createDefaultTypes: false }, async (createPadData, padData, result) => {
|
||||
const type = await client1.addType({
|
||||
name: "Test type",
|
||||
type: "marker"
|
||||
});
|
||||
|
||||
const client2 = await openClient(padData.id);
|
||||
|
||||
const onDeleteType1 = vi.fn();
|
||||
client1.on("deleteType", onDeleteType1);
|
||||
|
||||
const onDeleteType2 = vi.fn();
|
||||
client2.on("deleteType", onDeleteType2);
|
||||
|
||||
const deletedType = await client1.deleteType({ id: type.id });
|
||||
|
||||
expect(deletedType).toEqual(type);
|
||||
|
||||
await retry(async () => {
|
||||
expect(onDeleteType1).toBeCalledTimes(1);
|
||||
expect(onDeleteType2).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(onDeleteType1).toHaveBeenNthCalledWith(1, { id: type.id });
|
||||
expect(cloneDeep(client1.types)).toEqual({});
|
||||
|
||||
expect(onDeleteType2).toHaveBeenNthCalledWith(1, { id: type.id });
|
||||
expect(cloneDeep(client2.types)).toEqual({});
|
||||
|
||||
const client3 = await openClient(padData.id);
|
||||
expect(cloneDeep(client3.types)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
test("Delete type (existing markers)", async () => {
|
||||
const client1 = await openClient();
|
||||
|
||||
await createTemporaryPad(client1, { createDefaultTypes: false }, async (createPadData, padData, result) => {
|
||||
const type = await client1.addType({
|
||||
name: "Test type",
|
||||
type: "marker"
|
||||
});
|
||||
|
||||
await client1.addMarker({
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
typeId: type.id
|
||||
});
|
||||
|
||||
const client2 = await openClient(padData.id);
|
||||
|
||||
const onDeleteType1 = vi.fn();
|
||||
client1.on("deleteType", onDeleteType1);
|
||||
|
||||
const onDeleteType2 = vi.fn();
|
||||
client2.on("deleteType", onDeleteType2);
|
||||
|
||||
await expect(async () => {
|
||||
await client1.deleteType({ id: type.id });
|
||||
}).rejects.toThrowError("This type is in use.");
|
||||
|
||||
expect(onDeleteType1).toBeCalledTimes(0);
|
||||
expect(onDeleteType2).toBeCalledTimes(0);
|
||||
expect(cloneDeep(client1.types)).toEqual({
|
||||
[type.id]: type
|
||||
});
|
||||
expect(cloneDeep(client2.types)).toEqual({
|
||||
[type.id]: type
|
||||
});
|
||||
|
||||
const client3 = await openClient(padData.id);
|
||||
expect(cloneDeep(client3.types)).toEqual({
|
||||
[type.id]: type
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Delete type (existing lines)", async () => {
|
||||
const client1 = await openClient();
|
||||
|
||||
await createTemporaryPad(client1, { createDefaultTypes: false }, async (createPadData, padData, result) => {
|
||||
const type = await client1.addType({
|
||||
name: "Test type",
|
||||
type: "line"
|
||||
});
|
||||
|
||||
await client1.addLine({
|
||||
routePoints: [
|
||||
{ lat: 0, lon: 0 },
|
||||
{ lat: 1, lon: 1 }
|
||||
],
|
||||
typeId: type.id
|
||||
});
|
||||
|
||||
const client2 = await openClient(padData.id);
|
||||
|
||||
const onDeleteType1 = vi.fn();
|
||||
client1.on("deleteType", onDeleteType1);
|
||||
|
||||
const onDeleteType2 = vi.fn();
|
||||
client2.on("deleteType", onDeleteType2);
|
||||
|
||||
await expect(async () => {
|
||||
await client1.deleteType({ id: type.id });
|
||||
}).rejects.toThrowError("This type is in use.");
|
||||
|
||||
expect(onDeleteType1).toBeCalledTimes(0);
|
||||
expect(onDeleteType2).toBeCalledTimes(0);
|
||||
expect(cloneDeep(client1.types)).toEqual({
|
||||
[type.id]: type
|
||||
});
|
||||
expect(cloneDeep(client2.types)).toEqual({
|
||||
[type.id]: type
|
||||
});
|
||||
|
||||
const client3 = await openClient(padData.id);
|
||||
expect(cloneDeep(client3.types)).toEqual({
|
||||
[type.id]: type
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,20 +1,22 @@
|
|||
import { expect, test, vi } from "vitest";
|
||||
import { createTemporaryPad, openClient } from "./utils";
|
||||
import { createTemporaryPad, openClient, retry } from "./utils";
|
||||
import { type CRU, type View } from "facilmap-types";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
|
||||
test("Database views", async () => {
|
||||
const client = await openClient();
|
||||
test("Create view (default values)", async () => {
|
||||
const client1 = await openClient();
|
||||
|
||||
const onView = vi.fn();
|
||||
client.on("view", onView);
|
||||
await createTemporaryPad(client1, {}, async (padData) => {
|
||||
const client2 = await openClient(padData.id);
|
||||
|
||||
const onPadData = vi.fn();
|
||||
client.on("padData", onPadData);
|
||||
const onView1 = vi.fn();
|
||||
client1.on("view", onView1);
|
||||
|
||||
await createTemporaryPad(client, {}, async (padData) => {
|
||||
// Create View 1
|
||||
const onView2 = vi.fn();
|
||||
client2.on("view", onView2);
|
||||
|
||||
const view1 = {
|
||||
|
||||
const view = {
|
||||
name: "Test view 1",
|
||||
left: -10,
|
||||
right: 10,
|
||||
|
@ -24,27 +26,160 @@ test("Database views", async () => {
|
|||
layers: []
|
||||
} satisfies View<CRU.CREATE>;
|
||||
|
||||
const view1Result = await client.addView(view1);
|
||||
const viewResult = await client1.addView(view);
|
||||
|
||||
const expectedView1: View = {
|
||||
...view1,
|
||||
const expectedView: View = {
|
||||
...view,
|
||||
idx: 0,
|
||||
filter: null,
|
||||
id: view1Result.id,
|
||||
id: viewResult.id,
|
||||
padId: padData.id
|
||||
};
|
||||
|
||||
expect(view1Result).toEqual(expectedView1);
|
||||
expect(onView).toBeCalledTimes(1);
|
||||
expect(onView).toHaveBeenNthCalledWith(1, expectedView1);
|
||||
expect(client.views).toEqual({
|
||||
[expectedView1.id]: expectedView1
|
||||
expect(viewResult).toEqual(expectedView);
|
||||
|
||||
await retry(async () => {
|
||||
expect(onView1).toBeCalledTimes(1);
|
||||
expect(onView2).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(onView1).toHaveBeenNthCalledWith(1, expectedView);
|
||||
expect(client1.views).toEqual({
|
||||
[expectedView.id]: expectedView
|
||||
});
|
||||
|
||||
// Create view 2
|
||||
expect(onView2).toHaveBeenNthCalledWith(1, expectedView);
|
||||
expect(client2.views).toEqual({
|
||||
[expectedView.id]: expectedView
|
||||
});
|
||||
|
||||
const view2 = {
|
||||
const client3 = await openClient(padData.id);
|
||||
expect(client3.views).toEqual({
|
||||
[expectedView.id]: expectedView
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Create view (custom values)", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
const onView = vi.fn();
|
||||
client.on("view", onView);
|
||||
|
||||
await createTemporaryPad(client, {}, async (padData) => {
|
||||
const view = {
|
||||
name: "Test view 1",
|
||||
left: -10,
|
||||
right: 10,
|
||||
top: -20,
|
||||
bottom: 20,
|
||||
baseLayer: "Mpnk",
|
||||
layers: ["grid"],
|
||||
idx: 2,
|
||||
filter: "name == 'Test'"
|
||||
} satisfies View<CRU.CREATE>;
|
||||
|
||||
const viewResult = await client.addView(view);
|
||||
|
||||
const expectedView: View = {
|
||||
...view,
|
||||
id: viewResult.id,
|
||||
padId: padData.id
|
||||
};
|
||||
|
||||
expect(viewResult).toEqual(expectedView);
|
||||
expect(onView).toBeCalledTimes(1);
|
||||
expect(onView).toHaveBeenNthCalledWith(1, expectedView);
|
||||
expect(cloneDeep(client.views)).toEqual({
|
||||
[expectedView.id]: expectedView
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Update view", async () => {
|
||||
const client1 = await openClient();
|
||||
|
||||
await createTemporaryPad(client1, {}, async (padData) => {
|
||||
const createdView = await client1.addView({
|
||||
name: "Test view 1",
|
||||
left: -10,
|
||||
right: 10,
|
||||
top: -20,
|
||||
bottom: 20,
|
||||
baseLayer: "Mpnk",
|
||||
layers: []
|
||||
});
|
||||
|
||||
const client2 = await openClient(padData.id);
|
||||
|
||||
const onView1 = vi.fn();
|
||||
client1.on("view", onView1);
|
||||
|
||||
const onView2 = vi.fn();
|
||||
client2.on("view", onView2);
|
||||
|
||||
const update = {
|
||||
id: createdView.id,
|
||||
name: "Test view 2",
|
||||
left: -20,
|
||||
right: 20,
|
||||
top: -40,
|
||||
bottom: 40,
|
||||
baseLayer: "Lima",
|
||||
layers: ["grid"],
|
||||
idx: 2,
|
||||
filter: "name == 'Test'"
|
||||
} satisfies View<CRU.UPDATE>;
|
||||
const view = await client1.editView(update);
|
||||
|
||||
const expectedView: View = {
|
||||
...update,
|
||||
padId: padData.id
|
||||
};
|
||||
|
||||
expect(view).toEqual(expectedView);
|
||||
|
||||
await retry(async () => {
|
||||
expect(onView1).toBeCalledTimes(1);
|
||||
expect(onView2).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(onView1).toHaveBeenNthCalledWith(1, expectedView);
|
||||
expect(onView2).toHaveBeenNthCalledWith(1, expectedView);
|
||||
expect(cloneDeep(client1.views)).toEqual({
|
||||
[expectedView.id]: expectedView
|
||||
});
|
||||
expect(cloneDeep(client2.views)).toEqual({
|
||||
[expectedView.id]: expectedView
|
||||
});
|
||||
|
||||
const client3 = await openClient(padData.id);
|
||||
expect(client3.views).toEqual({
|
||||
[expectedView.id]: expectedView
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Set default view", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
const onPadData = vi.fn();
|
||||
client.on("padData", onPadData);
|
||||
|
||||
await createTemporaryPad(client, {}, async (padData) => {
|
||||
await client.addView({
|
||||
name: "Test view 1",
|
||||
left: -10,
|
||||
right: 10,
|
||||
top: -20,
|
||||
bottom: 20,
|
||||
baseLayer: "Mpnk",
|
||||
layers: []
|
||||
});
|
||||
|
||||
const view2 = await client.addView({
|
||||
name: "Test view 2",
|
||||
idx: 1,
|
||||
left: -30,
|
||||
right: 30,
|
||||
top: -40,
|
||||
|
@ -52,30 +187,91 @@ test("Database views", async () => {
|
|||
baseLayer: "ToPl",
|
||||
layers: ["grid"],
|
||||
filter: "name == \"\""
|
||||
} satisfies View<CRU.CREATE>;
|
||||
|
||||
const view2Result = await client.addView(view2);
|
||||
|
||||
const expectedView2: View = {
|
||||
...view2,
|
||||
id: view2Result.id,
|
||||
padId: padData.id
|
||||
};
|
||||
|
||||
expect(view2Result).toEqual(expectedView2);
|
||||
expect(onView).toBeCalledTimes(2);
|
||||
expect(onView).toHaveBeenNthCalledWith(2, expectedView2);
|
||||
expect(client.views).toEqual({
|
||||
[expectedView1.id]: expectedView1,
|
||||
[expectedView2.id]: expectedView2
|
||||
});
|
||||
|
||||
|
||||
// Set view 2 as default view
|
||||
const padResult = await client.editPad({
|
||||
defaultViewId: expectedView2.id
|
||||
defaultViewId: view2.id
|
||||
});
|
||||
expect(padResult.defaultView).toEqual(expectedView2);
|
||||
expect(onPadData.mock.lastCall[0].defaultView).toEqual(expectedView2);
|
||||
expect(padResult.defaultView).toEqual(view2);
|
||||
expect(onPadData.mock.lastCall[0].defaultView).toEqual(view2);
|
||||
});
|
||||
});
|
||||
|
||||
test("Delete view", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, {}, async (padData) => {
|
||||
const view = await client.addView({
|
||||
name: "Test view 1",
|
||||
left: -10,
|
||||
right: 10,
|
||||
top: -20,
|
||||
bottom: 20,
|
||||
baseLayer: "Mpnk",
|
||||
layers: []
|
||||
});
|
||||
|
||||
const onDeleteView = vi.fn();
|
||||
client.on("deleteView", onDeleteView);
|
||||
|
||||
const deletedView = await client.deleteView({ id: view.id });
|
||||
|
||||
expect(deletedView).toEqual(view);
|
||||
expect(onDeleteView).toBeCalledTimes(1);
|
||||
expect(onDeleteView).toHaveBeenNthCalledWith(1, { id: view.id });
|
||||
expect(client.views).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
test("Reorder views", async () => {
|
||||
const client = await openClient();
|
||||
|
||||
await createTemporaryPad(client, {}, async (padData) => {
|
||||
const viewSettings = {
|
||||
name: "Test view",
|
||||
left: -10,
|
||||
right: 10,
|
||||
top: -20,
|
||||
bottom: 20,
|
||||
baseLayer: "Mpnk",
|
||||
layers: []
|
||||
};
|
||||
|
||||
const view1 = await client.addView({
|
||||
...viewSettings
|
||||
});
|
||||
|
||||
expect(view1.idx).toEqual(0);
|
||||
|
||||
const view2 = await client.addView({
|
||||
...viewSettings,
|
||||
idx: 3
|
||||
});
|
||||
|
||||
expect(view2.idx).toEqual(3);
|
||||
|
||||
const view3 = await client.addView({
|
||||
...viewSettings,
|
||||
idx: 0 // Should move view1 down, but not view2 (since there is a gap)
|
||||
});
|
||||
expect(view3.idx).toEqual(0);
|
||||
expect(client.views[view1.id].idx).toEqual(1);
|
||||
expect(client.views[view2.id].idx).toEqual(3);
|
||||
|
||||
const updatedView1 = await client.editView({
|
||||
id: view1.id,
|
||||
idx: 0 // Should move view3 down, but not view2 (since there is a gap)
|
||||
});
|
||||
expect(updatedView1.idx).toEqual(0);
|
||||
expect(client.views[view2.id].idx).toEqual(3);
|
||||
expect(client.views[view3.id].idx).toEqual(1);
|
||||
|
||||
const newUpdatedView1 = await client.editView({
|
||||
id: view1.id,
|
||||
idx: 3 // Should move view2 down but leave view3 untouched
|
||||
});
|
||||
expect(newUpdatedView1.idx).toEqual(3);
|
||||
expect(client.views[view2.id].idx).toEqual(4);
|
||||
expect(client.views[view3.id].idx).toEqual(1);
|
||||
});
|
||||
});
|
|
@ -25,5 +25,8 @@ export default defineConfig({
|
|||
&& !["facilmap-types", "facilmap-utils"].includes(id)
|
||||
)
|
||||
}
|
||||
},
|
||||
test: {
|
||||
testTimeout: 20_000
|
||||
}
|
||||
});
|
||||
|
|
|
@ -26,12 +26,13 @@ class TransparentLayer extends Layer {
|
|||
}
|
||||
}
|
||||
|
||||
export type ClickListener = (point: Point) => void;
|
||||
export type ClickListener = (point?: Point) => void;
|
||||
export type MoveListener = (point: Point) => void;
|
||||
export interface ClickListenerHandle {
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export function addClickListener(map: Map, listener: ClickListener, moveListener?: ClickListener): ClickListenerHandle {
|
||||
export function addClickListener(map: Map, listener: ClickListener, moveListener?: MoveListener): ClickListenerHandle {
|
||||
map.fire('fmInteractionStart');
|
||||
|
||||
const transparentLayer = new TransparentLayer().addTo(map);
|
||||
|
@ -41,26 +42,41 @@ export function addClickListener(map: Map, listener: ClickListener, moveListener
|
|||
};
|
||||
|
||||
const handleClick = (e: LeafletMouseEvent) => {
|
||||
cancel();
|
||||
|
||||
e.originalEvent.preventDefault();
|
||||
DomEvent.stopPropagation(e);
|
||||
listener({ lat: e.latlng.lat, lon: e.latlng.lng });
|
||||
finish({ lat: e.latlng.lat, lon: e.latlng.lng });
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "Escape") {
|
||||
e.preventDefault();
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
const finish = (point?: Point) => {
|
||||
map.fire('fmInteractionEnd');
|
||||
|
||||
transparentLayer.removeFrom(map).off("click", handleClick);
|
||||
|
||||
if(moveListener)
|
||||
transparentLayer.off("mousemove", handleMove);
|
||||
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
|
||||
listener(point);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
transparentLayer.addTo(map).on("click", handleClick);
|
||||
|
||||
if(moveListener)
|
||||
transparentLayer.on("mousemove", handleMove);
|
||||
|
||||
return { cancel };
|
||||
return {
|
||||
cancel: () => {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import type { ID, Line, LinePointsEvent, ObjectWithId, Point, Stroke, Type, Width } from "facilmap-types";
|
||||
import type { ID, Line, LinePointsEvent, LineTemplate, ObjectWithId, Point, Stroke, Type, Width } from "facilmap-types";
|
||||
import { FeatureGroup, latLng, type LayerOptions, type Map as LeafletMap, type PolylineOptions, type LatLngBounds } from "leaflet";
|
||||
import { type HighlightableLayerOptions, HighlightablePolyline } from "leaflet-highlightable-layers";
|
||||
import { type BasicTrackPoints, disconnectSegmentsOutsideViewport, tooltipOptions, trackPointsToLatLngArray, fmToLeafletBbox } from "../utils/leaflet";
|
||||
|
@ -185,12 +185,23 @@ export default class LinesLayer extends FeatureGroup {
|
|||
|
||||
protected _endDrawLine?: (save: boolean) => void;
|
||||
|
||||
drawLine(lineTemplate: Line): Promise<Point[] | undefined> {
|
||||
drawLine(lineTemplate: LineTemplate): Promise<Point[] | undefined> {
|
||||
return new Promise<Point[] | undefined>((resolve) => {
|
||||
const line: Line & { trackPoints: BasicTrackPoints } = {
|
||||
id: -1,
|
||||
padId: "",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
distance: 0,
|
||||
time: null,
|
||||
ascent: null,
|
||||
descent: null,
|
||||
...lineTemplate,
|
||||
routePoints: [],
|
||||
trackPoints: []
|
||||
trackPoints: [],
|
||||
extraInfo: {}
|
||||
};
|
||||
line.trackPoints = line.routePoints;
|
||||
|
||||
|
@ -206,24 +217,45 @@ export default class LinesLayer extends FeatureGroup {
|
|||
handler = addClickListener(this._map, handleClick, handleMouseMove);
|
||||
};
|
||||
|
||||
const handleClick = (pos: Point) => {
|
||||
const handleClick = (pos?: Point) => {
|
||||
if (isFinishing) {
|
||||
// Called by handler.cancel()
|
||||
return;
|
||||
}
|
||||
|
||||
handler = undefined;
|
||||
if(routePoints.length > 0 && pos.lon == routePoints[routePoints.length-1].lon && pos.lat == routePoints[routePoints.length-1].lat)
|
||||
void finishLine(true);
|
||||
else
|
||||
addPoint(pos);
|
||||
}
|
||||
if (pos) {
|
||||
if(routePoints.length > 0 && pos.lon == routePoints[routePoints.length-1].lon && pos.lat == routePoints[routePoints.length-1].lat)
|
||||
void finishLine(true);
|
||||
else
|
||||
addPoint(pos);
|
||||
} else {
|
||||
void finishLine(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (pos: Point) => {
|
||||
if(line.routePoints!.length > 0) {
|
||||
line.routePoints![line.routePoints!.length-1] = pos;
|
||||
this._addLine(line);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "Enter") {
|
||||
e.preventDefault();
|
||||
void finishLine(true);
|
||||
}
|
||||
};
|
||||
|
||||
let isFinishing = false;
|
||||
|
||||
const finishLine = async (save: boolean) => {
|
||||
isFinishing = true;
|
||||
if (handler)
|
||||
handler.cancel();
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
|
||||
this._deleteLine(line);
|
||||
|
||||
delete this._endDrawLine;
|
||||
|
@ -232,9 +264,10 @@ export default class LinesLayer extends FeatureGroup {
|
|||
resolve(routePoints);
|
||||
else
|
||||
resolve(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
handler = addClickListener(this._map, handleClick, handleMouseMove)
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
handler = addClickListener(this._map, handleClick, handleMouseMove);
|
||||
|
||||
this._endDrawLine = finishLine;
|
||||
});
|
||||
|
|
|
@ -144,6 +144,7 @@ export const overpassPresets: OverpassPresetCategory[] = [
|
|||
{ key: "cosmeticsshop", query: "(node[shop=cosmetics];way[shop=cosmetics];rel[shop=cosmetics];)", label: "Cosmetics" },
|
||||
{ key: "departmentstore", query: "(node[shop=department_store];way[shop=department_store];rel[shop=department_store];)", label: "Department store" },
|
||||
{ key: "diyshop", query: "(node[shop~'doityourself|hardware'];way[shop~'doityourself|hardware'];rel[shop~'doityourself|hardware'];)", label: "DIY/hardware" },
|
||||
{ key: "florist", query: "(nwr[shop=florist];nwr[shop=garden_centre];)", label: "Florist" },
|
||||
{ key: "gardencentre", query: "(node[shop=garden_centre];way[shop=garden_centre];rel[shop=garden_centre];)", label: "Garden centre" },
|
||||
{ key: "generalshop", query: "(node[shop=general];way[shop=general];rel[shop=general];)", label: "General" },
|
||||
{ key: "giftshop", query: "(node[shop=gift];way[shop=gift];rel[shop=gift];)", label: "Gift" },
|
||||
|
|
|
@ -58,7 +58,6 @@
|
|||
"maxmind": "^4.3.18",
|
||||
"md5-file": "^5.0.0",
|
||||
"mysql2": "^3.9.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"p-throttle": "^6.1.0",
|
||||
"pg": "^8.11.3",
|
||||
"sequelize": "^6.37.1",
|
||||
|
@ -78,7 +77,6 @@
|
|||
"@types/geojson": "^7946.0.14",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^20.11.25",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/string-similarity": "^4.0.2",
|
||||
"cpy-cli": "^5.0.0",
|
||||
"debug": "^4.3.4",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type { Options as SequelizeOptions } from "sequelize";
|
||||
import "dotenv/config";
|
||||
import { setConfig, setFetchAdapter } from "facilmap-utils";
|
||||
|
||||
export interface DbConfig {
|
||||
type: SequelizeOptions['dialect'];
|
||||
|
@ -28,6 +29,8 @@ export interface Config {
|
|||
customCssFile?: string;
|
||||
nominatimUrl: string;
|
||||
openElevationApiUrl: string;
|
||||
openElevationThrottleMs: number;
|
||||
openElevationMaxBatchSize: number;
|
||||
}
|
||||
|
||||
const config: Config = {
|
||||
|
@ -65,7 +68,23 @@ const config: Config = {
|
|||
customCssFile: process.env.CUSTOM_CSS_FILE || undefined,
|
||||
|
||||
nominatimUrl: process.env.NOMINATIM_URL || "https://nominatim.openstreetmap.org",
|
||||
|
||||
openElevationApiUrl: process.env.OPEN_ELEVATION_URL || "https://api.open-elevation.com",
|
||||
openElevationThrottleMs: process.env.OPEN_ELEVATION_THROTTLE_MS ? Number(process.env.OPEN_ELEVATION_THROTTLE_MS) : 1000, // Maximum one request per second, see https://github.com/Jorl17/open-elevation/issues/3
|
||||
openElevationMaxBatchSize: process.env.OPEN_ELEVATION_MAX_BATCH_SIZE ? Number(process.env.OPEN_ELEVATION_MAX_BATCH_SIZE) : 200,
|
||||
};
|
||||
|
||||
setConfig({
|
||||
openElevationApiUrl: config.openElevationApiUrl,
|
||||
openElevationThrottleMs: config.openElevationThrottleMs,
|
||||
openElevationMaxBatchSize: config.openElevationMaxBatchSize,
|
||||
nominatimUrl: config.nominatimUrl
|
||||
});
|
||||
|
||||
setFetchAdapter(async (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("User-Agent", config.userAgent);
|
||||
return await fetch(input, { ...init, headers });
|
||||
});
|
||||
|
||||
export default config;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { type CreationAttributes, type CreationOptional, DataTypes, type ForeignKey, type HasManyGetAssociationsMixin, type InferAttributes, type InferCreationAttributes, Model, Op } from "sequelize";
|
||||
import type { BboxWithZoom, ID, Latitude, Line, ExtraInfo, Longitude, PadId, Point, Route, TrackPoint, CRU, RouteInfo, Stroke, Colour, RouteMode, Width, Type } from "facilmap-types";
|
||||
import type { BboxWithZoom, ID, Latitude, Line, ExtraInfo, Longitude, PadId, Point, Route, TrackPoint, CRU, RouteInfo, Stroke, Colour, RouteMode, Width, Type, LineTemplate } from "facilmap-types";
|
||||
import Database from "./database.js";
|
||||
import { type BboxWithExcept, createModel, dataDefinition, type DataModel, getDefaultIdType, getLatType, getLonType, getPosType, getVirtualLatType, getVirtualLonType, makeNotNullForeignKey } from "./helpers.js";
|
||||
import { chunk, groupBy, isEqual, mapValues, omit } from "lodash-es";
|
||||
|
@ -7,7 +7,7 @@ import { calculateRouteForLine } from "../routing/routing.js";
|
|||
import type { PadModel } from "./pad";
|
||||
import type { Point as GeoJsonPoint } from "geojson";
|
||||
import type { TypeModel } from "./type";
|
||||
import { resolveCreateLine, resolveUpdateLine } from "facilmap-utils";
|
||||
import { getLineTemplate, resolveCreateLine, resolveUpdateLine } from "facilmap-utils";
|
||||
|
||||
export type LineWithTrackPoints = Line & {
|
||||
trackPoints: TrackPoint[];
|
||||
|
@ -193,26 +193,10 @@ export default class DatabaseLines {
|
|||
return this._db.helpers._getPadObjects<Line>("Line", padId, { where: { typeId: typeId } });
|
||||
}
|
||||
|
||||
async getLineTemplate(padId: PadId, data: { typeId: ID }): Promise<Line> {
|
||||
const lineTemplate = {
|
||||
...this.LineModel.build({ ...data, padId: padId } satisfies Partial<CreationAttributes<LineModel>> as any).toJSON(),
|
||||
data: { }
|
||||
} as Line;
|
||||
|
||||
async getLineTemplate(padId: PadId, data: { typeId: ID }): Promise<LineTemplate> {
|
||||
const type = await this._db.types.getType(padId, data.typeId);
|
||||
|
||||
if(type.defaultColour)
|
||||
lineTemplate.colour = type.defaultColour;
|
||||
if(type.defaultWidth)
|
||||
lineTemplate.width = type.defaultWidth;
|
||||
if(type.defaultStroke != null)
|
||||
lineTemplate.stroke = type.defaultStroke;
|
||||
if(type.defaultMode)
|
||||
lineTemplate.mode = type.defaultMode;
|
||||
|
||||
await this._db.helpers._updateObjectStyles(lineTemplate);
|
||||
|
||||
return lineTemplate;
|
||||
return getLineTemplate(type);
|
||||
}
|
||||
|
||||
getLine(padId: PadId, lineId: ID): Promise<Line> {
|
||||
|
@ -250,27 +234,27 @@ export default class DatabaseLines {
|
|||
throw new Error(`Cannot use ${newType.type} type for line.`);
|
||||
}
|
||||
|
||||
const update = {
|
||||
...resolveUpdateLine(originalLine, data, newType),
|
||||
routePoints: data.routePoints || originalLine.routePoints,
|
||||
mode: (data.mode ?? originalLine.mode) || ""
|
||||
};
|
||||
const update = resolveUpdateLine(originalLine, data, newType);
|
||||
|
||||
let routeInfo: RouteInfo | undefined;
|
||||
if((update.mode == "track" && update.trackPoints) || !isEqual(update.routePoints, originalLine.routePoints) || update.mode != originalLine.mode)
|
||||
routeInfo = await calculateRouteForLine(update, trackPointsFromRoute);
|
||||
if((update.mode == "track" && update.trackPoints) || (update.routePoints && !isEqual(update.routePoints, originalLine.routePoints)) || (update.mode != null && update.mode != originalLine.mode))
|
||||
routeInfo = await calculateRouteForLine({ ...originalLine, ...update }, trackPointsFromRoute);
|
||||
|
||||
Object.assign(update, mapValues(routeInfo, (val) => val == null ? null : val)); // Use null instead of undefined
|
||||
delete update.trackPoints; // They came if mode is track
|
||||
|
||||
const newLine = await this._db.helpers._updatePadObject<Line>("Line", originalLine.padId, originalLine.id, update, noHistory);
|
||||
if (Object.keys(update).length > 0) {
|
||||
const newLine = await this._db.helpers._updatePadObject<Line>("Line", originalLine.padId, originalLine.id, update, noHistory);
|
||||
|
||||
this._db.emit("line", originalLine.padId, newLine);
|
||||
this._db.emit("line", originalLine.padId, newLine);
|
||||
|
||||
if(routeInfo)
|
||||
await this._setLinePoints(originalLine.padId, originalLine.id, routeInfo.trackPoints);
|
||||
if(routeInfo)
|
||||
await this._setLinePoints(originalLine.padId, originalLine.id, routeInfo.trackPoints);
|
||||
|
||||
return newLine;
|
||||
return newLine;
|
||||
} else {
|
||||
return originalLine;
|
||||
}
|
||||
}
|
||||
|
||||
async _setLinePoints(padId: PadId, lineId: ID, trackPoints: Point[], _noEvent?: boolean): Promise<void> {
|
||||
|
|
|
@ -2,11 +2,10 @@ import { type CreationOptional, DataTypes, type ForeignKey, type InferAttributes
|
|||
import type { BboxWithZoom, CRU, Colour, ID, Latitude, Longitude, Marker, PadId, Shape, Size, Symbol, Type } from "facilmap-types";
|
||||
import { type BboxWithExcept, createModel, dataDefinition, type DataModel, getDefaultIdType, getPosType, getVirtualLatType, getVirtualLonType, makeNotNullForeignKey } from "./helpers.js";
|
||||
import Database from "./database.js";
|
||||
import { getElevationForPoint } from "../elevation.js";
|
||||
import { getElevationForPoint, resolveCreateMarker, resolveUpdateMarker } from "facilmap-utils";
|
||||
import type { PadModel } from "./pad.js";
|
||||
import type { Point as GeoJsonPoint } from "geojson";
|
||||
import type { TypeModel } from "./type.js";
|
||||
import { resolveCreateMarker, resolveUpdateMarker } from "facilmap-utils";
|
||||
|
||||
export interface MarkerModel extends Model<InferAttributes<MarkerModel>, InferCreationAttributes<MarkerModel>> {
|
||||
id: CreationOptional<ID>;
|
||||
|
@ -95,13 +94,19 @@ export default class DatabaseMarkers {
|
|||
throw new Error(`Cannot use ${type.type} type for marker.`);
|
||||
}
|
||||
|
||||
const resolvedData = resolveCreateMarker(data, type);
|
||||
|
||||
const result = await this._db.helpers._createPadObject<Marker>("Marker", padId, {
|
||||
...resolvedData,
|
||||
ele: data.ele === undefined ? await getElevationForPoint(data) : data.ele
|
||||
});
|
||||
const result = await this._db.helpers._createPadObject<Marker>("Marker", padId, resolveCreateMarker(data, type));
|
||||
this._db.emit("marker", padId, result);
|
||||
|
||||
if (data.ele === undefined) {
|
||||
getElevationForPoint(data).then(async (ele) => {
|
||||
if (ele != null) {
|
||||
await this.updateMarker(padId, result.id, { ele }, true);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn("Error updating marker elevation", err);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
@ -118,14 +123,25 @@ export default class DatabaseMarkers {
|
|||
|
||||
const update = resolveUpdateMarker(originalMarker, data, newType);
|
||||
|
||||
if (update.lat != null && update.lon != null && update.ele === undefined)
|
||||
update.ele = await getElevationForPoint({ lat: update.lat, lon: update.lon });
|
||||
if (Object.keys(update).length > 0) {
|
||||
const result = await this._db.helpers._updatePadObject<Marker>("Marker", originalMarker.padId, originalMarker.id, update, noHistory);
|
||||
|
||||
const result = await this._db.helpers._updatePadObject<Marker>("Marker", originalMarker.padId, originalMarker.id, update, noHistory);
|
||||
this._db.emit("marker", originalMarker.padId, result);
|
||||
|
||||
this._db.emit("marker", originalMarker.padId, result);
|
||||
if (update.lat != null && update.lon != null && update.ele === undefined) {
|
||||
getElevationForPoint({ lat: update.lat, lon: update.lon }).then(async (ele) => {
|
||||
if (ele != null) {
|
||||
await this.updateMarker(originalMarker.padId, originalMarker.id, { ele }, true);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn("Error updating marker elevation", err);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return result;
|
||||
} else {
|
||||
return originalMarker;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMarker(padId: PadId, markerId: ID): Promise<Marker> {
|
||||
|
|
|
@ -15,6 +15,8 @@ export interface MetaProperties {
|
|||
untitledMigrationCompleted: "1";
|
||||
fieldsNullMigrationCompleted: "1";
|
||||
extraInfoNullMigrationCompleted: "1";
|
||||
typesIdxMigrationCompleted: "1";
|
||||
viewsIdxMigrationCompleted: "1";
|
||||
}
|
||||
|
||||
export default class DatabaseMeta {
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import { generateRandomId, promiseProps } from "../utils/utils.js";
|
||||
import { type CreationAttributes, DataTypes, Op, Utils, col, fn } from "sequelize";
|
||||
import { DataTypes, Op, Utils, col, fn } from "sequelize";
|
||||
import { cloneDeep, isEqual } from "lodash-es";
|
||||
import Database from "./database.js";
|
||||
import type { PadModel } from "./pad.js";
|
||||
import type { LineModel, LinePointModel } from "./line.js";
|
||||
import { getElevationForPoints } from "../elevation.js";
|
||||
import type { LinePointModel } from "./line.js";
|
||||
import { getElevationForPoint } from "facilmap-utils";
|
||||
import type { MarkerModel } from "./marker.js";
|
||||
import { ReadableStream } from "stream/web";
|
||||
import type { PadId } from "facilmap-types";
|
||||
|
||||
export default class DatabaseMigrations {
|
||||
|
||||
|
@ -19,15 +22,23 @@ export default class DatabaseMigrations {
|
|||
await this._changeColMigrations();
|
||||
await this._addColMigrations();
|
||||
await this._dropdownKeyMigration();
|
||||
await this._elevationMigration();
|
||||
await this._legendMigration();
|
||||
await this._bboxMigration();
|
||||
await this._spatialMigration();
|
||||
await this._untitledMigration();
|
||||
await this._fieldsNullMigration();
|
||||
await this._extraInfoNullMigration();
|
||||
}
|
||||
await this._typesIdxMigration();
|
||||
await this._viewsIdxMigration();
|
||||
|
||||
(async () => {
|
||||
await this._elevationMigration();
|
||||
})().catch((err) => {
|
||||
console.error("DB migration: Unexpected error in background migration", err);
|
||||
}).finally(() => {
|
||||
console.log("DB migration: All migrations finished");
|
||||
});
|
||||
}
|
||||
|
||||
/** Run any migrations that rename columns */
|
||||
async _renameColMigrations(): Promise<void> {
|
||||
|
@ -37,18 +48,22 @@ export default class DatabaseMigrations {
|
|||
|
||||
// Rename Line.points to Line.routePoints
|
||||
if(lineAttrs.points) {
|
||||
console.log("DB migration: Rename Lines.points to Lines.routePoints");
|
||||
await queryInterface.renameColumn('Lines', 'points', 'routePoints');
|
||||
}
|
||||
|
||||
// Change routing type "shortest" / "fastest" to "car", add type "track"
|
||||
if(lineAttrs.mode.type.indexOf("shortest") != -1)
|
||||
if(lineAttrs.mode.type.indexOf("shortest") != -1) {
|
||||
console.log("DB migration: Change \"shortest\"/\"fastest\" route mode to \"car\"");
|
||||
await this._db.lines.LineModel.update({ mode: "car" }, { where: { mode: { [Op.in]: [ "fastest", "shortest" ] } } });
|
||||
}
|
||||
|
||||
|
||||
const padAttrs = await queryInterface.describeTable('Pads');
|
||||
|
||||
// Rename writeId to adminId
|
||||
if(!padAttrs.adminId) {
|
||||
console.log("DB migration: Rename pad writeId to adminId");
|
||||
const Pad = this._db.pads.PadModel;
|
||||
await queryInterface.renameColumn('Pads', 'writeId', 'adminId');
|
||||
await queryInterface.addColumn('Pads', 'writeId', Pad.rawAttributes.writeId);
|
||||
|
@ -78,22 +93,26 @@ export default class DatabaseMigrations {
|
|||
|
||||
// Forbid null pad name
|
||||
if (padsAttributes.name.allowNull) {
|
||||
console.log("DB migration: Change null pad names to \"\"");
|
||||
await this._db.pads.PadModel.update({ name: "" }, { where: { name: null as any } });
|
||||
await queryInterface.changeColumn("Pads", "name", this._db.pads.PadModel.getAttributes().name);
|
||||
}
|
||||
|
||||
// Change description type from STRING to TEXT
|
||||
if (padsAttributes.description.type !== "TEXT") {
|
||||
console.log("DB migration: Change Pads.description from STRING to TEXT");
|
||||
await queryInterface.changeColumn("Pads", "description", this._db.pads.PadModel.getAttributes().description);
|
||||
}
|
||||
|
||||
// Change legend1 type from STRING to TEXT
|
||||
if (padsAttributes.legend1.type !== "TEXT") {
|
||||
console.log("DB migration: Change Pads.legend1 from STRING to TEXT");
|
||||
await queryInterface.changeColumn("Pads", "legend1", this._db.pads.PadModel.getAttributes().legend1);
|
||||
}
|
||||
|
||||
// Change legend2 type from STRING to TEXT
|
||||
if (padsAttributes.legend2.type !== "TEXT") {
|
||||
console.log("DB migration: Change Pads.legend2 from STRING to TEXT");
|
||||
await queryInterface.changeColumn("Pads", "legend2", this._db.pads.PadModel.getAttributes().legend2);
|
||||
}
|
||||
|
||||
|
@ -106,28 +125,33 @@ export default class DatabaseMigrations {
|
|||
|
||||
// Forbid null marker name
|
||||
if (markersAttributes.name.allowNull) {
|
||||
console.log("DB migration: Change null marker names to \"\"");
|
||||
await this._db.markers.MarkerModel.update({ name: "" }, { where: { name: null as any } });
|
||||
await queryInterface.changeColumn("Markers", "name", this._db.markers.MarkerModel.getAttributes().name);
|
||||
}
|
||||
|
||||
// Remove marker colour default value
|
||||
if (markersAttributes.colour.defaultValue) {
|
||||
console.log("DB migration: Remove defaultValue from Markers.colour");
|
||||
await queryInterface.changeColumn("Markers", "colour", this._db.markers.MarkerModel.getAttributes().colour);
|
||||
}
|
||||
|
||||
// Remove marker size default value
|
||||
if (markersAttributes.size.defaultValue) {
|
||||
console.log("DB migration: Remove defaultValue from Markers.size");
|
||||
await queryInterface.changeColumn("Markers", "size", this._db.markers.MarkerModel.getAttributes().size);
|
||||
}
|
||||
|
||||
// Forbid null marker symbol
|
||||
if (markersAttributes.symbol.allowNull) {
|
||||
console.log("DB migration: Remove defaultValue from Markers.symbol");
|
||||
await this._db.markers.MarkerModel.update({ symbol: "" }, { where: { symbol: null as any } });
|
||||
await queryInterface.changeColumn("Markers", "symbol", this._db.markers.MarkerModel.getAttributes().symbol);
|
||||
}
|
||||
|
||||
// Forbid null marker shape
|
||||
if (markersAttributes.shape.allowNull) {
|
||||
console.log("DB migration: Remove defaultValue from Markers.shape");
|
||||
await this._db.markers.MarkerModel.update({ shape: "" }, { where: { shape: null as any } });
|
||||
await queryInterface.changeColumn("Markers", "shape", this._db.markers.MarkerModel.getAttributes().shape);
|
||||
}
|
||||
|
@ -141,6 +165,7 @@ export default class DatabaseMigrations {
|
|||
|
||||
// Forbid null line name
|
||||
if (linesAttributes.name.allowNull) {
|
||||
console.log("DB migration: Change null line names to \"\"");
|
||||
await this._db.lines.LineModel.update({ name: "" }, { where: { name: null as any } });
|
||||
await queryInterface.changeColumn("Lines", "name", this._db.lines.LineModel.getAttributes().name);
|
||||
}
|
||||
|
@ -148,16 +173,19 @@ export default class DatabaseMigrations {
|
|||
// Change line mode field from ENUM to TEXT
|
||||
// Remove line mode default value
|
||||
if (linesAttributes.mode.type != "TEXT" || linesAttributes.mode.defaultValue) {
|
||||
console.log("DB migration: Change Lines.mode from ENUM to TEXT");
|
||||
await queryInterface.changeColumn("Lines", "mode", this._db.lines.LineModel.getAttributes().mode);
|
||||
}
|
||||
|
||||
// Remove line width default value
|
||||
if (linesAttributes.width.defaultValue) {
|
||||
console.log("DB migration: Remove defaultValue from Lines.width");
|
||||
await queryInterface.changeColumn("Lines", "width", this._db.lines.LineModel.getAttributes().width);
|
||||
}
|
||||
|
||||
// Remove line colour default value
|
||||
if (linesAttributes.colour.defaultValue) {
|
||||
console.log("DB migration: Remove defaultValue from Lines.colour");
|
||||
await queryInterface.changeColumn("Lines", "colour", this._db.lines.LineModel.getAttributes().colour);
|
||||
}
|
||||
|
||||
|
@ -170,6 +198,7 @@ export default class DatabaseMigrations {
|
|||
|
||||
// Forbid null defaultColour
|
||||
if (typesAttributes.defaultColour.allowNull) {
|
||||
console.log("DB migration: Set default colour for types");
|
||||
await this._db.types.TypeModel.update({ defaultColour: "ff0000" }, {
|
||||
where: {
|
||||
defaultColour: null as any,
|
||||
|
@ -187,12 +216,14 @@ export default class DatabaseMigrations {
|
|||
|
||||
// Forbid null colourFixed
|
||||
if (typesAttributes.colourFixed.allowNull) {
|
||||
console.log("DB migration: Disallow null for Types.colourFixed");
|
||||
await this._db.types.TypeModel.update({ colourFixed: false }, { where: { colourFixed: null as any } });
|
||||
await queryInterface.changeColumn("Types", "colourFixed", this._db.types.TypeModel.getAttributes().colourFixed);
|
||||
}
|
||||
|
||||
// Forbid null defaultSize
|
||||
if (typesAttributes.defaultSize.allowNull) {
|
||||
console.log("DB migration: Disallow null for Types.defaultSize");
|
||||
// 25 is the old default size, now it is 30
|
||||
await this._db.types.TypeModel.update({ defaultSize: 25 }, { where: { defaultSize: null as any } });
|
||||
await queryInterface.changeColumn("Types", "defaultSize", this._db.types.TypeModel.getAttributes().defaultSize);
|
||||
|
@ -200,42 +231,49 @@ export default class DatabaseMigrations {
|
|||
|
||||
// Forbid null sizeFixed
|
||||
if (typesAttributes.sizeFixed.allowNull) {
|
||||
console.log("DB migration: Disallow null for Types.sizeFixed");
|
||||
await this._db.types.TypeModel.update({ sizeFixed: false }, { where: { sizeFixed: null as any } });
|
||||
await queryInterface.changeColumn("Types", "sizeFixed", this._db.types.TypeModel.getAttributes().sizeFixed);
|
||||
}
|
||||
|
||||
// Forbid null defaultSymbol
|
||||
if (typesAttributes.defaultSymbol.allowNull) {
|
||||
console.log("DB migration: Disallow null for Types.defaultSymbol");
|
||||
await this._db.types.TypeModel.update({ defaultSymbol: "" }, { where: { defaultSymbol: null as any } });
|
||||
await queryInterface.changeColumn("Types", "defaultSymbol", this._db.types.TypeModel.getAttributes().defaultSymbol);
|
||||
}
|
||||
|
||||
// Forbid null symbolFixed
|
||||
if (typesAttributes.symbolFixed.allowNull) {
|
||||
console.log("DB migration: Disallow null for Types.symbolFixed");
|
||||
await this._db.types.TypeModel.update({ symbolFixed: false }, { where: { symbolFixed: null as any } });
|
||||
await queryInterface.changeColumn("Types", "symbolFixed", this._db.types.TypeModel.getAttributes().symbolFixed);
|
||||
}
|
||||
|
||||
// Forbid null defaultShape
|
||||
if (typesAttributes.defaultShape.allowNull) {
|
||||
console.log("DB migration: Disallow null for Types.defaultShape");
|
||||
await this._db.types.TypeModel.update({ defaultShape: "" }, { where: { defaultShape: null as any } });
|
||||
await queryInterface.changeColumn("Types", "defaultShape", this._db.types.TypeModel.getAttributes().defaultShape);
|
||||
}
|
||||
|
||||
// Forbid null shapeFixed
|
||||
if (typesAttributes.shapeFixed.allowNull) {
|
||||
console.log("DB migration: Disallow null for Types.shapeFixed");
|
||||
await this._db.types.TypeModel.update({ shapeFixed: false }, { where: { shapeFixed: null as any } });
|
||||
await queryInterface.changeColumn("Types", "shapeFixed", this._db.types.TypeModel.getAttributes().shapeFixed);
|
||||
}
|
||||
|
||||
// Forbid null defaultWidth
|
||||
if (typesAttributes.defaultWidth.allowNull) {
|
||||
console.log("DB migration: Disallow null for Types.defaultWidth");
|
||||
await this._db.types.TypeModel.update({ defaultWidth: 4 }, { where: { defaultWidth: null as any } });
|
||||
await queryInterface.changeColumn("Types", "defaultWidth", this._db.types.TypeModel.getAttributes().defaultWidth);
|
||||
}
|
||||
|
||||
// Forbid null widthFixed
|
||||
if (typesAttributes.widthFixed.allowNull) {
|
||||
console.log("DB migration: Disallow null for Types.widthFixed");
|
||||
await this._db.types.TypeModel.update({ widthFixed: false }, { where: { widthFixed: null as any } });
|
||||
await queryInterface.changeColumn("Types", "widthFixed", this._db.types.TypeModel.getAttributes().widthFixed);
|
||||
}
|
||||
|
@ -244,19 +282,23 @@ export default class DatabaseMigrations {
|
|||
// Forbid null defaultMode
|
||||
if (typesAttributes.defaultMode.type != "TEXT" || typesAttributes.defaultMode.allowNull) {
|
||||
if (typesAttributes.defaultMode.allowNull) {
|
||||
console.log("DB migration: Disallow null for Types.defaultMode");
|
||||
await this._db.types.TypeModel.update({ defaultMode: "" }, { where: { defaultMode: null as any } });
|
||||
}
|
||||
console.log("DB migration: Change Types.defaultMode from ENUM to TEXT");
|
||||
await queryInterface.changeColumn("Types", "defaultMode", this._db.types.TypeModel.getAttributes().defaultMode);
|
||||
}
|
||||
|
||||
// Forbid null modeFixed
|
||||
if (typesAttributes.modeFixed.allowNull) {
|
||||
console.log("DB migration: Disallow null for Types.modeFixed");
|
||||
await this._db.types.TypeModel.update({ modeFixed: false }, { where: { modeFixed: null as any } });
|
||||
await queryInterface.changeColumn("Types", "modeFixed", this._db.types.TypeModel.getAttributes().modeFixed);
|
||||
}
|
||||
|
||||
// Forbid null showInLegend
|
||||
if (typesAttributes.showInLegend.allowNull) {
|
||||
console.log("DB migration: Disallow null for Types.showInLegend");
|
||||
await this._db.types.TypeModel.update({ showInLegend: false }, { where: { showInLegend: null as any } });
|
||||
await queryInterface.changeColumn("Types", "showInLegend", this._db.types.TypeModel.getAttributes().showInLegend);
|
||||
}
|
||||
|
@ -276,8 +318,10 @@ export default class DatabaseMigrations {
|
|||
const attributes = await queryInterface.describeTable(model.getTableName());
|
||||
const rawAttributes = model.getAttributes();
|
||||
for(const attribute in rawAttributes) {
|
||||
if((rawAttributes[attribute].type as any).key !== DataTypes.VIRTUAL.key && !attributes[attribute] && !exempt.some((e) => e[0] == table && e[1] == attribute))
|
||||
if((rawAttributes[attribute].type as any).key !== DataTypes.VIRTUAL.key && !attributes[attribute] && !exempt.some((e) => e[0] == table && e[1] == attribute)) {
|
||||
console.log(`DB migration: Add column ${model.getTableName() as string}.${attribute}`);
|
||||
await queryInterface.addColumn(model.getTableName(), attribute, rawAttributes[attribute]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -289,6 +333,8 @@ export default class DatabaseMigrations {
|
|||
if(dropdownKeysMigrated == "1")
|
||||
return;
|
||||
|
||||
console.log("DB migration: Change dropdown keys to dropdown values");
|
||||
|
||||
const types = await this._db.types.TypeModel.findAll();
|
||||
for(const type of types) {
|
||||
const newFields = type.fields; // type.fields is a getter, we cannot modify the object directly
|
||||
|
@ -303,7 +349,7 @@ export default class DatabaseMigrations {
|
|||
if(newVal)
|
||||
newData[dropdown.name] = newVal.value;
|
||||
else if(newData[dropdown.name])
|
||||
console.log(`Warning: Dropdown key ${newData[dropdown.name]} for field ${dropdown.name} of type ${type.name} of pad ${type.padId} does not exist.`);
|
||||
console.log(`DB migration: Warning: Dropdown key ${newData[dropdown.name]} for field ${dropdown.name} of type ${type.name} of pad ${type.padId} does not exist.`);
|
||||
}
|
||||
|
||||
if(!isEqual(newData, object.data))
|
||||
|
@ -316,7 +362,7 @@ export default class DatabaseMigrations {
|
|||
if(newDefault)
|
||||
dropdown.default = newDefault.value;
|
||||
else
|
||||
console.log(`Warning: Default dropdown key ${dropdown.default} for field ${dropdown.name} of type ${type.name} of pad ${type.padId} does not exist.`);
|
||||
console.log(`DB migration: Warning: Default dropdown key ${dropdown.default} for field ${dropdown.name} of type ${type.name} of pad ${type.padId} does not exist.`);
|
||||
}
|
||||
|
||||
dropdown.options?.forEach((option: any) => {
|
||||
|
@ -334,24 +380,50 @@ export default class DatabaseMigrations {
|
|||
|
||||
/* Get elevation data for all lines/markers that don't have any yet */
|
||||
async _elevationMigration(): Promise<void> {
|
||||
const hasElevation = await this._db.meta.getMeta("hasElevation");
|
||||
if(hasElevation == "1")
|
||||
return;
|
||||
try {
|
||||
const hasElevation = await this._db.meta.getMeta("hasElevation");
|
||||
if(hasElevation == "2")
|
||||
return;
|
||||
|
||||
const lines = await this._db.lines.LineModel.findAll();
|
||||
for(const line of lines) {
|
||||
const trackPoints = await this._db.lines.LineModel.build({ id: line.id } satisfies Partial<CreationAttributes<LineModel>> as any).getLinePoints();
|
||||
await this._db.lines._setLinePoints(line.padId, line.id, trackPoints, true);
|
||||
console.log("DB migration: Get marker elevations in background");
|
||||
|
||||
const markers = await this._db.markers.MarkerModel.findAll({ where: { ele: null } });
|
||||
|
||||
let anyError = false;
|
||||
const stream = new ReadableStream<{ marker: MarkerModel; ele: number | undefined }>({
|
||||
async start(controller) {
|
||||
await Promise.allSettled(markers.map(async (marker) => {
|
||||
try {
|
||||
const ele = await getElevationForPoint(marker);
|
||||
controller.enqueue({ marker, ele });
|
||||
} catch (err: any) {
|
||||
console.warn(`DB migration: Error fetching elevaton for ${marker.lat},${marker.lon}.`, err);
|
||||
anyError = true;
|
||||
}
|
||||
}));
|
||||
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
let i = 0;
|
||||
for await (const { marker, ele } of stream) {
|
||||
await this._db.helpers._updatePadObject("Marker", marker.padId, marker.id, { ele }, true);
|
||||
|
||||
if (++i % 1000 === 0) {
|
||||
console.log(`DB migration: Elevation migration ${i}/${markers.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (anyError) {
|
||||
console.warn("DB migration: There were errors, not marking elevation migration as completed.");
|
||||
} else {
|
||||
console.log("DB migration: Elevation migration completed");
|
||||
await this._db.meta.setMeta("hasElevation", "2");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("DB migration: Elevation migration crashed", err);
|
||||
}
|
||||
|
||||
const markers = await this._db.markers.MarkerModel.findAll({where: {ele: null}});
|
||||
const elevations = await getElevationForPoints(markers);
|
||||
|
||||
for (let i = 0; i < markers.length; i++) {
|
||||
await this._db.helpers._updatePadObject("Marker", markers[i].padId, markers[i].id, {ele: elevations[i]}, true);
|
||||
}
|
||||
|
||||
await this._db.meta.setMeta("hasElevation", "1");
|
||||
}
|
||||
|
||||
|
||||
|
@ -361,6 +433,8 @@ export default class DatabaseMigrations {
|
|||
if(hasLegendOption == "1")
|
||||
return;
|
||||
|
||||
console.log("DB migration: Add Types.showInLegend");
|
||||
|
||||
const types = await this._db.types.TypeModel.findAll();
|
||||
for(const type of types) {
|
||||
let showInLegend = false;
|
||||
|
@ -389,6 +463,8 @@ export default class DatabaseMigrations {
|
|||
if(await this._db.meta.getMeta("hasBboxes") == "1")
|
||||
return;
|
||||
|
||||
console.log("DB migration: Add line bboxes");
|
||||
|
||||
const LinePoint = this._db.lines.LinePointModel;
|
||||
|
||||
for(const line of await this._db.lines.LineModel.findAll()) {
|
||||
|
@ -419,6 +495,7 @@ export default class DatabaseMigrations {
|
|||
const table = model.getTableName() as string;
|
||||
const attrs = await queryInterface.describeTable(table);
|
||||
if(!attrs.pos) {
|
||||
console.log(`DB migration: Add ${table}.pos`);
|
||||
await queryInterface.addColumn(table, 'pos', {
|
||||
...model.rawAttributes.pos,
|
||||
allowNull: true
|
||||
|
@ -433,8 +510,10 @@ export default class DatabaseMigrations {
|
|||
|
||||
// We create the index here even in a non-migration case, because adding it to the model definition will cause an error if the column does not exist yet.
|
||||
const indexes: any = await queryInterface.showIndex(table);
|
||||
if (!indexes.some((index: any) => index.name == (Utils as any).underscore(`${table}_pos`)))
|
||||
if (!indexes.some((index: any) => index.name == (Utils as any).underscore(`${table}_pos`))) {
|
||||
console.log(`DB migration: Add index for ${table}.pos`);
|
||||
await queryInterface.addIndex(table, { fields: ["pos"], type: "SPATIAL" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -443,6 +522,8 @@ export default class DatabaseMigrations {
|
|||
if(await this._db.meta.getMeta("untitledMigrationCompleted") == "1")
|
||||
return;
|
||||
|
||||
console.log("DB migration: Empty name for unnamed markers/lines/pads");
|
||||
|
||||
await this._db.markers.MarkerModel.update({ name: "" }, { where: { name: "Untitled marker" } });
|
||||
await this._db.lines.LineModel.update({ name: "" }, { where: { name: "Untitled line" } });
|
||||
await this._db.pads.PadModel.update({ name: "" }, { where: { name: "New FacilMap" } });
|
||||
|
@ -455,6 +536,8 @@ export default class DatabaseMigrations {
|
|||
if(await this._db.meta.getMeta("fieldsNullMigrationCompleted") == "1")
|
||||
return;
|
||||
|
||||
console.log("DB migration: Normalize null values for field properties");
|
||||
|
||||
const allTypes = await this._db.types.TypeModel.findAll({
|
||||
attributes: ["id", "fields"]
|
||||
});
|
||||
|
@ -496,6 +579,8 @@ export default class DatabaseMigrations {
|
|||
if(await this._db.meta.getMeta("extraInfoNullMigrationCompleted") == "1")
|
||||
return;
|
||||
|
||||
console.log("DB migration: Change Lines.extraInfo from \"null\"/\"{}\" to null");
|
||||
|
||||
await this._db.lines.LineModel.update({
|
||||
extraInfo: null
|
||||
}, {
|
||||
|
@ -509,4 +594,52 @@ export default class DatabaseMigrations {
|
|||
await this._db.meta.setMeta("extraInfoNullMigrationCompleted", "1");
|
||||
}
|
||||
|
||||
/** Add Types.idx */
|
||||
async _typesIdxMigration(): Promise<void> {
|
||||
if(await this._db.meta.getMeta("typesIdxMigrationCompleted") == "1")
|
||||
return;
|
||||
|
||||
console.log("DB migration: Set initial values for Types.idx");
|
||||
|
||||
const allTypes = await this._db.types.TypeModel.findAll({
|
||||
attributes: ["id", "padId"]
|
||||
});
|
||||
|
||||
let lastIndex: Record<PadId, number> = Object.create(null);
|
||||
|
||||
for (const type of allTypes) {
|
||||
if (!Object.prototype.hasOwnProperty.call(lastIndex, type.padId)) {
|
||||
lastIndex[type.padId] = -1;
|
||||
}
|
||||
|
||||
await type.update({ idx: ++lastIndex[type.padId] });
|
||||
}
|
||||
|
||||
await this._db.meta.setMeta("typesIdxMigrationCompleted", "1");
|
||||
}
|
||||
|
||||
/** Add Views.idx */
|
||||
async _viewsIdxMigration(): Promise<void> {
|
||||
if(await this._db.meta.getMeta("viewsIdxMigrationCompleted") == "1")
|
||||
return;
|
||||
|
||||
console.log("DB migration: Set initial values for Views.idx");
|
||||
|
||||
const allViews = await this._db.views.ViewModel.findAll({
|
||||
attributes: ["id", "padId"]
|
||||
});
|
||||
|
||||
let lastIndex: Record<PadId, number> = Object.create(null);
|
||||
|
||||
for (const view of allViews) {
|
||||
if (!Object.prototype.hasOwnProperty.call(lastIndex, view.padId)) {
|
||||
lastIndex[view.padId] = -1;
|
||||
}
|
||||
|
||||
await view.update({ idx: ++lastIndex[view.padId] });
|
||||
}
|
||||
|
||||
await this._db.meta.setMeta("viewsIdxMigrationCompleted", "1");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -94,7 +94,9 @@ export default class DatabasePads {
|
|||
|
||||
const createdObj = await this.PadModel.create(data);
|
||||
|
||||
await this._db.types.createDefaultTypes(data.id);
|
||||
if (data.createDefaultTypes) {
|
||||
await this._db.types.createDefaultTypes(data.id);
|
||||
}
|
||||
|
||||
return fixPadData(createdObj.toJSON());
|
||||
}
|
||||
|
|
|
@ -3,11 +3,14 @@ import { typeValidator, type CRU, type Field, type ID, type PadId, type Type, ty
|
|||
import Database from "./database.js";
|
||||
import { createModel, getDefaultIdType, makeNotNullForeignKey } from "./helpers.js";
|
||||
import type { PadModel } from "./pad.js";
|
||||
import { asyncIteratorToArray } from "../utils/streams.js";
|
||||
import { insertIdx } from "facilmap-utils";
|
||||
|
||||
export interface TypeModel extends Model<InferAttributes<TypeModel>, InferCreationAttributes<TypeModel>> {
|
||||
id: CreationOptional<ID>;
|
||||
name: string;
|
||||
type: "marker" | "line";
|
||||
idx: number;
|
||||
padId: ForeignKey<PadModel["id"]>;
|
||||
defaultColour: Colour;
|
||||
colourFixed: boolean;
|
||||
|
@ -46,6 +49,7 @@ export default class DatabaseTypes {
|
|||
id: getDefaultIdType(),
|
||||
name: { type: DataTypes.TEXT, allowNull: false },
|
||||
type: { type: DataTypes.ENUM("marker", "line"), allowNull: false },
|
||||
idx: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false },
|
||||
defaultColour: { type: DataTypes.STRING(6), allowNull: false },
|
||||
colourFixed: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
defaultSize: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false },
|
||||
|
@ -93,24 +97,69 @@ export default class DatabaseTypes {
|
|||
return this._db.helpers._getPadObject<Type>("Type", padId, typeId);
|
||||
}
|
||||
|
||||
async createType(padId: PadId, data: Type<CRU.CREATE_VALIDATED>): Promise<Type> {
|
||||
if(data.name == null || data.name.trim().length == 0)
|
||||
throw new Error("No name provided.");
|
||||
async _freeTypeIdx(padId: PadId, typeId: ID | undefined, newIdx: number | undefined): Promise<number> {
|
||||
const existingTypes = await asyncIteratorToArray(this.getTypes(padId));
|
||||
|
||||
const createdType = await this._db.helpers._createPadObject<Type>("Type", padId, data);
|
||||
const resolvedNewIdx = newIdx ?? (existingTypes.length > 0 ? existingTypes[existingTypes.length - 1].idx + 1 : 0);
|
||||
|
||||
const newIndexes = insertIdx(existingTypes, typeId, resolvedNewIdx).reverse();
|
||||
|
||||
for (const obj of newIndexes) {
|
||||
if ((typeId == null || obj.id !== typeId) && obj.oldIdx !== obj.newIdx) {
|
||||
const result = await this._db.helpers._updatePadObject<Type>("Type", padId, obj.id, { idx: obj.newIdx }, true);
|
||||
this._db.emit("type", result.padId, result);
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedNewIdx;
|
||||
}
|
||||
|
||||
async createType(padId: PadId, data: Type<CRU.CREATE_VALIDATED>): Promise<Type> {
|
||||
const idx = await this._freeTypeIdx(padId, undefined, data.idx);
|
||||
|
||||
const createdType = await this._db.helpers._createPadObject<Type>("Type", padId, {
|
||||
...data,
|
||||
idx
|
||||
});
|
||||
this._db.emit("type", createdType.padId, createdType);
|
||||
return createdType;
|
||||
}
|
||||
|
||||
async updateType(padId: PadId, typeId: ID, data: Omit<Type<CRU.UPDATE_VALIDATED>, "id">, _doNotUpdateStyles?: boolean): Promise<Type> {
|
||||
if(data.name == null || data.name.trim().length == 0)
|
||||
throw new Error("No name provided.");
|
||||
async updateType(padId: PadId, typeId: ID, data: Omit<Type<CRU.UPDATE_VALIDATED>, "id">): Promise<Type> {
|
||||
const rename: Record<string, { name?: string, values?: Record<string, string> }> = {};
|
||||
for(const field of (data.fields || [])) {
|
||||
if(field.oldName && field.oldName != field.name)
|
||||
rename[field.oldName] = { name: field.name };
|
||||
|
||||
if(field.options) {
|
||||
for(const option of field.options) {
|
||||
if(option.oldValue && option.oldValue != option.value) {
|
||||
if(!rename[field.oldName || field.name])
|
||||
rename[field.oldName || field.name] = { };
|
||||
if(!rename[field.oldName || field.name].values)
|
||||
rename[field.oldName || field.name].values = { };
|
||||
|
||||
rename[field.oldName || field.name].values![option.oldValue] = option.value;
|
||||
}
|
||||
|
||||
delete option.oldValue;
|
||||
}
|
||||
}
|
||||
|
||||
delete field.oldName;
|
||||
}
|
||||
|
||||
if (data.idx != null) {
|
||||
await this._freeTypeIdx(padId, typeId, data.idx);
|
||||
}
|
||||
|
||||
const result = await this._db.helpers._updatePadObject<Type>("Type", padId, typeId, data);
|
||||
this._db.emit("type", result.padId, result);
|
||||
|
||||
if(!_doNotUpdateStyles)
|
||||
await this.recalculateObjectStylesForType(result.padId, typeId, result.type == "line");
|
||||
if(Object.keys(rename).length > 0)
|
||||
await this._db.helpers.renameObjectDataField(padId, result.id, rename, result.type == "line");
|
||||
|
||||
await this.recalculateObjectStylesForType(result.padId, typeId, result.type == "line");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
@ -3,13 +3,16 @@ import type { CRU, ID, Latitude, Longitude, PadId, View } from "facilmap-types";
|
|||
import Database from "./database.js";
|
||||
import { createModel, getDefaultIdType, getLatType, getLonType, makeNotNullForeignKey } from "./helpers.js";
|
||||
import type { PadModel } from "./pad.js";
|
||||
import { asyncIteratorToArray } from "../utils/streams.js";
|
||||
import { insertIdx } from "facilmap-utils";
|
||||
|
||||
export interface ViewModel extends Model<InferAttributes<ViewModel>, InferCreationAttributes<ViewModel>> {
|
||||
id: CreationOptional<ID>;
|
||||
padId: ForeignKey<PadModel["id"]>;
|
||||
name: string;
|
||||
idx: number;
|
||||
baseLayer: string;
|
||||
layers: string;
|
||||
layers: string[];
|
||||
top: Latitude;
|
||||
bottom: Latitude;
|
||||
left: Longitude;
|
||||
|
@ -30,15 +33,17 @@ export default class DatabaseViews {
|
|||
this.ViewModel.init({
|
||||
id: getDefaultIdType(),
|
||||
name : { type: DataTypes.TEXT, allowNull: false },
|
||||
idx: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false },
|
||||
baseLayer : { type: DataTypes.TEXT, allowNull: false },
|
||||
layers : {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
get: function(this: ViewModel) {
|
||||
return JSON.parse(this.getDataValue("layers"));
|
||||
const layers = this.getDataValue("layers") as any as string; // https://github.com/sequelize/sequelize/issues/11558
|
||||
return layers == null ? [] : JSON.parse(layers);
|
||||
},
|
||||
set: function(this: ViewModel, v) {
|
||||
this.setDataValue("layers", JSON.stringify(v));
|
||||
this.setDataValue("layers", JSON.stringify(v) as any);
|
||||
}
|
||||
},
|
||||
top : getLatType(),
|
||||
|
@ -61,11 +66,30 @@ export default class DatabaseViews {
|
|||
return this._db.helpers._getPadObjects<View>("View", padId);
|
||||
}
|
||||
|
||||
async createView(padId: PadId, data: View<CRU.CREATE_VALIDATED>): Promise<View> {
|
||||
if(data.name == null || data.name.trim().length == 0)
|
||||
throw new Error("No name provided.");
|
||||
async _freeViewIdx(padId: PadId, viewId: ID | undefined, newIdx: number | undefined): Promise<number> {
|
||||
const existingViews = await asyncIteratorToArray(this.getViews(padId));
|
||||
|
||||
const newData = await this._db.helpers._createPadObject<View>("View", padId, data);
|
||||
const resolvedNewIdx = newIdx ?? (existingViews.length > 0 ? existingViews[existingViews.length - 1].idx + 1 : 0);
|
||||
|
||||
const newIndexes = insertIdx(existingViews, viewId, resolvedNewIdx).reverse();
|
||||
|
||||
for (const obj of newIndexes) {
|
||||
if ((viewId == null || obj.id !== viewId) && obj.oldIdx !== obj.newIdx) {
|
||||
const newData = await this._db.helpers._updatePadObject<View>("View", padId, obj.id, { idx: obj.newIdx }, true);
|
||||
this._db.emit("view", padId, newData);
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedNewIdx;
|
||||
}
|
||||
|
||||
async createView(padId: PadId, data: View<CRU.CREATE_VALIDATED>): Promise<View> {
|
||||
const idx = await this._freeViewIdx(padId, undefined, data.idx);
|
||||
|
||||
const newData = await this._db.helpers._createPadObject<View>("View", padId, {
|
||||
...data,
|
||||
idx
|
||||
});
|
||||
|
||||
await this._db.history.addHistoryEntry(padId, {
|
||||
type: "View",
|
||||
|
@ -79,6 +103,10 @@ export default class DatabaseViews {
|
|||
}
|
||||
|
||||
async updateView(padId: PadId, viewId: ID, data: Omit<View<CRU.UPDATE_VALIDATED>, "id">): Promise<View> {
|
||||
if (data.idx != null) {
|
||||
await this._freeViewIdx(padId, viewId, data.idx);
|
||||
}
|
||||
|
||||
const newData = await this._db.helpers._updatePadObject<View>("View", padId, viewId, data);
|
||||
|
||||
this._db.emit("view", padId, newData);
|
||||
|
|
|
@ -1,77 +0,0 @@
|
|||
import type { Point } from "facilmap-types";
|
||||
import config from "./config";
|
||||
|
||||
export async function getElevationForPoint(point: Point, failOnError = false): Promise<number | undefined> {
|
||||
const points = await getElevationForPoints([point], failOnError);
|
||||
return points[0];
|
||||
}
|
||||
|
||||
export async function getElevationForPoints(points: Point[], failOnError = false): Promise<Array<number | undefined>> {
|
||||
if(points.length == 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${config.openElevationApiUrl}/api/v1/lookup`, {
|
||||
method: "post",
|
||||
headers: {
|
||||
"Content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
locations: points.map((point) => ({ latitude: point.lat, longitude: point.lon }))
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Looking up elevations failed with status ${res.status}.`);
|
||||
}
|
||||
const json: { results: Array<{ latitude: number; longitude: number; elevation: number }> } = await res.json();
|
||||
|
||||
return json.results.map((result: any) => {
|
||||
if (result.elevation !== 0) {
|
||||
return result.elevation;
|
||||
}
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (failOnError) {
|
||||
throw err;
|
||||
} else {
|
||||
console.warn("Error lookup up elevation", err);
|
||||
return points.map(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AscentDescent {
|
||||
ascent: number | undefined;
|
||||
descent: number | undefined;
|
||||
}
|
||||
|
||||
export function getAscentDescent(elevations: Array<number | null>): AscentDescent {
|
||||
if(!elevations.some((ele) => (ele != null))) {
|
||||
return {
|
||||
ascent: undefined,
|
||||
descent: undefined
|
||||
};
|
||||
}
|
||||
|
||||
const ret: AscentDescent = {
|
||||
ascent: 0,
|
||||
descent: 0
|
||||
};
|
||||
|
||||
let last: number | null = null;
|
||||
|
||||
for(const ele of elevations) {
|
||||
if(last == null || ele == null)
|
||||
continue;
|
||||
|
||||
if(ele > last)
|
||||
ret.ascent! += ele - last;
|
||||
else
|
||||
ret.descent! += last - ele;
|
||||
|
||||
last = ele;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
|
@ -62,6 +62,10 @@ async function getScripts(entry: "mapEntry" | "tableEntry"): Promise<Scripts> {
|
|||
function getInjectedConfig(): InjectedConfig {
|
||||
return {
|
||||
appName: config.appName,
|
||||
openElevationApiUrl: config.openElevationApiUrl,
|
||||
openElevationThrottleMs: config.openElevationThrottleMs,
|
||||
openElevationMaxBatchSize: config.openElevationMaxBatchSize,
|
||||
nominatimUrl: config.nominatimUrl,
|
||||
limaLabsToken: config.limaLabsToken,
|
||||
hideCommercialMapLinks: config.hideCommercialMapLinks,
|
||||
};
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { distanceToDegreesLat, distanceToDegreesLon } from "./utils/geo.js";
|
||||
import md5 from "md5-file";
|
||||
import { schedule } from "node-cron";
|
||||
import { open, type Reader, type Response } from "maxmind";
|
||||
import { createWriteStream } from "fs";
|
||||
import { rename } from "node:fs/promises";
|
||||
import { rename, stat } from "node:fs/promises";
|
||||
import https from "https";
|
||||
import zlib from "zlib";
|
||||
import config from "./config.js";
|
||||
|
@ -12,6 +11,7 @@ import type { Bbox } from "facilmap-types";
|
|||
import { fileURLToPath } from "url";
|
||||
import { fileExists } from "./utils/utils";
|
||||
import findCacheDir from "find-cache-dir";
|
||||
import { utimes } from "fs/promises";
|
||||
|
||||
const geoliteUrl = "https://updates.maxmind.com/geoip/databases/GeoLite2-City/update?db_md5=";
|
||||
const cacheDir = findCacheDir({ name: "facilmap-server", create: true })
|
||||
|
@ -23,14 +23,17 @@ let currentMd5: string | null = null;
|
|||
let db: Reader<Response> | null = null;
|
||||
|
||||
if(config.maxmindUserId && config.maxmindLicenseKey) {
|
||||
schedule("0 3 * * *", download);
|
||||
|
||||
load().catch((err) => {
|
||||
console.log("Error loading maxmind database", err);
|
||||
});
|
||||
download().catch((err) => {
|
||||
checkDownload().catch((err) => {
|
||||
console.log("Error downloading maxmind database", err);
|
||||
});
|
||||
setInterval(() => {
|
||||
checkDownload().catch((err) => {
|
||||
console.log("Error downloading maxmind database", err);
|
||||
});
|
||||
}, 3600_000);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
|
@ -38,8 +41,13 @@ async function load() {
|
|||
db = await open(fname);
|
||||
else
|
||||
db = null;
|
||||
}
|
||||
|
||||
|
||||
async function checkDownload() {
|
||||
const mtime = (await fileExists(fname)) ? (await stat(fname)).mtimeMs : -Infinity;
|
||||
if (Date.now() - mtime >= 86400_000) {
|
||||
await download();
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
|
@ -60,6 +68,7 @@ async function download() {
|
|||
|
||||
if(res.statusCode == 304) {
|
||||
console.log("Maxmind database is up to date, no update needed.");
|
||||
await utimes(fname, Date.now(), Date.now());
|
||||
return;
|
||||
} else if(res.statusCode != 200)
|
||||
throw new Error(`Unexpected status code ${res.statusCode} when downloading maxmind database.`);
|
||||
|
|
|
@ -1,465 +1,24 @@
|
|||
import { round } from "./utils/utils.js";
|
||||
import { load, type CheerioAPI } from "cheerio";
|
||||
import compressjs from "compressjs";
|
||||
import zlib from "zlib";
|
||||
import util from "util";
|
||||
import { getElevationForPoint, getElevationForPoints } from "./elevation.js";
|
||||
import type { ZoomLevel, Point, SearchResult } from "facilmap-types";
|
||||
import type { Geometry } from "geojson";
|
||||
import type { SearchResult } from "facilmap-types";
|
||||
import stripBomBuf from "strip-bom-buf";
|
||||
import throttle from "p-throttle";
|
||||
import config from "./config.js";
|
||||
import { find as findSearch, parseUrlQuery } from "facilmap-utils";
|
||||
|
||||
interface NominatimResult {
|
||||
place_id: number;
|
||||
license: string;
|
||||
osm_type: "node" | "way" | "relation";
|
||||
osm_id: number;
|
||||
boundingbox: [string, string, string, string];
|
||||
lat: string;
|
||||
lon: string;
|
||||
zoom?: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
place_rank: number;
|
||||
category: string;
|
||||
type: string;
|
||||
importance: number;
|
||||
icon: string;
|
||||
address: Partial<Record<string, string>>;
|
||||
geojson: Geometry;
|
||||
extratags: Record<string, string>;
|
||||
namedetails: Record<string, string> | null;
|
||||
elevation?: number; // Added by us
|
||||
}
|
||||
|
||||
interface NominatimError {
|
||||
error: { code?: number; message: string } | string;
|
||||
}
|
||||
|
||||
const limit = 25;
|
||||
const stateAbbr: Record<string, Record<string, string>> = {
|
||||
"us" : {
|
||||
"alabama":"AL","alaska":"AK","arizona":"AZ","arkansas":"AR","california":"CA","colorado":"CO","connecticut":"CT",
|
||||
"delaware":"DE","florida":"FL","georgia":"GA","hawaii":"HI","idaho":"ID","illinois":"IL","indiana":"IN","iowa":"IA",
|
||||
"kansas":"KS","kentucky":"KY","louisiana":"LA","maine":"ME","maryland":"MD","massachusetts":"MA","michigan":"MI",
|
||||
"minnesota":"MN","mississippi":"MS","missouri":"MO","montana":"MT","nebraska":"NE","nevada":"NV","new hampshire":"NH",
|
||||
"new jersey":"NJ","new mexico":"NM","new york":"NY","north carolina":"NC","north dakota":"ND","ohio":"OH","oklahoma":"OK",
|
||||
"oregon":"OR","pennsylvania":"PA","rhode island":"RI","south carolina":"SC","south dakota":"SD","tennessee":"TN",
|
||||
"texas":"TX","utah":"UT","vermont":"VT","virginia":"VA","washington":"WA","west virginia":"WV","wisconsin":"WI","wyoming":"WY"
|
||||
},
|
||||
"it" : {
|
||||
"agrigento":"AG","alessandria":"AL","ancona":"AN","aosta":"AO","arezzo":"AR","ascoli piceno":"AP","asti":"AT",
|
||||
"avellino":"AV","bari":"BA","barletta":"BT","barletta-andria-trani":"BT","belluno":"BL","benevento":"BN",
|
||||
"bergamo":"BG","biella":"BI","bologna":"BO","bolzano":"BZ","brescia":"BS","brindisi":"BR","cagliari":"CA",
|
||||
"caltanissetta":"CL","campobasso":"CB","carbonia-iglesias":"CI","caserta":"CE","catania":"CT","catanzaro":"CZ",
|
||||
"chieti":"CH","como":"CO","cosenza":"CS","cremona":"CR","crotone":"KR","cuneo":"CN","enna":"EN","fermo":"FM",
|
||||
"ferrara":"FE","firenze":"FI","foggia":"FG","forli-cesena":"FC","frosinone":"FR","genova":"GE","gorizia":"GO",
|
||||
"grosseto":"GR","imperia":"IM","isernia":"IS","la spezia":"SP","l'aquila":"AQ","latina":"LT","lecce":"LE",
|
||||
"lecco":"LC","livorno":"LI","lodi":"LO","lucca":"LU","macerata":"MC","mantova":"MN","massa e carrara":"MS",
|
||||
"matera":"MT","medio campidano":"VS","messina":"ME","milano":"MI","modena":"MO","monza e brianza":"MB",
|
||||
"napoli":"NA","novara":"NO","nuoro":"NU","ogliastra":"OG","olbia-tempio":"OT","oristano":"OR","padova":"PD",
|
||||
"palermo":"PA","parma":"PR","pavia":"PV","perugia":"PG","pesaro e urbino":"PU","pescara":"PE","piacenza":"PC",
|
||||
"pisa":"PI","pistoia":"PT","pordenone":"PN","potenza":"PZ","prato":"PO","ragusa":"RG","ravenna":"RA",
|
||||
"reggio calabria":"RC","reggio emilia":"RE","rieti":"RI","rimini":"RN","roma":"RM","rovigo":"RO","salerno":"SA",
|
||||
"sassari":"SS","savona":"SV","siena":"SI","siracusa":"SR","sondrio":"SO","taranto":"TA","teramo":"TE","terni":"TR",
|
||||
"torino":"TO","trapani":"TP","trento":"TN","treviso":"TV","trieste":"TS","udine":"UD","varese":"VA","venezia":"VE",
|
||||
"verbano":"VB","verbano-cusio-ossola":"VB","vercelli":"VC","verona":"VR","vibo valentia":"VV","vicenza":"VI","viterbo":"VT"
|
||||
},
|
||||
"ca" : {
|
||||
"ontario":"ON","quebec":"QC","nova scotia":"NS","new brunswick":"NB","manitoba":"MB","british columbia":"BC",
|
||||
"prince edward island":"PE","saskatchewan":"SK","alberta":"AB","newfoundland and labrador":"NL"
|
||||
},
|
||||
"au" : {
|
||||
"australian capital territory":"ACT","jervis bay territory":"JBT","new south wales":"NSW","northern territory":"NT",
|
||||
"queensland":"QLD","south australia":"SA","tasmania":"TAS","victoria":"VIC","western australia":"WA"
|
||||
}
|
||||
};
|
||||
|
||||
// Respect Nominatim rate limit (https://operations.osmfoundation.org/policies/nominatim/)
|
||||
const throttledFetch = throttle({ limit: 1, interval: 1000 })(fetch);
|
||||
|
||||
interface PointWithZoom extends Point {
|
||||
zoom?: ZoomLevel;
|
||||
}
|
||||
|
||||
export async function find(query: string, loadUrls = false, loadElevation = false): Promise<Array<SearchResult> | string> {
|
||||
query = query.replace(/^\s+/, "").replace(/\s+$/, "");
|
||||
|
||||
if(loadUrls) {
|
||||
let m = query.match(/^(node|way|relation)\s+(\d+)$/);
|
||||
if(m)
|
||||
return await _loadUrl("https://api.openstreetmap.org/api/0.6/" + m[1] + "/" + m[2] + (m[1] != "node" ? "/full" : ""), true);
|
||||
|
||||
m = query.match(/^trace\s+(\d+)$/);
|
||||
if(m)
|
||||
return await _loadUrl("https://www.openstreetmap.org/trace/" + m[1] + "/data");
|
||||
|
||||
if(query.match(/^https?:\/\//))
|
||||
return await _loadUrl(query);
|
||||
}
|
||||
|
||||
const lonlat_match = matchLonLat(query);
|
||||
if(lonlat_match) {
|
||||
const result = await _findLonLat(lonlat_match, loadElevation);
|
||||
return result.map((res) => ({ ...res, id: query }));
|
||||
}
|
||||
|
||||
const osm_match = query.match(/^([nwr])(\d+)$/i);
|
||||
if(osm_match)
|
||||
return await _findOsmObject(osm_match[1], osm_match[2], loadElevation);
|
||||
|
||||
return await _findQuery(query, loadElevation);
|
||||
}
|
||||
|
||||
async function _findQuery(query: string, loadElevation = false): Promise<Array<SearchResult>> {
|
||||
const body: Array<NominatimResult> | NominatimError = await throttledFetch(
|
||||
config.nominatimUrl + "/search?format=jsonv2&polygon_geojson=1&addressdetails=1&namedetails=1&limit=" + encodeURIComponent(limit) + "&extratags=1&q=" + encodeURIComponent(query),
|
||||
{
|
||||
headers: {
|
||||
"User-Agent": config.userAgent
|
||||
}
|
||||
}
|
||||
).then((res) => res.json() as any);
|
||||
|
||||
if(!body)
|
||||
throw new Error("Invalid response from name finder.");
|
||||
|
||||
if('error' in body)
|
||||
throw new Error(typeof body.error === 'string' ? body.error : body.error.message);
|
||||
|
||||
const points = body.filter((res) => (res.lon && res.lat));
|
||||
if(loadElevation && points.length > 0) {
|
||||
const elevations = await getElevationForPoints(points.map((point) => ({ lat: Number(point.lat), lon: Number(point.lon) })));
|
||||
elevations.forEach((elevation, i) => {
|
||||
points[i].elevation = elevation;
|
||||
});
|
||||
}
|
||||
|
||||
return body.map(_prepareSearchResult);
|
||||
}
|
||||
|
||||
async function _findOsmObject(type: string, id: string, loadElevation = false): Promise<Array<SearchResult>> {
|
||||
const body: Array<NominatimResult> | NominatimError = await throttledFetch(
|
||||
`${config.nominatimUrl}/lookup?format=jsonv2&addressdetails=1&polygon_geojson=1&extratags=1&namedetails=1&osm_ids=${encodeURI(type.toUpperCase())}${encodeURI(id)}`,
|
||||
{
|
||||
headers: {
|
||||
"User-Agent": config.userAgent
|
||||
}
|
||||
}
|
||||
).then((res) => res.json() as any);
|
||||
|
||||
if(!body)
|
||||
throw new Error("Invalid response from name finder.");
|
||||
|
||||
if('error' in body)
|
||||
throw new Error(typeof body.error === 'string' ? body.error : body.error.message);
|
||||
|
||||
const points = body.filter((res) => (res.lon && res.lat));
|
||||
if(loadElevation && points.length > 0) {
|
||||
const elevations = await getElevationForPoints(points.map((point) => ({ lat: Number(point.lat), lon: Number(point.lon) })));
|
||||
elevations.forEach((elevation, i) => {
|
||||
points[i].elevation = elevation;
|
||||
});
|
||||
}
|
||||
|
||||
return body.map(_prepareSearchResult);
|
||||
}
|
||||
|
||||
async function _findLonLat(lonlatWithZoom: PointWithZoom, loadElevation = false): Promise<Array<SearchResult>> {
|
||||
const [body, elevation] = await Promise.all([
|
||||
throttledFetch(
|
||||
`${config.nominatimUrl}/reverse?format=jsonv2&addressdetails=1&polygon_geojson=0&extratags=1&namedetails=1&lat=${encodeURIComponent(lonlatWithZoom.lat)}&lon=${encodeURIComponent(lonlatWithZoom.lon)}&zoom=${encodeURIComponent(lonlatWithZoom.zoom != null ? (lonlatWithZoom.zoom >= 12 ? lonlatWithZoom.zoom+2 : lonlatWithZoom.zoom) : 17)}`,
|
||||
{
|
||||
headers: {
|
||||
"User-Agent": config.userAgent
|
||||
}
|
||||
}
|
||||
).then((res) => res.json() as any),
|
||||
...(loadElevation ? [getElevationForPoint(lonlatWithZoom)] : [])
|
||||
]);
|
||||
|
||||
if(!body || body.error) {
|
||||
const name = round(lonlatWithZoom.lat, 5) + ", " + round(lonlatWithZoom.lon, 5);
|
||||
return [ {
|
||||
lat: lonlatWithZoom.lat,
|
||||
lon : lonlatWithZoom.lon,
|
||||
type : "coordinates",
|
||||
short_name: name,
|
||||
display_name : name,
|
||||
zoom: lonlatWithZoom.zoom != null ? lonlatWithZoom.zoom : 15,
|
||||
icon: undefined,
|
||||
elevation: elevation
|
||||
} ];
|
||||
}
|
||||
|
||||
body.lat = lonlatWithZoom.lat;
|
||||
body.lon = lonlatWithZoom.lon;
|
||||
body.zoom = lonlatWithZoom.zoom || 15;
|
||||
|
||||
body.elevation = elevation;
|
||||
|
||||
return [ _prepareSearchResult(body) ];
|
||||
}
|
||||
|
||||
function _prepareSearchResult(result: NominatimResult): SearchResult {
|
||||
const { address, nameWithAddress, name } = _formatAddress(result);
|
||||
return {
|
||||
short_name: name,
|
||||
display_name: nameWithAddress,
|
||||
address,
|
||||
boundingbox: result.boundingbox?.map((n) => Number(n)) as [number, number, number, number],
|
||||
lat: Number(result.lat),
|
||||
lon: Number(result.lon),
|
||||
zoom: result.zoom,
|
||||
extratags: result.extratags,
|
||||
geojson: result.geojson,
|
||||
icon: result.icon && result.icon.replace(/^.*\/([a-z0-9_]+)\.[a-z0-9]+\.[0-9]+\.[a-z0-9]+$/i, "$1"),
|
||||
type: result.type == "yes" ? result.category : result.type,
|
||||
id: result.osm_id ? result.osm_type.charAt(0) + result.osm_id : undefined,
|
||||
elevation: result.elevation
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to format a search result in a readable way according to the address notation habits in
|
||||
* the appropriate country.
|
||||
* @param result {Object} A place object as returned by Nominatim
|
||||
* @return {Object} An object with address, nameWithAddress and name strings
|
||||
*/
|
||||
function _formatAddress(result: NominatimResult) {
|
||||
// See http://en.wikipedia.org/wiki/Address_%28geography%29#Mailing_address_format_by_country for
|
||||
// address notation guidelines
|
||||
|
||||
let type = result.type;
|
||||
let name = result.namedetails?.name ?? result.name;
|
||||
const countryCode = result.address.country_code;
|
||||
|
||||
let road = result.address.road;
|
||||
const housenumber = result.address.house_number;
|
||||
let suburb = result.address.town || result.address.suburb || result.address.village || result.address.hamlet || result.address.residential;
|
||||
const postcode = result.address.postcode;
|
||||
let city = result.address.city;
|
||||
let county = result.address.county;
|
||||
let state = result.address.state;
|
||||
const country = result.address.country;
|
||||
|
||||
if([ "road", "residential", "town", "suburb", "village", "hamlet", "residential", "city", "county", "state" ].indexOf(type) != -1)
|
||||
name = "";
|
||||
|
||||
if(!city && suburb) {
|
||||
city = suburb;
|
||||
suburb = "";
|
||||
}
|
||||
|
||||
if(road) {
|
||||
switch(countryCode) {
|
||||
case "pl":
|
||||
road = "ul. "+road;
|
||||
break;
|
||||
export async function find(query: string, loadUrls = false): Promise<Array<SearchResult> | string> {
|
||||
if (loadUrls) {
|
||||
const url = parseUrlQuery(query);
|
||||
if (url) {
|
||||
return await _loadUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Add house number to road
|
||||
if(road && housenumber) {
|
||||
switch(countryCode) {
|
||||
case "ar":
|
||||
case "at":
|
||||
case "ca":
|
||||
case "de":
|
||||
case "hr":
|
||||
case "cz":
|
||||
case "dk":
|
||||
case "fi":
|
||||
case "is":
|
||||
case "il":
|
||||
case "it":
|
||||
case "nl":
|
||||
case "no":
|
||||
case "pe":
|
||||
case "pl":
|
||||
case "sk":
|
||||
case "si":
|
||||
case "se":
|
||||
case "tr":
|
||||
road += " "+housenumber;
|
||||
break;
|
||||
case "be":
|
||||
case "es":
|
||||
road += ", "+housenumber;
|
||||
break;
|
||||
case "cl":
|
||||
road += " N° "+housenumber;
|
||||
break;
|
||||
case "hu":
|
||||
road += " "+housenumber+".";
|
||||
break;
|
||||
case "id":
|
||||
road += " No. "+housenumber;
|
||||
break;
|
||||
case "my":
|
||||
road = "No." +housenumber+", "+road;
|
||||
break;
|
||||
case "ro":
|
||||
road += ", nr. "+housenumber;
|
||||
break;
|
||||
case "au":
|
||||
case "fr":
|
||||
case "hk":
|
||||
case "ie":
|
||||
case "in":
|
||||
case "nz":
|
||||
case "sg":
|
||||
case "lk":
|
||||
case "tw":
|
||||
case "gb":
|
||||
case "us":
|
||||
default:
|
||||
road = housenumber+" "+road;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add postcode and districts to city
|
||||
switch(countryCode) {
|
||||
case "ar":
|
||||
if(postcode && city)
|
||||
city = postcode+", "+city;
|
||||
else if(postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
case "at":
|
||||
case "ch":
|
||||
case "de":
|
||||
if(city) {
|
||||
if(suburb)
|
||||
city += "-"+(suburb);
|
||||
suburb = undefined;
|
||||
if(type == "suburb" || type == "residential")
|
||||
type = "city";
|
||||
|
||||
if(postcode)
|
||||
city = postcode+" "+city;
|
||||
} else if (postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
case "be":
|
||||
case "hr":
|
||||
case "cz":
|
||||
case "dk":
|
||||
case "fi":
|
||||
case "fr":
|
||||
case "hu":
|
||||
case "is":
|
||||
case "il":
|
||||
case "my":
|
||||
case "nl":
|
||||
case "no":
|
||||
case "sk":
|
||||
case "si":
|
||||
case "es":
|
||||
case "se":
|
||||
case "tr":
|
||||
if(city && postcode)
|
||||
city = postcode+" "+city;
|
||||
else if (postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
case "au":
|
||||
case "ca":
|
||||
case "us":
|
||||
if(city && state)
|
||||
{
|
||||
const thisStateAbbr = stateAbbr[countryCode][state.toLowerCase()];
|
||||
if(thisStateAbbr)
|
||||
{
|
||||
city += " "+thisStateAbbr;
|
||||
state = undefined;
|
||||
}
|
||||
}
|
||||
if(city && postcode)
|
||||
city += " "+postcode;
|
||||
else if(postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
case "it":
|
||||
if(city)
|
||||
{
|
||||
if(county)
|
||||
{
|
||||
const countyAbbr = stateAbbr.it[county.toLowerCase().replace(/ì/g, "i")];
|
||||
if(countyAbbr)
|
||||
{
|
||||
city += " ("+countyAbbr+")";
|
||||
county = undefined;
|
||||
}
|
||||
}
|
||||
if(postcode)
|
||||
city = postcode+" "+city;
|
||||
} else if (postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
case "ro":
|
||||
if(city && county)
|
||||
{
|
||||
city += ", jud. "+county;
|
||||
county = undefined;
|
||||
}
|
||||
if(city && postcode)
|
||||
city += ", "+postcode;
|
||||
else if (postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
case "cl":
|
||||
case "hk": // Postcode rarely/not used
|
||||
case "ie":
|
||||
case "in":
|
||||
case "id":
|
||||
case "nz":
|
||||
case "pe":
|
||||
case "sg":
|
||||
case "lk":
|
||||
case "tw":
|
||||
case "gb":
|
||||
default:
|
||||
if(city && postcode)
|
||||
city = city+" "+postcode;
|
||||
else if(postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
}
|
||||
|
||||
const address = [ ];
|
||||
|
||||
if(road)
|
||||
address.push(road);
|
||||
if(suburb)
|
||||
address.push(suburb);
|
||||
if(city)
|
||||
address.push(city);
|
||||
if(["residential", "town", "suburb", "village", "hamlet", "residential", "city", "county", "state"].includes(type) || address.length == 0)
|
||||
{ // Searching for a town
|
||||
if(county && county != city)
|
||||
address.push(county);
|
||||
if(state && state != city)
|
||||
address.push(state);
|
||||
}
|
||||
|
||||
if(country)
|
||||
address.push(country);
|
||||
|
||||
const fullName = [ ...address ];
|
||||
if(name && name != address[0])
|
||||
fullName.unshift(name);
|
||||
|
||||
return {
|
||||
address: address.join(", "),
|
||||
nameWithAddress: fullName.join(", "),
|
||||
name: fullName[0]
|
||||
};
|
||||
return await findSearch(query);
|
||||
}
|
||||
|
||||
async function _loadUrl(url: string, completeOsmObjects = false): Promise<string> {
|
||||
async function _loadUrl(url: string): Promise<string> {
|
||||
const res = await fetch(
|
||||
url,
|
||||
{
|
||||
|
@ -492,7 +51,7 @@ async function _loadUrl(url: string, completeOsmObjects = false): Promise<string
|
|||
const $ = load(body, { xmlMode: true });
|
||||
const rootEl = $.root().children();
|
||||
|
||||
if(rootEl.is("osm") && completeOsmObjects) {
|
||||
if(rootEl.is("osm") && url.startsWith("https://api.openstreetmap.org/api/")) {
|
||||
await _loadSubRelations($);
|
||||
return $.xml();
|
||||
} else if(rootEl.is("gpx,kml,osm"))
|
||||
|
@ -545,57 +104,3 @@ async function _loadSubRelations($: CheerioAPI) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lonLatRegexp = (() => {
|
||||
const number = `[-\u2212]?\\s*\\d+([.,]\\d+)?`;
|
||||
|
||||
const getCoordinate = (n: number) => (
|
||||
`(` +
|
||||
`(?<degrees${n}>${number})` +
|
||||
`(\\s*[°]\\s*|\\s*deg\\s*|\\s+|$|(?!\\d))` +
|
||||
`)(` +
|
||||
`(?<minutes${n}>${number})` +
|
||||
`(\\s*['\u2032]\\s*)` +
|
||||
`)?(` +
|
||||
`(?<seconds${n}>${number})` +
|
||||
`(\\s*["\u2033]\\s*)` +
|
||||
`)?(` +
|
||||
`(?<hemisphere${n}>[NWSE])` +
|
||||
`)?`
|
||||
);
|
||||
|
||||
const coords = (
|
||||
`(geo\\s*:\\s*)?` +
|
||||
`\\s*` +
|
||||
getCoordinate(1) +
|
||||
`(\\s*[,;]\\s*|\\s+)` +
|
||||
getCoordinate(2) +
|
||||
`(\\?z=(?<zoom>\\d+))?`
|
||||
);
|
||||
|
||||
return new RegExp(`^\\s*${coords}\\s*$`, "i");
|
||||
})();
|
||||
|
||||
export function matchLonLat(query: string): (Point & { zoom?: number }) | undefined {
|
||||
const m = lonLatRegexp.exec(query);
|
||||
|
||||
const prepareNumber = (str: string) => Number(str.replace(",", ".").replace("\u2212", "-").replace(/\s+/, ""));
|
||||
const prepareCoords = (deg: string, min: string | undefined, sec: string | undefined, hem: string | undefined) => {
|
||||
const degrees = prepareNumber(deg);
|
||||
const result = Math.abs(degrees) + (min ? prepareNumber(min) / 60 : 0) + (sec ? prepareNumber(sec) / 3600 : 0);
|
||||
return result * (degrees < 0 ? -1 : 1) * (hem && ["s", "S", "w", "W"].includes(hem) ? -1 : 1);
|
||||
};
|
||||
|
||||
if (m) {
|
||||
const number1 = prepareCoords(m.groups!.degrees1, m.groups!.minutes1, m.groups!.seconds1, m.groups!.hemisphere1);
|
||||
const number2 = prepareCoords(m.groups!.degrees2, m.groups!.minutes2, m.groups!.seconds2, m.groups!.hemisphere2);
|
||||
const zoom = m.groups!.zoom ? Number(m.groups!.zoom) : undefined;
|
||||
const zoomObj = zoom != null && isFinite(zoom) ? { zoom } : {};
|
||||
|
||||
if ([undefined, "n", "N", "s", "S"].includes(m.groups!.hemisphere1) && [undefined, "w", "W", "e", "E"].includes(m.groups!.hemisphere2)) {
|
||||
return { lat: number1, lon: number2, ...zoomObj };
|
||||
} else if (["w", "W", "e", "E"].includes(m.groups!.hemisphere1) && [undefined, "n", "N", "s", "S"].includes(m.groups!.hemisphere2)) {
|
||||
return { lat: number2, lon: number1, ...zoomObj };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -33,7 +33,6 @@ export class SocketConnectionV1 extends SocketConnectionV2 {
|
|||
addMarker: mapResult(socketHandlers.addMarker, (marker) => this.prepareMarker(marker)),
|
||||
editMarker: mapResult(socketHandlers.editMarker, (marker) => this.prepareMarker(marker)),
|
||||
deleteMarker: mapResult(socketHandlers.deleteMarker, (marker) => this.prepareMarker(marker)),
|
||||
getLineTemplate: mapResult(socketHandlers.getLineTemplate, (line) => this.prepareLine(line)),
|
||||
addLine: mapResult(socketHandlers.addLine, (line) => this.prepareLine(line)),
|
||||
editLine: mapResult(socketHandlers.editLine, (line) => this.prepareLine(line)),
|
||||
deleteLine: mapResult(socketHandlers.deleteLine, (line) => this.prepareLine(line)),
|
||||
|
|
|
@ -357,39 +357,7 @@ export class SocketConnectionV2 extends SocketConnection {
|
|||
if (!isPadId(this.padId))
|
||||
throw new Error("No map opened.");
|
||||
|
||||
const rename: Record<string, { name?: string, values?: Record<string, string> }> = {};
|
||||
for(const field of (data.fields || [])) {
|
||||
if(field.oldName && field.oldName != field.name)
|
||||
rename[field.oldName] = { name: field.name };
|
||||
|
||||
if(field.options) {
|
||||
for(const option of field.options) {
|
||||
if(option.oldValue && option.oldValue != option.value) {
|
||||
if(!rename[field.oldName || field.name])
|
||||
rename[field.oldName || field.name] = { };
|
||||
if(!rename[field.oldName || field.name].values)
|
||||
rename[field.oldName || field.name].values = { };
|
||||
|
||||
rename[field.oldName || field.name].values![option.oldValue] = option.value;
|
||||
}
|
||||
|
||||
delete option.oldValue;
|
||||
}
|
||||
}
|
||||
|
||||
delete field.oldName;
|
||||
}
|
||||
|
||||
// We first update the type (without updating the styles). If that succeeds, we rename the data fields.
|
||||
// Only then we update the object styles (as they often depend on the field values).
|
||||
const newType = await this.database.types.updateType(this.padId, data.id, data, false)
|
||||
|
||||
if(Object.keys(rename).length > 0)
|
||||
await this.database.helpers.renameObjectDataField(this.padId, data.id, rename, newType.type == "line");
|
||||
|
||||
await this.database.types.recalculateObjectStylesForType(newType.padId, newType.id, newType.type == "line")
|
||||
|
||||
return newType;
|
||||
return await this.database.types.updateType(this.padId, data.id, data);
|
||||
},
|
||||
|
||||
deleteType: async (data) => {
|
||||
|
@ -404,7 +372,7 @@ export class SocketConnectionV2 extends SocketConnection {
|
|||
find: async (data) => {
|
||||
this.validatePermissions(Writable.READ);
|
||||
|
||||
return await find(data.query, data.loadUrls, data.elevation);
|
||||
return await find(data.query, data.loadUrls);
|
||||
},
|
||||
|
||||
findOnMap: async (data) => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { viewValidator } from "./view.js";
|
||||
import { idValidator, padIdValidator } from "./base.js";
|
||||
import * as z from "zod";
|
||||
import { CRU, type CRUType, cruValidator, optionalUpdate, optionalCreate, onlyRead } from "./cru";
|
||||
import { CRU, type CRUType, cruValidator, optionalUpdate, optionalCreate, onlyRead, onlyCreate } from "./cru";
|
||||
|
||||
export enum Writable {
|
||||
READ = 0,
|
||||
|
@ -31,6 +31,8 @@ export const padDataValidator = cruValidator({
|
|||
legend2: optionalCreate(z.string(), ""),
|
||||
defaultViewId: optionalCreate(idValidator.or(z.null()), null),
|
||||
|
||||
createDefaultTypes: onlyCreate(z.boolean().default(true)),
|
||||
|
||||
defaultView: onlyRead(viewValidator.read.or(z.null()))
|
||||
});
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { exportFormatValidator, idValidator, type ID } from "../base.js";
|
||||
import { exportFormatValidator, idValidator, type Bbox, type ID } from "../base.js";
|
||||
import { type PadData } from "../padData.js";
|
||||
import { type Marker } from "../marker.js";
|
||||
import { type Line, type TrackPoint } from "../line.js";
|
||||
|
@ -28,6 +28,8 @@ export const lineTemplateRequestValidator = z.object({
|
|||
});
|
||||
export type LineTemplateRequest = z.infer<typeof lineTemplateRequestValidator>;
|
||||
|
||||
export type LineTemplate = Omit<Line, "id" | "routePoints" | "extraInfo" | keyof Bbox | "distance" | "ascent" | "descent" | "time" | "padId">;
|
||||
|
||||
export const lineExportRequestValidator = z.object({
|
||||
id: idValidator,
|
||||
format: exportFormatValidator
|
||||
|
@ -42,8 +44,7 @@ export type RouteExportRequest = z.infer<typeof routeExportRequestValidator>;
|
|||
|
||||
export const findQueryValidator = z.object({
|
||||
query: z.string(),
|
||||
loadUrls: z.boolean().optional(),
|
||||
elevation: z.boolean().optional()
|
||||
loadUrls: z.boolean().optional()
|
||||
});
|
||||
export type FindQuery = z.infer<typeof findQueryValidator>;
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import { type View, viewValidator } from "../view.js";
|
|||
import type { MultipleEvents } from "../events.js";
|
||||
import type { SearchResult } from "../searchResult.js";
|
||||
import * as z from "zod";
|
||||
import { findPadsQueryValidator, getPadQueryValidator, type FindPadsResult, type PagedResults, type FindOnMapResult, lineTemplateRequestValidator, lineExportRequestValidator, findQueryValidator, findOnMapQueryValidator, routeExportRequestValidator, type LinePointsEvent, type RoutePointsEvent, nullOrUndefinedValidator } from "./socket-common";
|
||||
import { findPadsQueryValidator, getPadQueryValidator, type FindPadsResult, type PagedResults, type FindOnMapResult, lineTemplateRequestValidator, lineExportRequestValidator, findQueryValidator, findOnMapQueryValidator, routeExportRequestValidator, type LinePointsEvent, type RoutePointsEvent, nullOrUndefinedValidator, type LineTemplate } from "./socket-common";
|
||||
import type { HistoryEntry } from "../historyEntry";
|
||||
|
||||
export const requestDataValidatorsV2 = {
|
||||
|
@ -61,7 +61,7 @@ export interface ResponseDataMapV2 {
|
|||
addMarker: Marker;
|
||||
editMarker: Marker;
|
||||
deleteMarker: Marker;
|
||||
getLineTemplate: Line;
|
||||
getLineTemplate: LineTemplate;
|
||||
addLine: Line;
|
||||
editLine: Line;
|
||||
deleteLine: Line;
|
||||
|
|
|
@ -22,6 +22,28 @@ export const fieldOptionValidator = cruValidator({
|
|||
export type FieldOption<Mode extends CRU = CRU.READ> = CRUType<Mode, typeof fieldOptionValidator>;
|
||||
export type FieldOptionUpdate = FieldOption<CRU.UPDATE>;
|
||||
|
||||
const noDuplicateOptionValues = (options: Array<FieldOption<CRU>>, ctx: z.RefinementCtx) => {
|
||||
const values: Record<string, number> = {};
|
||||
for (const option of options) {
|
||||
values[option.value] = (values[option.value] ?? 0) + 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
if (values[options[i].value] > 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Dropdown option values must be unique.",
|
||||
path: [i, "value"]
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
export const fieldOptionsValidator = {
|
||||
read: z.array(fieldOptionValidator.read).superRefine(noDuplicateOptionValues),
|
||||
create: z.array(fieldOptionValidator.create).superRefine(noDuplicateOptionValues),
|
||||
update: z.array(fieldOptionValidator.update).superRefine(noDuplicateOptionValues)
|
||||
};
|
||||
|
||||
export const fieldValidator = cruValidator({
|
||||
name: z.string().trim().min(1),
|
||||
type: fieldTypeValidator,
|
||||
|
@ -34,9 +56,9 @@ export const fieldValidator = cruValidator({
|
|||
controlStroke: z.boolean().optional(),
|
||||
|
||||
options: {
|
||||
read: z.array(fieldOptionValidator.read).optional(),
|
||||
create: z.array(fieldOptionValidator.create).optional(),
|
||||
update: z.array(fieldOptionValidator.update).optional()
|
||||
read: fieldOptionsValidator.read.optional(),
|
||||
create: fieldOptionsValidator.create.optional(),
|
||||
update: fieldOptionsValidator.update.optional()
|
||||
},
|
||||
|
||||
oldName: onlyUpdate(z.string().optional())
|
||||
|
@ -45,12 +67,35 @@ export const fieldValidator = cruValidator({
|
|||
export type Field<Mode extends CRU = CRU.READ> = CRUType<Mode, typeof fieldValidator>;
|
||||
export type FieldUpdate = Field<CRU.UPDATE>;
|
||||
|
||||
const noDuplicateFieldNames = (fields: Array<Field<CRU>>, ctx: z.RefinementCtx) => {
|
||||
const fieldNames: Record<string, number> = {};
|
||||
for (const field of fields) {
|
||||
fieldNames[field.name] = (fieldNames[field.name] ?? 0) + 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
if (fieldNames[fields[i].name] > 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Field names must be unique.",
|
||||
path: [i, "name"]
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
export const fieldsValidator = {
|
||||
read: z.array(fieldValidator.read).superRefine(noDuplicateFieldNames),
|
||||
create: z.array(fieldValidator.create).superRefine(noDuplicateFieldNames),
|
||||
update: z.array(fieldValidator.update).superRefine(noDuplicateFieldNames)
|
||||
};
|
||||
|
||||
const rawTypeValidator = cruValidator({
|
||||
id: exceptCreate(idValidator),
|
||||
type: exceptUpdate(objectTypeValidator),
|
||||
padId: onlyRead(padIdValidator),
|
||||
|
||||
name: optionalUpdate(z.string().trim().min(1).max(100)),
|
||||
idx: optionalCreate(z.number().int().min(0)),
|
||||
|
||||
defaultColour: optionalCreate(colourValidator), // Default value is applied below
|
||||
colourFixed: optionalCreate(z.boolean(), false),
|
||||
|
@ -69,9 +114,9 @@ const rawTypeValidator = cruValidator({
|
|||
showInLegend: optionalCreate(z.boolean(), false),
|
||||
|
||||
fields: {
|
||||
read: z.array(fieldValidator.read),
|
||||
create: z.array(fieldValidator.create).default(() => [ { name: "Description", type: "textarea" as const } ]),
|
||||
update: z.array(fieldValidator.update)
|
||||
read: fieldsValidator.read,
|
||||
create: fieldsValidator.create.default(() => [ { name: "Description", type: "textarea" as const } ]),
|
||||
update: fieldsValidator.update.optional()
|
||||
}
|
||||
});
|
||||
export const typeValidator = {
|
||||
|
|
|
@ -7,6 +7,7 @@ export const viewValidator = cruValidator({
|
|||
padId: onlyRead(padIdValidator),
|
||||
|
||||
name: optionalUpdate(z.string().trim().min(1).max(100)),
|
||||
idx: optionalCreate(z.number().int().min(0)),
|
||||
...mapValues(bboxValidator.shape, optionalUpdate),
|
||||
baseLayer: optionalUpdate(layerValidator),
|
||||
layers: optionalUpdate(z.array(layerValidator)),
|
||||
|
|
|
@ -44,7 +44,8 @@
|
|||
"linkify-string": "^4.1.3",
|
||||
"linkifyjs": "^4.1.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^12.0.1"
|
||||
"marked": "^12.0.1",
|
||||
"p-throttle": "^6.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
|
|
@ -13,8 +13,8 @@ test("matchLonLat", () => {
|
|||
expect(matchLonLat("1,-2")).toEqual({ lat: 1, lon: -2 });
|
||||
|
||||
// With unicode minus
|
||||
expect(matchLonLat("\u22121.234,2.345")).toEqual({ lat: -1.234, lon: 2.345 });
|
||||
expect(matchLonLat("1.234,\u22122.345")).toEqual({ lat: 1.234, lon: -2.345 });
|
||||
expect(matchLonLat("−1.234,2.345")).toEqual({ lat: -1.234, lon: 2.345 });
|
||||
expect(matchLonLat("1.234,−2.345")).toEqual({ lat: 1.234, lon: -2.345 });
|
||||
|
||||
// With spaces
|
||||
expect(matchLonLat(" - 1.234 , - 2.345 ")).toEqual({ lat: -1.234, lon: -2.345 });
|
||||
|
@ -48,9 +48,9 @@ test("matchLonLat", () => {
|
|||
expect(matchLonLat("-1 ° 24 ' -2 ° 36 '")).toEqual({ lat: -1.4, lon: -2.6 });
|
||||
|
||||
// With unicode minute sign
|
||||
expect(matchLonLat("-1deg 24\u2032 -2deg 36\u2032")).toEqual({ lat: -1.4, lon: -2.6 });
|
||||
expect(matchLonLat("-1deg 24\u2032, -2deg 36\u2032")).toEqual({ lat: -1.4, lon: -2.6 });
|
||||
expect(matchLonLat("-1 deg 24 \u2032 -2 deg 36 \u2032")).toEqual({ lat: -1.4, lon: -2.6 });
|
||||
expect(matchLonLat("-1deg 24′ -2deg 36′")).toEqual({ lat: -1.4, lon: -2.6 });
|
||||
expect(matchLonLat("-1deg 24′, -2deg 36′")).toEqual({ lat: -1.4, lon: -2.6 });
|
||||
expect(matchLonLat("-1 deg 24 ′ -2 deg 36 ′")).toEqual({ lat: -1.4, lon: -2.6 });
|
||||
|
||||
// With seconds
|
||||
expect(matchLonLat("-1° 24' 36\" -2° 36' 72\"")).toEqual({ lat: -1.41, lon: -2.62 });
|
||||
|
@ -59,24 +59,41 @@ test("matchLonLat", () => {
|
|||
expect(matchLonLat("-1° 36\" -2° 72\"")).toEqual({ lat: -1.01, lon: -2.02 });
|
||||
|
||||
// With unicode second sign
|
||||
expect(matchLonLat("-1deg 24\u2032 36\u2033 -2deg 36\u2032 72\u2033")).toEqual({ lat: -1.41, lon: -2.62 });
|
||||
expect(matchLonLat("-1deg 24\u2032 36\u2033, -2deg 36\u2032 72\u2033")).toEqual({ lat: -1.41, lon: -2.62 });
|
||||
expect(matchLonLat("-1 deg 24 \u2032 36 \u2033 -2 deg 36 \u2032 72 \u2033")).toEqual({ lat: -1.41, lon: -2.62 });
|
||||
expect(matchLonLat("-1deg 36\u2033 -2deg 72\u2033")).toEqual({ lat: -1.01, lon: -2.02 });
|
||||
expect(matchLonLat("-1deg 24′ 36″ -2deg 36′ 72″")).toEqual({ lat: -1.41, lon: -2.62 });
|
||||
expect(matchLonLat("-1deg 24′ 36″, -2deg 36′ 72″")).toEqual({ lat: -1.41, lon: -2.62 });
|
||||
expect(matchLonLat("-1 deg 24 ′ 36 ″ -2 deg 36 ′ 72 ″")).toEqual({ lat: -1.41, lon: -2.62 });
|
||||
expect(matchLonLat("-1deg 36″ -2deg 72″")).toEqual({ lat: -1.01, lon: -2.02 });
|
||||
|
||||
// With unicode quote signs
|
||||
expect(matchLonLat("-1deg 24’ 36” -2deg 36’ 72”")).toEqual({ lat: -1.41, lon: -2.62 });
|
||||
expect(matchLonLat("-1deg 24’ 36”, -2deg 36’ 72”")).toEqual({ lat: -1.41, lon: -2.62 });
|
||||
expect(matchLonLat("-1 deg 24 ’ 36 ” -2 deg 36 ’ 72 ”")).toEqual({ lat: -1.41, lon: -2.62 });
|
||||
expect(matchLonLat("-1deg 36” -2deg 72”")).toEqual({ lat: -1.01, lon: -2.02 });
|
||||
|
||||
// Other hemisphere
|
||||
expect(matchLonLat("1° 24' N 2° 36' E")).toEqual({ lat: 1.4, lon: 2.6 });
|
||||
expect(matchLonLat("N 1° 24' E 2° 36'")).toEqual({ lat: 1.4, lon: 2.6 });
|
||||
expect(matchLonLat("1° 24' S 2° 36' E")).toEqual({ lat: -1.4, lon: 2.6 });
|
||||
expect(matchLonLat("S 1° 24' E 2° 36'")).toEqual({ lat: -1.4, lon: 2.6 });
|
||||
expect(matchLonLat("1° 24' N 2° 36' W")).toEqual({ lat: 1.4, lon: -2.6 });
|
||||
expect(matchLonLat("N 1° 24' W 2° 36'")).toEqual({ lat: 1.4, lon: -2.6 });
|
||||
expect(matchLonLat("1° 24' s 2° 36' w")).toEqual({ lat: -1.4, lon: -2.6 });
|
||||
expect(matchLonLat("s 1° 24' w 2° 36'")).toEqual({ lat: -1.4, lon: -2.6 });
|
||||
|
||||
// Switch lon/lat
|
||||
expect(matchLonLat("1° 24' E 2° 36'")).toEqual({ lat: 2.6, lon: 1.4 });
|
||||
expect(matchLonLat("1° 24' E 2° 36' N")).toEqual({ lat: 2.6, lon: 1.4 });
|
||||
expect(matchLonLat("E 1° 24' N 2° 36'")).toEqual({ lat: 2.6, lon: 1.4 });
|
||||
expect(matchLonLat("1° 24' E 2° 36' S")).toEqual({ lat: -2.6, lon: 1.4 });
|
||||
expect(matchLonLat("1° 24' W 2° 36'")).toEqual({ lat: 2.6, lon: -1.4 });
|
||||
expect(matchLonLat("E 1° 24' S 2° 36'")).toEqual({ lat: -2.6, lon: 1.4 });
|
||||
expect(matchLonLat("1° 24' W 2° 36' N")).toEqual({ lat: 2.6, lon: -1.4 });
|
||||
expect(matchLonLat("W 1° 24' N 2° 36'")).toEqual({ lat: 2.6, lon: -1.4 });
|
||||
expect(matchLonLat("1° 24' W 2° 36' S")).toEqual({ lat: -2.6, lon: -1.4 });
|
||||
expect(matchLonLat("W 1° 24' S 2° 36'")).toEqual({ lat: -2.6, lon: -1.4 });
|
||||
|
||||
// Practical examples
|
||||
expect(matchLonLat("N 53°53’42.8928” E 10°44’13.4844”")).toEqual({ lat: 53.895248, lon: 10.737079 }); // Park4night
|
||||
expect(matchLonLat("53°53'42.9\"N 10°44'13.5\"E")).toEqual({ lat: 53.895250, lon: expect.closeTo(10.737083, 6) }); // Google Maps
|
||||
expect(matchLonLat("55°41′34.3″N 12°35′57.4″E")).toEqual({ lat: expect.closeTo(55.692861, 6), lon: expect.closeTo(12.599278, 6) }); // Wikipedia
|
||||
|
||||
// Invalid lon/lat combination
|
||||
expect(matchLonLat("1° 24' N 2° 36' N")).toEqual(undefined);
|
||||
|
@ -87,4 +104,12 @@ test("matchLonLat", () => {
|
|||
expect(matchLonLat("1° 24' S 2° 36' N")).toEqual(undefined);
|
||||
expect(matchLonLat("1° 24' W 2° 36' E")).toEqual(undefined);
|
||||
expect(matchLonLat("1° 24' E 2° 36' W")).toEqual(undefined);
|
||||
|
||||
// Invalid hemisphere prefix/suffix combination
|
||||
expect(matchLonLat("N 1° 24' 2° 36'")).toEqual(undefined);
|
||||
expect(matchLonLat("1° 24' E 2° 36'")).toEqual(undefined);
|
||||
expect(matchLonLat("1° 24' 2° 36' E")).toEqual(undefined);
|
||||
expect(matchLonLat("N 1° 24' E 2° 36' E")).toEqual(undefined);
|
||||
expect(matchLonLat("N 1° 24' 2° 36' E")).toEqual(undefined);
|
||||
expect(matchLonLat("1° 24' E N 2° 36'")).toEqual(undefined);
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import { expect, test } from "vitest";
|
||||
import { mergeObject } from "../utils";
|
||||
import { insertIdx, mergeObject } from "../utils";
|
||||
|
||||
test('mergeObject', () => {
|
||||
interface TestObject {
|
||||
|
@ -107,4 +107,102 @@ test('mergeObject', () => {
|
|||
test('mergeObject prototype pollution', () => {
|
||||
mergeObject({}, JSON.parse('{"__proto__":{"test": "test"}}'), {});
|
||||
expect(({} as any).test).toBeUndefined();
|
||||
});
|
||||
|
||||
test("insertAtIdx", () => {
|
||||
const list = [
|
||||
{ id: 0, idx: 0 },
|
||||
{ id: 1, idx: 1 },
|
||||
{ id: 2, idx: 3 }
|
||||
];
|
||||
|
||||
// Create new item
|
||||
|
||||
expect(insertIdx(list, undefined, 0)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 1 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 2 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 3 }
|
||||
]);
|
||||
|
||||
expect(insertIdx(list, undefined, 1)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 0 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 2 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 3 }
|
||||
]);
|
||||
|
||||
expect(insertIdx(list, undefined, 2)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 0 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 1 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 3 }
|
||||
]);
|
||||
|
||||
expect(insertIdx(list, undefined, 3)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 0 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 1 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 4 }
|
||||
]);
|
||||
|
||||
expect(insertIdx(list, undefined, 4)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 0 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 1 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 3 }
|
||||
]);
|
||||
|
||||
|
||||
// Move existing item down
|
||||
expect(insertIdx(list, 0, 0)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 0 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 1 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 3 }
|
||||
]);
|
||||
|
||||
expect(insertIdx(list, 0, 1)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 1 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 2 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 3 }
|
||||
]);
|
||||
|
||||
expect(insertIdx(list, 0, 2)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 2 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 1 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 3 }
|
||||
]);
|
||||
|
||||
expect(insertIdx(list, 0, 3)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 3 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 1 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 4 }
|
||||
]);
|
||||
|
||||
expect(insertIdx(list, 0, 4)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 4 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 1 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 3 }
|
||||
]);
|
||||
|
||||
|
||||
// Move existing item down
|
||||
expect(insertIdx(list, 2, 0)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 1 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 2 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 0 }
|
||||
]);
|
||||
|
||||
expect(insertIdx(list, 2, 1)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 0 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 2 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 1 }
|
||||
]);
|
||||
|
||||
expect(insertIdx(list, 2, 2)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 0 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 1 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 2 }
|
||||
]);
|
||||
|
||||
expect(insertIdx(list, 2, 3)).toEqual([
|
||||
{ id: 0, oldIdx: 0, newIdx: 0 },
|
||||
{ id: 1, oldIdx: 1, newIdx: 1 },
|
||||
{ id: 2, oldIdx: 3, newIdx: 3 }
|
||||
]);
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
export interface Config {
|
||||
openElevationApiUrl: string;
|
||||
openElevationThrottleMs: number;
|
||||
openElevationMaxBatchSize: number;
|
||||
nominatimUrl: string;
|
||||
}
|
||||
|
||||
export let fetchAdapter = fetch;
|
||||
|
||||
export function setFetchAdapter(newFetchAdapter: typeof fetchAdapter): void {
|
||||
fetchAdapter = newFetchAdapter;
|
||||
}
|
||||
|
||||
let config: Config | undefined;
|
||||
|
||||
export function getConfig(): Config {
|
||||
if (!config) {
|
||||
throw new Error("Config is not initialized.");
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function setConfig(newConfig: Config): void {
|
||||
config = newConfig;
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
import type { Point } from "facilmap-types";
|
||||
import { RetryError, throttledBatch } from "./utils";
|
||||
import { fetchAdapter, getConfig } from "./config";
|
||||
|
||||
const MAX_DELAY_MS = 60_000;
|
||||
|
||||
let retryCount = 0;
|
||||
let delayMs = () => Math.min(MAX_DELAY_MS, getConfig().openElevationThrottleMs * (2 ** retryCount));
|
||||
let maxBatchSize = () => Math.max(1, Math.floor(getConfig().openElevationMaxBatchSize / (2 ** retryCount)));
|
||||
export const getElevationForPoint = throttledBatch<[Point], number | undefined>(async (args) => {
|
||||
const res = await fetchAdapter(`${getConfig().openElevationApiUrl}/api/v1/lookup`, {
|
||||
method: "post",
|
||||
headers: {
|
||||
"Content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
locations: args.map(([point]) => ({ latitude: point.lat, longitude: point.lon }))
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
let error = new Error(`Looking up elevations failed with status ${res.status}.`);
|
||||
if (res.status === 504) {
|
||||
// Probably caused by an overload on the server. Usually it goes away after a while. Let's exponentially increase delays
|
||||
// between requests until it succeeds again.
|
||||
retryCount++;
|
||||
console.warn(`Looking up elevations failed with status ${res.status}, retrying (delay ${delayMs()/1000}s, batch size ${maxBatchSize()}).`);
|
||||
throw new RetryError(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (retryCount > 0) {
|
||||
console.log(`Looking up elevations retry succeeded.`);
|
||||
retryCount = 0;
|
||||
}
|
||||
|
||||
const json: { results: Array<{ latitude: number; longitude: number; elevation: number }> } = await res.json();
|
||||
|
||||
return json.results.map((result: any) => {
|
||||
if (result.elevation !== 0) {
|
||||
return result.elevation;
|
||||
}
|
||||
});
|
||||
}, {
|
||||
delayMs,
|
||||
maxSize: maxBatchSize,
|
||||
maxRetries: Infinity,
|
||||
noParallel: true
|
||||
});
|
||||
|
||||
interface AscentDescent {
|
||||
ascent: number | undefined;
|
||||
descent: number | undefined;
|
||||
}
|
||||
|
||||
export function getAscentDescent(elevations: Array<number | null>): AscentDescent {
|
||||
if(!elevations.some((ele) => (ele != null))) {
|
||||
return {
|
||||
ascent: undefined,
|
||||
descent: undefined
|
||||
};
|
||||
}
|
||||
|
||||
const ret: AscentDescent = {
|
||||
ascent: 0,
|
||||
descent: 0
|
||||
};
|
||||
|
||||
let last: number | null = null;
|
||||
|
||||
for(const ele of elevations) {
|
||||
if(last == null || ele == null)
|
||||
continue;
|
||||
|
||||
if(ele > last)
|
||||
ret.ascent! += ele - last;
|
||||
else
|
||||
ret.descent! += last - ele;
|
||||
|
||||
last = ele;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { marked, type MarkedOptions } from "marked";
|
||||
import type { Field } from "facilmap-types";
|
||||
import type { Field, Point } from "facilmap-types";
|
||||
import { quoteHtml } from "./utils.js";
|
||||
import linkifyStr from "linkify-string";
|
||||
import createPurify from "dompurify";
|
||||
|
@ -147,4 +147,8 @@ export function renderOsmTag(key: string, value: string): string {
|
|||
target: (href: string, type: string) => type === "url" ? "_blank" : ""
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCoordinates(point: Point): string {
|
||||
return `${point.lat.toFixed(5)},${point.lon.toFixed(5)}`;
|
||||
}
|
|
@ -1,6 +1,9 @@
|
|||
export * from "./config.js";
|
||||
export * from "./elevation.js";
|
||||
export * from "./filter.js";
|
||||
export * from "./format.js";
|
||||
export * from "./objects.js";
|
||||
export * from "./pads.js";
|
||||
export * from "./routing.js";
|
||||
export * from "./search.js";
|
||||
export * from "./types.js";
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { lineValidator, markerValidator, type CRU, type Field, type FieldOption, type Line, type Marker, type Type } from "facilmap-types";
|
||||
import { lineValidator, markerValidator, type CRU, type Field, type FieldOption, type Line, type LineTemplate, type Marker, type Type } from "facilmap-types";
|
||||
import { omit } from "lodash-es";
|
||||
|
||||
export function isMarker<Mode extends CRU.READ | CRU.CREATE>(object: Marker<Mode> | Line<Mode>): object is Marker<Mode> {
|
||||
return "lat" in object && object.lat != null;
|
||||
|
@ -90,8 +91,8 @@ export function resolveCreateMarker(marker: Marker<CRU.CREATE>, type: Type): Mar
|
|||
return result;
|
||||
}
|
||||
|
||||
export function resolveUpdateMarker(marker: Marker, update: Omit<Marker<CRU.UPDATE>, "id">, newType: Type): Marker<CRU.UPDATE_VALIDATED> {
|
||||
const resolvedUpdate = markerValidator.update.parse(update);
|
||||
export function resolveUpdateMarker(marker: Marker, update: Omit<Marker<CRU.UPDATE>, "id">, newType: Type): Omit<Marker<CRU.UPDATE_VALIDATED>, "id"> {
|
||||
const resolvedUpdate = markerValidator.update.omit({ id: true }).parse(update);
|
||||
return {
|
||||
...resolvedUpdate,
|
||||
...applyMarkerStyles({ ...marker, ...resolvedUpdate }, newType)
|
||||
|
@ -148,10 +149,28 @@ export function resolveCreateLine(line: Line<CRU.CREATE>, type: Type): Line<CRU.
|
|||
return result;
|
||||
}
|
||||
|
||||
export function resolveUpdateLine(line: Line, update: Omit<Line<CRU.UPDATE>, "id">, newType: Type): Line<CRU.UPDATE_VALIDATED> {
|
||||
const resolvedUpdate = lineValidator.update.parse(update);
|
||||
export function resolveUpdateLine(line: Line, update: Omit<Line<CRU.UPDATE>, "id">, newType: Type): Omit<Line<CRU.UPDATE_VALIDATED>, "id"> {
|
||||
const resolvedUpdate = lineValidator.update.omit({ id: true }).parse(update);
|
||||
return {
|
||||
...resolvedUpdate,
|
||||
...applyLineStyles({ ...line, ...resolvedUpdate }, newType)
|
||||
};
|
||||
}
|
||||
|
||||
export function getLineTemplate(type: Type): LineTemplate {
|
||||
return {
|
||||
data: {},
|
||||
...omit(resolveCreateLine({
|
||||
typeId: type.id,
|
||||
routePoints: [{ lat: 0, lon: 0 }, { lat: 0, lon: 0 }]
|
||||
}, type), ["routePoints", "extraInfo", "trackPoints"]),
|
||||
} as LineTemplate;
|
||||
}
|
||||
|
||||
export function normalizeMarkerName(name: string | undefined): string {
|
||||
return name || "Untitled marker";
|
||||
}
|
||||
|
||||
export function normalizeLineName(name: string | undefined): string {
|
||||
return name || "Untitled line";
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import type { ID, Type, View } from "facilmap-types";
|
||||
|
||||
const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
const LENGTH = 12;
|
||||
|
||||
export function generateRandomPadId(length: number = LENGTH): string {
|
||||
let randomPadId = "";
|
||||
for(let i=0; i<length; i++) {
|
||||
randomPadId += LETTERS[Math.floor(Math.random() * LETTERS.length)];
|
||||
}
|
||||
return randomPadId;
|
||||
}
|
||||
|
||||
export function normalizePadName(name: string | undefined): string {
|
||||
return name || "Unnamed map";
|
||||
}
|
||||
|
||||
export function normalizePageTitle(padName: string | undefined, appName: string): string {
|
||||
return `${padName ? `${padName} – ` : ''}${appName}`;
|
||||
}
|
||||
|
||||
export function normalizePageDescription(padDescription: string | undefined): string {
|
||||
return padDescription || "A fully-featured OpenStreetMap-based map where markers and lines can be added with live collaboration.";
|
||||
}
|
||||
|
||||
export function getOrderedTypes(types: Type[] | Record<ID, Type>): Type[] {
|
||||
const typeArr = Array.isArray(types) ? [...types] : Object.values(types);
|
||||
return typeArr.sort((a, b) => a.idx - b.idx);
|
||||
}
|
||||
|
||||
export function getOrderedViews(views: View[] | Record<ID, View>): View[] {
|
||||
const typeArr = Array.isArray(views) ? [...views] : Object.values(views);
|
||||
return typeArr.sort((a, b) => a.idx - b.idx);
|
||||
}
|
|
@ -1,4 +1,80 @@
|
|||
import type { Point } from "facilmap-types";
|
||||
import type { Point, SearchResult, ZoomLevel } from "facilmap-types";
|
||||
import throttle from "p-throttle";
|
||||
import type { Geometry } from "geojson";
|
||||
import { formatCoordinates } from "./format";
|
||||
import { fetchAdapter, getConfig } from "./config";
|
||||
|
||||
interface NominatimResult {
|
||||
place_id: number;
|
||||
license: string;
|
||||
osm_type: "node" | "way" | "relation";
|
||||
osm_id: number;
|
||||
boundingbox: [string, string, string, string];
|
||||
lat: string;
|
||||
lon: string;
|
||||
zoom?: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
place_rank: number;
|
||||
category: string;
|
||||
type: string;
|
||||
importance: number;
|
||||
icon: string;
|
||||
address: Partial<Record<string, string>>;
|
||||
geojson: Geometry;
|
||||
extratags: Record<string, string>;
|
||||
namedetails: Record<string, string> | null;
|
||||
}
|
||||
|
||||
interface NominatimError {
|
||||
error: { code?: number; message: string } | string;
|
||||
}
|
||||
|
||||
const limit = 25;
|
||||
const stateAbbr: Record<string, Record<string, string>> = {
|
||||
"us" : {
|
||||
"alabama":"AL","alaska":"AK","arizona":"AZ","arkansas":"AR","california":"CA","colorado":"CO","connecticut":"CT",
|
||||
"delaware":"DE","florida":"FL","georgia":"GA","hawaii":"HI","idaho":"ID","illinois":"IL","indiana":"IN","iowa":"IA",
|
||||
"kansas":"KS","kentucky":"KY","louisiana":"LA","maine":"ME","maryland":"MD","massachusetts":"MA","michigan":"MI",
|
||||
"minnesota":"MN","mississippi":"MS","missouri":"MO","montana":"MT","nebraska":"NE","nevada":"NV","new hampshire":"NH",
|
||||
"new jersey":"NJ","new mexico":"NM","new york":"NY","north carolina":"NC","north dakota":"ND","ohio":"OH","oklahoma":"OK",
|
||||
"oregon":"OR","pennsylvania":"PA","rhode island":"RI","south carolina":"SC","south dakota":"SD","tennessee":"TN",
|
||||
"texas":"TX","utah":"UT","vermont":"VT","virginia":"VA","washington":"WA","west virginia":"WV","wisconsin":"WI","wyoming":"WY"
|
||||
},
|
||||
"it" : {
|
||||
"agrigento":"AG","alessandria":"AL","ancona":"AN","aosta":"AO","arezzo":"AR","ascoli piceno":"AP","asti":"AT",
|
||||
"avellino":"AV","bari":"BA","barletta":"BT","barletta-andria-trani":"BT","belluno":"BL","benevento":"BN",
|
||||
"bergamo":"BG","biella":"BI","bologna":"BO","bolzano":"BZ","brescia":"BS","brindisi":"BR","cagliari":"CA",
|
||||
"caltanissetta":"CL","campobasso":"CB","carbonia-iglesias":"CI","caserta":"CE","catania":"CT","catanzaro":"CZ",
|
||||
"chieti":"CH","como":"CO","cosenza":"CS","cremona":"CR","crotone":"KR","cuneo":"CN","enna":"EN","fermo":"FM",
|
||||
"ferrara":"FE","firenze":"FI","foggia":"FG","forli-cesena":"FC","frosinone":"FR","genova":"GE","gorizia":"GO",
|
||||
"grosseto":"GR","imperia":"IM","isernia":"IS","la spezia":"SP","l'aquila":"AQ","latina":"LT","lecce":"LE",
|
||||
"lecco":"LC","livorno":"LI","lodi":"LO","lucca":"LU","macerata":"MC","mantova":"MN","massa e carrara":"MS",
|
||||
"matera":"MT","medio campidano":"VS","messina":"ME","milano":"MI","modena":"MO","monza e brianza":"MB",
|
||||
"napoli":"NA","novara":"NO","nuoro":"NU","ogliastra":"OG","olbia-tempio":"OT","oristano":"OR","padova":"PD",
|
||||
"palermo":"PA","parma":"PR","pavia":"PV","perugia":"PG","pesaro e urbino":"PU","pescara":"PE","piacenza":"PC",
|
||||
"pisa":"PI","pistoia":"PT","pordenone":"PN","potenza":"PZ","prato":"PO","ragusa":"RG","ravenna":"RA",
|
||||
"reggio calabria":"RC","reggio emilia":"RE","rieti":"RI","rimini":"RN","roma":"RM","rovigo":"RO","salerno":"SA",
|
||||
"sassari":"SS","savona":"SV","siena":"SI","siracusa":"SR","sondrio":"SO","taranto":"TA","teramo":"TE","terni":"TR",
|
||||
"torino":"TO","trapani":"TP","trento":"TN","treviso":"TV","trieste":"TS","udine":"UD","varese":"VA","venezia":"VE",
|
||||
"verbano":"VB","verbano-cusio-ossola":"VB","vercelli":"VC","verona":"VR","vibo valentia":"VV","vicenza":"VI","viterbo":"VT"
|
||||
},
|
||||
"ca" : {
|
||||
"ontario":"ON","quebec":"QC","nova scotia":"NS","new brunswick":"NB","manitoba":"MB","british columbia":"BC",
|
||||
"prince edward island":"PE","saskatchewan":"SK","alberta":"AB","newfoundland and labrador":"NL"
|
||||
},
|
||||
"au" : {
|
||||
"australian capital territory":"ACT","jervis bay territory":"JBT","new south wales":"NSW","northern territory":"NT",
|
||||
"queensland":"QLD","south australia":"SA","tasmania":"TAS","victoria":"VIC","western australia":"WA"
|
||||
}
|
||||
};
|
||||
|
||||
interface PointWithZoom extends Point {
|
||||
zoom?: ZoomLevel;
|
||||
}
|
||||
|
||||
// Respect Nominatim rate limit (https://operations.osmfoundation.org/policies/nominatim/)
|
||||
const throttledFetch = throttle({ limit: 1, interval: 1000 })((...args: Parameters<typeof fetchAdapter>) => fetchAdapter(...args));
|
||||
|
||||
export function splitRouteQuery(query: string): { queries: string[], mode: string | null } {
|
||||
const splitQuery = query.split(/(^|\s+)(from|to|via|by)(\s+|$)/).filter((item, i) => (i%2 == 0)); // Filter out every second item (whitespace parantheses)
|
||||
|
@ -117,4 +193,431 @@ export function decodeShortLink(encoded: string): DecodedGeoQuery | undefined {
|
|||
|
||||
export function isSearchId(string: string | undefined): boolean {
|
||||
return !!string?.match(/^[nwr]\d+$/i);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseUrlQuery(query: string): string | undefined {
|
||||
query = query.replace(/^\s+/, "").replace(/\s+$/, "");
|
||||
|
||||
let m = query.match(/^(node|way|relation)\s+(\d+)$/);
|
||||
if(m)
|
||||
return `https://api.openstreetmap.org/api/0.6/${m[1]}/${m[2]}${m[1] != "node" ? "/full" : ""}`;
|
||||
|
||||
m = query.match(/^trace\s+(\d+)$/);
|
||||
if(m)
|
||||
return `https://www.openstreetmap.org/trace/${m[1]}/data`;
|
||||
|
||||
if(query.match(/^https?:\/\//))
|
||||
return query;
|
||||
}
|
||||
|
||||
export async function find(query: string): Promise<Array<SearchResult>> {
|
||||
query = query.replace(/^\s+/, "").replace(/\s+$/, "");
|
||||
|
||||
const lonlat_match = matchLonLat(query);
|
||||
if(lonlat_match) {
|
||||
const result = await _findLonLat(lonlat_match);
|
||||
return result.map((res) => ({ ...res, id: query }));
|
||||
}
|
||||
|
||||
const osm_match = query.match(/^([nwr])(\d+)$/i);
|
||||
if(osm_match)
|
||||
return await _findOsmObject(osm_match[1], osm_match[2]);
|
||||
|
||||
return await _findQuery(query);
|
||||
}
|
||||
|
||||
export function getFallbackLonLatResult(pointWithZoom: PointWithZoom): SearchResult {
|
||||
const name = formatCoordinates(pointWithZoom);
|
||||
return {
|
||||
lat: pointWithZoom.lat,
|
||||
lon: pointWithZoom.lon,
|
||||
type: "coordinates",
|
||||
short_name: name,
|
||||
display_name: name,
|
||||
zoom: pointWithZoom.zoom != null ? pointWithZoom.zoom : 15,
|
||||
icon: undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function _findLonLat(lonlatWithZoom: PointWithZoom): Promise<Array<SearchResult>> {
|
||||
const res = await throttledFetch(
|
||||
`${getConfig().nominatimUrl}/reverse?format=jsonv2&addressdetails=1&polygon_geojson=0&extratags=1&namedetails=1&lat=${encodeURIComponent(lonlatWithZoom.lat)}&lon=${encodeURIComponent(lonlatWithZoom.lon)}&zoom=${encodeURIComponent(lonlatWithZoom.zoom != null ? (lonlatWithZoom.zoom >= 12 ? lonlatWithZoom.zoom+2 : lonlatWithZoom.zoom) : 17)}`
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Reverse geocoding failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
const body: NominatimResult | NominatimError = await res.json();
|
||||
|
||||
if("error" in body) {
|
||||
throw new Error(typeof body.error === 'string' ? body.error : body.error.message);
|
||||
}
|
||||
|
||||
body.lat = `${lonlatWithZoom.lat}`;
|
||||
body.lon = `${lonlatWithZoom.lon}`;
|
||||
body.zoom = lonlatWithZoom.zoom || 15;
|
||||
|
||||
return [ _prepareSearchResult(body) ];
|
||||
}
|
||||
|
||||
async function _findQuery(query: string): Promise<Array<SearchResult>> {
|
||||
const res = await throttledFetch(
|
||||
getConfig().nominatimUrl + "/search?format=jsonv2&polygon_geojson=1&addressdetails=1&namedetails=1&limit=" + encodeURIComponent(limit) + "&extratags=1&q=" + encodeURIComponent(query),
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Search failed with status ${res.status}.`);
|
||||
}
|
||||
|
||||
const body: Array<NominatimResult> | NominatimError = await res.json();
|
||||
|
||||
if ('error' in body) {
|
||||
throw new Error(typeof body.error === 'string' ? body.error : body.error.message);
|
||||
}
|
||||
|
||||
|
||||
return body.map(_prepareSearchResult);
|
||||
}
|
||||
|
||||
async function _findOsmObject(type: string, id: string): Promise<Array<SearchResult>> {
|
||||
const body: Array<NominatimResult> | NominatimError = await throttledFetch(
|
||||
`${getConfig().nominatimUrl}/lookup?format=jsonv2&addressdetails=1&polygon_geojson=1&extratags=1&namedetails=1&osm_ids=${encodeURI(type.toUpperCase())}${encodeURI(id)}`
|
||||
).then((res) => res.json() as any);
|
||||
|
||||
if(!body)
|
||||
throw new Error("Invalid response from name finder.");
|
||||
|
||||
if('error' in body)
|
||||
throw new Error(typeof body.error === 'string' ? body.error : body.error.message);
|
||||
|
||||
return body.map(_prepareSearchResult);
|
||||
}
|
||||
|
||||
function _prepareSearchResult(result: NominatimResult): SearchResult {
|
||||
const { address, nameWithAddress, name } = _formatAddress(result);
|
||||
return {
|
||||
short_name: name,
|
||||
display_name: nameWithAddress,
|
||||
address,
|
||||
boundingbox: result.boundingbox?.map((n) => Number(n)) as [number, number, number, number],
|
||||
lat: Number(result.lat),
|
||||
lon: Number(result.lon),
|
||||
zoom: result.zoom,
|
||||
extratags: result.extratags,
|
||||
geojson: result.geojson,
|
||||
icon: result.icon && result.icon.replace(/^.*\/([a-z0-9_]+)\.[a-z0-9]+\.[0-9]+\.[a-z0-9]+$/i, "$1"),
|
||||
type: result.type == "yes" ? result.category : result.type,
|
||||
id: result.osm_id ? result.osm_type.charAt(0) + result.osm_id : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to format a search result in a readable way according to the address notation habits in
|
||||
* the appropriate country.
|
||||
* @param result {Object} A place object as returned by Nominatim
|
||||
* @return {Object} An object with address, nameWithAddress and name strings
|
||||
*/
|
||||
function _formatAddress(result: NominatimResult) {
|
||||
// See http://en.wikipedia.org/wiki/Address_%28geography%29#Mailing_address_format_by_country for
|
||||
// address notation guidelines
|
||||
|
||||
let type = result.type;
|
||||
let name = result.namedetails?.name ?? result.name;
|
||||
const countryCode = result.address.country_code;
|
||||
|
||||
let road = result.address.road;
|
||||
const housenumber = result.address.house_number;
|
||||
let suburb = result.address.town || result.address.suburb || result.address.village || result.address.hamlet || result.address.residential;
|
||||
const postcode = result.address.postcode;
|
||||
let city = result.address.city;
|
||||
let county = result.address.county;
|
||||
let state = result.address.state;
|
||||
const country = result.address.country;
|
||||
|
||||
if([ "road", "residential", "town", "suburb", "village", "hamlet", "residential", "city", "county", "state" ].indexOf(type) != -1)
|
||||
name = "";
|
||||
|
||||
if(!city && suburb) {
|
||||
city = suburb;
|
||||
suburb = "";
|
||||
}
|
||||
|
||||
if(road) {
|
||||
switch(countryCode) {
|
||||
case "pl":
|
||||
road = "ul. "+road;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add house number to road
|
||||
if(road && housenumber) {
|
||||
switch(countryCode) {
|
||||
case "ar":
|
||||
case "at":
|
||||
case "ca":
|
||||
case "de":
|
||||
case "hr":
|
||||
case "cz":
|
||||
case "dk":
|
||||
case "fi":
|
||||
case "is":
|
||||
case "il":
|
||||
case "it":
|
||||
case "nl":
|
||||
case "no":
|
||||
case "pe":
|
||||
case "pl":
|
||||
case "sk":
|
||||
case "si":
|
||||
case "se":
|
||||
case "tr":
|
||||
road += " "+housenumber;
|
||||
break;
|
||||
case "be":
|
||||
case "es":
|
||||
road += ", "+housenumber;
|
||||
break;
|
||||
case "cl":
|
||||
road += " N° "+housenumber;
|
||||
break;
|
||||
case "hu":
|
||||
road += " "+housenumber+".";
|
||||
break;
|
||||
case "id":
|
||||
road += " No. "+housenumber;
|
||||
break;
|
||||
case "my":
|
||||
road = "No." +housenumber+", "+road;
|
||||
break;
|
||||
case "ro":
|
||||
road += ", nr. "+housenumber;
|
||||
break;
|
||||
case "au":
|
||||
case "fr":
|
||||
case "hk":
|
||||
case "ie":
|
||||
case "in":
|
||||
case "nz":
|
||||
case "sg":
|
||||
case "lk":
|
||||
case "tw":
|
||||
case "gb":
|
||||
case "us":
|
||||
default:
|
||||
road = housenumber+" "+road;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add postcode and districts to city
|
||||
switch(countryCode) {
|
||||
case "ar":
|
||||
if(postcode && city)
|
||||
city = postcode+", "+city;
|
||||
else if(postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
case "at":
|
||||
case "ch":
|
||||
case "de":
|
||||
if(city) {
|
||||
if(suburb)
|
||||
city += "-"+(suburb);
|
||||
suburb = undefined;
|
||||
if(type == "suburb" || type == "residential")
|
||||
type = "city";
|
||||
|
||||
if(postcode)
|
||||
city = postcode+" "+city;
|
||||
} else if (postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
case "be":
|
||||
case "hr":
|
||||
case "cz":
|
||||
case "dk":
|
||||
case "fi":
|
||||
case "fr":
|
||||
case "hu":
|
||||
case "is":
|
||||
case "il":
|
||||
case "my":
|
||||
case "nl":
|
||||
case "no":
|
||||
case "sk":
|
||||
case "si":
|
||||
case "es":
|
||||
case "se":
|
||||
case "tr":
|
||||
if(city && postcode)
|
||||
city = postcode+" "+city;
|
||||
else if (postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
case "au":
|
||||
case "ca":
|
||||
case "us":
|
||||
if(city && state)
|
||||
{
|
||||
const thisStateAbbr = stateAbbr[countryCode][state.toLowerCase()];
|
||||
if(thisStateAbbr)
|
||||
{
|
||||
city += " "+thisStateAbbr;
|
||||
state = undefined;
|
||||
}
|
||||
}
|
||||
if(city && postcode)
|
||||
city += " "+postcode;
|
||||
else if(postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
case "it":
|
||||
if(city)
|
||||
{
|
||||
if(county)
|
||||
{
|
||||
const countyAbbr = stateAbbr.it[county.toLowerCase().replace(/ì/g, "i")];
|
||||
if(countyAbbr)
|
||||
{
|
||||
city += " ("+countyAbbr+")";
|
||||
county = undefined;
|
||||
}
|
||||
}
|
||||
if(postcode)
|
||||
city = postcode+" "+city;
|
||||
} else if (postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
case "ro":
|
||||
if(city && county)
|
||||
{
|
||||
city += ", jud. "+county;
|
||||
county = undefined;
|
||||
}
|
||||
if(city && postcode)
|
||||
city += ", "+postcode;
|
||||
else if (postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
case "cl":
|
||||
case "hk": // Postcode rarely/not used
|
||||
case "ie":
|
||||
case "in":
|
||||
case "id":
|
||||
case "nz":
|
||||
case "pe":
|
||||
case "sg":
|
||||
case "lk":
|
||||
case "tw":
|
||||
case "gb":
|
||||
default:
|
||||
if(city && postcode)
|
||||
city = city+" "+postcode;
|
||||
else if(postcode)
|
||||
city = postcode;
|
||||
break;
|
||||
}
|
||||
|
||||
const address = [ ];
|
||||
|
||||
if(road)
|
||||
address.push(road);
|
||||
if(suburb)
|
||||
address.push(suburb);
|
||||
if(city)
|
||||
address.push(city);
|
||||
if(["residential", "town", "suburb", "village", "hamlet", "residential", "city", "county", "state"].includes(type) || address.length == 0)
|
||||
{ // Searching for a town
|
||||
if(county && county != city)
|
||||
address.push(county);
|
||||
if(state && state != city)
|
||||
address.push(state);
|
||||
}
|
||||
|
||||
if(country)
|
||||
address.push(country);
|
||||
|
||||
const fullName = [ ...address ];
|
||||
if(name && name != address[0])
|
||||
fullName.unshift(name);
|
||||
|
||||
return {
|
||||
address: address.join(", "),
|
||||
nameWithAddress: fullName.join(", "),
|
||||
name: fullName[0]
|
||||
};
|
||||
}
|
||||
|
||||
const lonLatRegexp = (() => {
|
||||
const number = `[-\u2212]?\\s*\\d+([.,]\\d+)?`;
|
||||
|
||||
const getCoordinate = (n: number) => (
|
||||
`(` +
|
||||
`(?<hemispherePrefix${n}>[NWSE])` +
|
||||
`)?(` +
|
||||
`(?<degrees${n}>${number})` +
|
||||
`(\\s*[°]\\s*|\\s*deg\\s*|\\s+|$|(?!\\d))` +
|
||||
`)(` +
|
||||
`(?<minutes${n}>${number})` +
|
||||
`(\\s*['\u2032\u2019]\\s*)` +
|
||||
`)?(` +
|
||||
`(?<seconds${n}>${number})` +
|
||||
`(\\s*["\u2033\u201d]\\s*)` +
|
||||
`)?(` +
|
||||
`(?<hemisphereSuffix${n}>[NWSE])` +
|
||||
`)?`
|
||||
);
|
||||
|
||||
const coords = (
|
||||
`(geo\\s*:\\s*)?` +
|
||||
`\\s*` +
|
||||
getCoordinate(1) +
|
||||
`(?<separator>\\s*[,;]\\s*|\\s+)` +
|
||||
getCoordinate(2) +
|
||||
`(\\?z=(?<zoom>\\d+))?`
|
||||
);
|
||||
|
||||
return new RegExp(`^\\s*${coords}\\s*$`, "i");
|
||||
})();
|
||||
|
||||
export function matchLonLat(query: string): (Point & { zoom?: number }) | undefined {
|
||||
const m = lonLatRegexp.exec(query);
|
||||
|
||||
const prepareNumber = (str: string) => Number(str.replace(",", ".").replace("\u2212", "-").replace(/\s+/, ""));
|
||||
const prepareCoords = (deg: string, min: string | undefined, sec: string | undefined, hem: string | undefined) => {
|
||||
const degrees = prepareNumber(deg);
|
||||
const result = Math.abs(degrees) + (min ? prepareNumber(min) / 60 : 0) + (sec ? prepareNumber(sec) / 3600 : 0);
|
||||
return result * (degrees < 0 ? -1 : 1) * (hem && ["s", "S", "w", "W"].includes(hem) ? -1 : 1);
|
||||
};
|
||||
|
||||
if (m) {
|
||||
const { hemispherePrefix1, degrees1, minutes1, seconds1, hemisphereSuffix1, separator, hemispherePrefix2, degrees2, minutes2, seconds2, hemisphereSuffix2, zoom } = m.groups!;
|
||||
|
||||
let hemisphere1: string | undefined = undefined, hemisphere2: string | undefined = undefined;
|
||||
if (hemispherePrefix1 && !hemisphereSuffix1 && hemispherePrefix2 && !hemisphereSuffix2) {
|
||||
[hemisphere1, hemisphere2] = [hemispherePrefix1, hemispherePrefix2];
|
||||
} else if (!hemispherePrefix1 && hemisphereSuffix1 && !hemispherePrefix2 && hemisphereSuffix2) {
|
||||
[hemisphere1, hemisphere2] = [hemisphereSuffix1, hemisphereSuffix2];
|
||||
} else if (hemispherePrefix1 && hemisphereSuffix1 && !hemispherePrefix2 && !hemisphereSuffix2 && !separator.trim()) {
|
||||
// Coordinate 2 has a hemisphere prefix, but because the two coordinates are separated by whitespace only, it was matched as a coordinate 1 suffix
|
||||
[hemisphere1, hemisphere2] = [hemispherePrefix1, hemisphereSuffix1];
|
||||
} else if (hemispherePrefix1 || hemisphereSuffix1 || hemispherePrefix2 || hemisphereSuffix2) {
|
||||
// Unsupported combination of hemisphere prefixes/suffixes
|
||||
return undefined;
|
||||
} // else: no hemispheres specified
|
||||
|
||||
const coordinate1 = prepareCoords(degrees1, minutes1, seconds1, hemisphere1);
|
||||
const coordinate2 = prepareCoords(degrees2, minutes2, seconds2, hemisphere2);
|
||||
const zoomNumber = zoom ? Number(zoom) : undefined;
|
||||
const zoomObj = zoomNumber != null && isFinite(zoomNumber) ? { zoom: zoomNumber } : {};
|
||||
|
||||
// Handle cases where lat/lon are switched
|
||||
if ([undefined, "n", "N", "s", "S"].includes(hemisphere1) && [undefined, "w", "W", "e", "E"].includes(hemisphere2)) {
|
||||
return { lat: coordinate1, lon: coordinate2, ...zoomObj };
|
||||
} else if ((["w", "W", "e", "E"] as Array<string | undefined>).includes(hemisphere1) && [undefined, "n", "N", "s", "S"].includes(hemisphere2)) {
|
||||
return { lat: coordinate2, lon: coordinate1, ...zoomObj };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,10 @@ export function isPromise(object: any): object is Promise<unknown> {
|
|||
*/
|
||||
export interface InjectedConfig {
|
||||
appName: string;
|
||||
openElevationApiUrl: string;
|
||||
openElevationThrottleMs: number;
|
||||
openElevationMaxBatchSize: number;
|
||||
nominatimUrl: string;
|
||||
limaLabsToken?: string;
|
||||
hideCommercialMapLinks?: boolean;
|
||||
}
|
|
@ -1,9 +1,6 @@
|
|||
import { cloneDeep, isEqual } from "lodash-es";
|
||||
import decodeURIComponent from "decode-uri-component";
|
||||
|
||||
const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
const LENGTH = 12;
|
||||
|
||||
export function quoteHtml(str: string | number): string {
|
||||
return `${str}`
|
||||
.replace(/&/g, "&")
|
||||
|
@ -20,14 +17,6 @@ export function quoteRegExp(str: string): string {
|
|||
return `${str}`.replace(/[.?*+^$[\]\\(){}|-]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function generateRandomPadId(length: number = LENGTH): string {
|
||||
let randomPadId = "";
|
||||
for(let i=0; i<length; i++) {
|
||||
randomPadId += LETTERS[Math.floor(Math.random() * LETTERS.length)];
|
||||
}
|
||||
return randomPadId;
|
||||
}
|
||||
|
||||
export function makeTextColour(backgroundColour: string, threshold = 0.5): string {
|
||||
return (getBrightness(backgroundColour) <= threshold) ? "#ffffff" : "#000000";
|
||||
}
|
||||
|
@ -137,26 +126,6 @@ export async function sleep(ms: number): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
export function normalizePadName(name: string | undefined): string {
|
||||
return name || "Unnamed map";
|
||||
}
|
||||
|
||||
export function normalizeMarkerName(name: string | undefined): string {
|
||||
return name || "Untitled marker";
|
||||
}
|
||||
|
||||
export function normalizeLineName(name: string | undefined): string {
|
||||
return name || "Untitled line";
|
||||
}
|
||||
|
||||
export function normalizePageTitle(padName: string | undefined, appName: string): string {
|
||||
return `${padName ? `${padName} – ` : ''}${appName}`;
|
||||
}
|
||||
|
||||
export function normalizePageDescription(padDescription: string | undefined): string {
|
||||
return padDescription || "A fully-featured OpenStreetMap-based map where markers and lines can be added with live collaboration.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a 3-way merge. Takes the difference between oldObject and newObject and applies it to targetObject.
|
||||
* @param oldObject {Object}
|
||||
|
@ -191,4 +160,96 @@ export function parsePadUrl(url: string, baseUrl: string): { padId: string; hash
|
|||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RetryError extends Error {
|
||||
constructor(public cause: Error) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export function throttledBatch<Args extends any[], Result>(
|
||||
getBatch: (batch: Args[]) => Promise<Result[]>,
|
||||
{ delayMs, maxSize, maxRetries = 3, noParallel = false }: {
|
||||
delayMs: number | (() => number);
|
||||
maxSize: number | (() => number);
|
||||
maxRetries?: number;
|
||||
noParallel?: boolean;
|
||||
}
|
||||
): ((...args: Args) => Promise<Result>) {
|
||||
let lastTime = -Infinity;
|
||||
let isScheduled = false;
|
||||
let batch: Array<{ args: Args; resolve: (result: Result) => void; reject: (err: any) => void; retryAttempt: number }> = [];
|
||||
|
||||
const handleBatch = () => {
|
||||
lastTime = Date.now();
|
||||
isScheduled = false;
|
||||
|
||||
const thisBatch = batch.splice(0, typeof maxSize === "function" ? maxSize() : maxSize);
|
||||
getBatch(thisBatch.map((it) => it.args)).then((results) => {
|
||||
for (let i = 0; i < thisBatch.length; i++) {
|
||||
thisBatch[i].resolve(results[i]);
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (err instanceof RetryError) {
|
||||
for (const { reject } of thisBatch.filter((it) => it.retryAttempt >= maxRetries)) {
|
||||
reject(err.cause);
|
||||
}
|
||||
batch.splice(0, 0, ...thisBatch.filter((it) => it.retryAttempt < maxRetries).map((it) => ({ ...it, retryAttempt: it.retryAttempt + 1 })));
|
||||
} else {
|
||||
for (const { reject } of thisBatch) {
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
}).finally(() => {
|
||||
schedule();
|
||||
});
|
||||
|
||||
if (!noParallel) {
|
||||
schedule();
|
||||
}
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
if (!isScheduled && batch.length > 0) {
|
||||
const delay = typeof delayMs === "function" ? delayMs() : delayMs;
|
||||
setTimeout(handleBatch, Math.max(0, lastTime + delay - Date.now()));
|
||||
isScheduled = true;
|
||||
}
|
||||
};
|
||||
|
||||
return async (...args) => {
|
||||
return await new Promise<Result>((resolve, reject) => {
|
||||
batch.push({ args, resolve, reject, retryAttempt: 0 });
|
||||
schedule();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of objects whose order is defined by an index field, returns how the index field of each object needs to be updated
|
||||
* in order to create a new item at the given index (id === undefined) or move an existing item to the new index (id !== undefined).
|
||||
*/
|
||||
export function insertIdx<IDType>(objects: Array<{ id: IDType; idx: number }>, id: IDType | undefined, insertAtIdx: number): Array<{ id: IDType; oldIdx: number; newIdx: number }> {
|
||||
const result = objects.map((obj) => ({ id: obj.id, oldIdx: obj.idx, newIdx: obj.idx }));
|
||||
|
||||
const insert = (thisId: IDType | undefined, thisIdx: number) => {
|
||||
for (const obj of result) {
|
||||
if ((thisId == null || obj.id !== thisId) && obj.newIdx === thisIdx) {
|
||||
insert(obj.id, obj.newIdx + 1);
|
||||
obj.newIdx++;
|
||||
}
|
||||
}
|
||||
};
|
||||
insert(id, insertAtIdx);
|
||||
|
||||
if (id != null) {
|
||||
for (const obj of result) {
|
||||
if (obj.id === id) {
|
||||
obj.newIdx = insertAtIdx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
22
yarn.lock
22
yarn.lock
|
@ -1120,13 +1120,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node-cron@npm:^3.0.11":
|
||||
version: 3.0.11
|
||||
resolution: "@types/node-cron@npm:3.0.11"
|
||||
checksum: a73f69bcca52a5f3b1671cfb00a8e4a1d150d0aef36a611564a2f94e66b6981bade577e267ceeeca6fcee241768902d55eb8cf3a81f9ef4ed767a23112fdb16d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node-fetch@npm:^2.6.11":
|
||||
version: 2.6.11
|
||||
resolution: "@types/node-fetch@npm:2.6.11"
|
||||
|
@ -3677,6 +3670,7 @@ __metadata:
|
|||
mitt: ^3.0.1
|
||||
osmtogeojson: ^3.0.0-beta.5
|
||||
p-debounce: ^4.0.0
|
||||
p-throttle: ^6.1.0
|
||||
pluralize: ^8.0.0
|
||||
popper-max-size-modifier: ^0.2.0
|
||||
qrcode.vue: ^3.4.1
|
||||
|
@ -3782,7 +3776,6 @@ __metadata:
|
|||
"@types/geojson": ^7946.0.14
|
||||
"@types/lodash-es": ^4.17.12
|
||||
"@types/node": ^20.11.25
|
||||
"@types/node-cron": ^3.0.11
|
||||
"@types/string-similarity": ^4.0.2
|
||||
cheerio: ^1.0.0-rc.12
|
||||
compression: ^1.7.4
|
||||
|
@ -3803,7 +3796,6 @@ __metadata:
|
|||
maxmind: ^4.3.18
|
||||
md5-file: ^5.0.0
|
||||
mysql2: ^3.9.2
|
||||
node-cron: ^3.0.3
|
||||
p-throttle: ^6.1.0
|
||||
pg: ^8.11.3
|
||||
rimraf: ^5.0.5
|
||||
|
@ -3857,6 +3849,7 @@ __metadata:
|
|||
linkifyjs: ^4.1.3
|
||||
lodash-es: ^4.17.21
|
||||
marked: ^12.0.1
|
||||
p-throttle: ^6.1.0
|
||||
rimraf: ^5.0.5
|
||||
typescript: ^5.4.2
|
||||
vite: ^5.1.5
|
||||
|
@ -5782,15 +5775,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-cron@npm:^3.0.3":
|
||||
version: 3.0.3
|
||||
resolution: "node-cron@npm:3.0.3"
|
||||
dependencies:
|
||||
uuid: 8.3.2
|
||||
checksum: 351c37491ebf717d0ae69cc941465de118e5c2ef5d48bc3f87c98556241b060f100402c8a618c7b86f9f626b44756b20d8b5385b70e52f80716f21e55db0f1c5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-domexception@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "node-domexception@npm:1.0.0"
|
||||
|
@ -8010,7 +7994,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uuid@npm:8.3.2, uuid@npm:^8.3.2":
|
||||
"uuid@npm:^8.3.2":
|
||||
version: 8.3.2
|
||||
resolution: "uuid@npm:8.3.2"
|
||||
bin:
|
||||
|
|
Ładowanie…
Reference in New Issue