Porównaj commity

...

24 Commity

Autor SHA1 Wiadomość Data
Candid Dauth 4941d77e98 Validate unique dropdown options 2024-03-28 12:50:45 +01:00
Candid Dauth e2b0f11c92 Add integration tests for dropdown styles 2024-03-28 12:41:17 +01:00
Candid Dauth 6044bf117c Add type style integration tests 2024-03-28 12:22:44 +01:00
Candid Dauth f56f860812 Fix flaky marker tests due to elevation 2024-03-28 12:21:53 +01:00
Candid Dauth 5a407e8395 Enforce unique field names 2024-03-27 22:20:45 +01:00
Candid Dauth 86789a9af8 Add integration tests for type order 2024-03-27 20:45:41 +01:00
Candid Dauth 3c1b59c280 Add tests for rename field 2024-03-27 15:16:58 +01:00
Candid Dauth 0e22ed24b2 Add test for Wikipedia coordinates 2024-03-27 02:11:24 +01:00
Candid Dauth 02d4ab0c42 Add some type tests 2024-03-27 01:02:57 +01:00
Candid Dauth 6fa3502f58 Allow disabling creation of default types 2024-03-26 20:33:26 +01:00
Candid Dauth 13dd7f6afc Allow specifying custom Open Elevation config 2024-03-26 13:46:38 +01:00
Candid Dauth d20356fa6e Fix pipeline script to fail on error 2024-03-26 04:02:44 +01:00
Candid Dauth b9a85a1185 Handle Nominatim errors properly 2024-03-26 03:52:15 +01:00
Candid Dauth 3b2ff2dfc9 Support hemisphere prefixes in coordinates (Park4night uses those) 2024-03-26 03:51:50 +01:00
Candid Dauth 25c79e4a73 Make views reorderable 2024-03-25 16:59:57 +01:00
Candid Dauth 07c059237f Make types reorderable 2024-03-24 21:03:50 +01:00
Candid Dauth 9cdfa4cef9 Add console logs for individual db migrations 2024-03-24 17:25:46 +01:00
Candid Dauth b1414d3428 Add integration tests for line export 2024-03-24 15:42:55 +01:00
Candid Dauth 7ef26a46ab Add florist POI type 2024-03-24 15:42:09 +01:00
Candid Dauth 028cd7216b Improve search and elevation handling
- Move search and elevation functionality to utils (and introduce
  config/fetch adapter)
- Load search results and elevation data directly in frontend where
  possible
- Set marker elevation asynchronously
- Load search results elevation asynchronously
- Fix click marker bugs
- Load click marker reverse geocode info asynchronously (fixes #25)
- Stabilize handling of 504 errors in elevation lookup
- Add migration to add elevation to existing markers
2024-03-24 06:03:21 +01:00
Candid Dauth 2cc372f486 Do not update maxmind db on every start 2024-03-24 00:23:08 +01:00
Candid Dauth 3562065aec Move pad util functions to separate file 2024-03-22 22:51:48 +01:00
Candid Dauth 73bb22b1be Make marker/line click/draw handler react to Enter/Escape 2024-03-22 22:50:43 +01:00
Candid Dauth 33642c546d Fix getLineTemplate() 2024-03-22 22:49:20 +01:00
75 zmienionych plików z 3652 dodań i 1040 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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}}&#x202F;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>

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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}}&#x202F;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"

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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}}&#x202F;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;

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -25,5 +25,8 @@ export default defineConfig({
&& !["facilmap-types", "facilmap-utils"].includes(id)
)
}
},
test: {
testTimeout: 20_000
}
});

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -15,6 +15,8 @@ export interface MetaProperties {
untitledMigrationCompleted: "1";
fieldsNullMigrationCompleted: "1";
extraInfoNullMigrationCompleted: "1";
typesIdxMigrationCompleted: "1";
viewsIdxMigrationCompleted: "1";
}
export default class DatabaseMeta {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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°5342.8928” E 10°4413.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°4134.3″N 12°3557.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);
});

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

34
utils/src/pads.ts 100644
Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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