Merge branch 'vue3'

pull/256/head
Candid Dauth 2024-03-29 01:47:48 +01:00
commit 64c9a2fae6
886 zmienionych plików z 30504 dodań i 22918 usunięć

Wyświetl plik

@ -1,5 +1,8 @@
node_modules
*/node_modules
*/dist
*/out
*/out.node
docs
.github
.github
Dockerfile

Wyświetl plik

@ -1,14 +1,29 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'import'],
extends: ['plugin:import/typescript'],
ignorePatterns: ["**/dist/*", "**/out/*", "**/out.*/*"],
parserOptions: {
parser: "@typescript-eslint/parser",
project: ["*/tsconfig.json", "*/tsconfig.node.json"],
extraFileExtensions: [".vue"]
},
plugins: ["@typescript-eslint", "import"],
extends: [
"plugin:import/typescript",
"plugin:vue/vue3-essential"
],
overrides: [
{
extends: ["plugin:@typescript-eslint/disable-type-checked"],
files: ["**/*.js", "**/*.cjs"]
}
],
env: {
node: true
},
rules: {
"@typescript-eslint/explicit-module-boundary-types": ["warn", { "allowArgumentsExplicitlyTypedAsAny": true }],
"import/no-unresolved": ["error", { "ignore": [ "geojson" ], "caseSensitive": true }],
"import/no-unresolved": ["error", { "ignore": [ "geojson", "virtual:icons" ], "caseSensitive": true }],
"import/no-extraneous-dependencies": ["error"],
"@typescript-eslint/no-unused-vars": ["warn", { "args": "none" }],
"import/no-named-as-default": ["warn"],
@ -21,6 +36,11 @@ module.exports = {
"@typescript-eslint/prefer-as-const": ["error"],
"no-restricted-globals": ["error", "$"],
"no-restricted-imports": ["error", "vue/types/umd"],
"vue/multi-word-component-names": ["off"],
"@typescript-eslint/no-base-to-string": ["error"],
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
"vue/return-in-computed-property": ["off"],
"@typescript-eslint/no-floating-promises": ["error"],
"constructor-super": ["error"],
"for-direction": ["error"],
@ -57,7 +77,6 @@ module.exports = {
"no-obj-calls": ["error"],
"no-octal": ["error"],
"no-prototype-builtins": ["error"],
"no-redeclare": ["error"],
"no-regex-spaces": ["error"],
"no-self-assign": ["error"],
"no-setter-return": ["error"],
@ -75,5 +94,12 @@ module.exports = {
"require-yield": ["error"],
"use-isnan": ["error"],
"valid-typeof": ["error"]
},
"settings": {
"import/resolver": {
"typescript": {
"project": ["tsconfig.json", "*/tsconfig.json"],
}
},
}
};

Wyświetl plik

@ -5,6 +5,9 @@ on:
branches:
- 'main'
env:
TAG: facilmap/facilmap:latest
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
@ -12,31 +15,64 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v3
-
name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push (FacilMap)
id: docker_build_facilmap
uses: docker/build-push-action@v2
name: Start integration test components in background
run: docker compose -f ./integration-tests/docker-compose.yml up -d --quiet-pull mysql postgres &
-
name: Build docker image
uses: docker/build-push-action@v5
with:
push: true
context: .
tags: facilmap/facilmap:latest
load: true
tags: |
${{env.TAG}}
facilmap-ci
-
name: Start integration test components
run: |
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
-
name: Run integration tests
run: >-
docker run --rm -u root --add-host host.docker.internal:host-gateway facilmap-ci sh -c "
yarn workspaces focus facilmap-integration-tests &&
FACILMAP_URL=http://host.docker.internal:8080 yarn workspace facilmap-integration-tests run integration-tests &&
FACILMAP_URL=http://host.docker.internal:8081 yarn workspace facilmap-integration-tests run integration-tests
"
-
name: Push docker image
run: docker push "$TAG"
-
name: Build and push (Docs)
id: docker_build_docs
uses: docker/build-push-action@v2
uses: docker/build-push-action@v5
with:
push: true
context: ./docs

Wyświetl plik

@ -5,12 +5,13 @@ on:
types: [published]
jobs:
docker:
push_to_registry:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Docker meta
id: meta
@ -18,59 +19,27 @@ jobs:
with:
images: facilmap/facilmap
tags: type=semver,pattern={{major}}.{{minor}}
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v3
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: facilmap/facilmap
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

4
.gitignore vendored
Wyświetl plik

@ -11,4 +11,6 @@ config.env
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
!.yarn/versions
out
out.*

Wyświetl plik

@ -1,26 +1,28 @@
FROM node:15.12-alpine
FROM node:21-alpine
MAINTAINER Candid Dauth <cdauth@cdauth.eu>
CMD yarn run server
CMD yarn run prod-server
EXPOSE 8080
ENV CACHE_DIR=/opt/facilmap/cache
HEALTHCHECK --start-period=60s --start-interval=3s --timeout=5s --retries=1 \
CMD wget -O/dev/null 'http://127.0.0.1:8080/socket.io/?EIO=4&transport=polling' || exit 1
RUN apk add --no-cache yarn
RUN adduser -D -h /opt/facilmap -s /bin/sh facilmap
RUN mkdir /opt/facilmap && adduser -D -H -h /opt/facilmap -s /bin/sh facilmap
WORKDIR /opt/facilmap/server
WORKDIR /opt/facilmap
COPY ./ ../
COPY ./ ./
RUN chown -R facilmap:facilmap /opt/facilmap
USER facilmap
RUN cd .. && yarn install
RUN cd .. && yarn run build
USER root
RUN chown -R root:root /opt/facilmap && chown -R facilmap:facilmap /opt/facilmap/server/cache
RUN yarn install && \
yarn check-types && \
yarn lint && \
yarn test && \
yarn run build:frontend:app && \
yarn run build:server && \
yarn workspaces focus -A --production
RUN mkdir -p "$CACHE_DIR" && chown -R facilmap:facilmap "$CACHE_DIR"
USER facilmap

Wyświetl plik

@ -7,7 +7,7 @@
* Smartphone-friendly interface and Progressive Web App
* Create collaborative maps, add markers, lines and routes and collaborate live through a share link
* View GPX/KML/OSM/GeoJSON files or import them to a collaborative map
* Export collaborative maps to GPX or GeoJSON to import them into Osmand or other apps
* Export collaborative maps to GPX or GeoJSON to import them into OsmAnd or other apps
* Link or embed a read-only or editable version of a collaborative map on your website
* Define different types of markers/lines with custom form fields to be filled out
* Create custom views where markers/lines are shown/hidden based on their form field values

Wyświetl plik

@ -3,7 +3,6 @@
"version": "3.4.0",
"description": "A library that acts as a client to FacilMap and makes it possible to retrieve and modify objects on a collaborative map.",
"keywords": [
"webpack",
"maps",
"osm",
"facilmap"
@ -14,7 +13,9 @@
},
"license": "AGPL-3.0",
"author": "Candid Dauth <cdauth@cdauth.eu>",
"main": "./dist/client.js",
"main": "./dist/facilmap-client.mjs",
"type": "module",
"types": "./dist/facilmap-client.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/FacilMap/facilmap.git"
@ -26,23 +27,20 @@
"tsconfig.json"
],
"scripts": {
"build": "webpack",
"watch": "webpack --watch",
"build": "vite build",
"clean": "rimraf dist",
"dev-server": "webpack-dev-server --mode development"
"dev-server": "vite",
"check-types": "tsc -b --emitDeclarationOnly"
},
"dependencies": {
"facilmap-types": "3.4.0",
"socket.io-client": "^4.1.2"
"facilmap-types": "workspace:^",
"socket.io-client": "^4.7.4"
},
"devDependencies": {
"@types/geojson": "^7946.0.7",
"rimraf": "^3.0.2",
"source-map-loader": "^4.0.1",
"ts-loader": "^9.4.4",
"typescript": "^5.2.2",
"webpack": "^5.88.2",
"webpack-bundle-analyzer": "^4.9.1",
"webpack-cli": "^5.1.4"
"@types/geojson": "^7946.0.14",
"rimraf": "^5.0.5",
"typescript": "^5.4.2",
"vite": "^5.1.5",
"vite-plugin-dts": "^3.7.3"
}
}

Wyświetl plik

@ -1,16 +1,7 @@
import { io, Socket as SocketIO } from "socket.io-client";
import {
Bbox,
BboxWithZoom, EventHandler, EventName, FindOnMapQuery, FindPadsQuery, FindPadsResult, FindQuery, GetPadQuery, HistoryEntry, ID, Line, LineCreate,
LineExportRequest, LineTemplateRequest, LineToRouteCreate, LineUpdate, MapEvents, Marker, MarkerCreate, MarkerUpdate, MultipleEvents, ObjectWithId,
PadData, PadDataCreate, PadDataUpdate, PadId, PagedResults, RequestData, RequestName, ResponseData, Route, RouteClear, RouteCreate, RouteExportRequest,
RouteInfo,
RouteRequest,
SearchResult,
TrackPoint, Type, TypeCreate, TypeUpdate, View, ViewCreate, ViewUpdate, Writable
} from "facilmap-types";
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, LineTemplate } from "facilmap-types";
export interface ClientEvents<DataType = Record<string, string>> extends MapEvents<DataType> {
export interface ClientEvents extends SocketEvents<SocketVersion.V2> {
connect: [];
disconnect: [string];
connect_error: [Error];
@ -29,9 +20,9 @@ export interface ClientEvents<DataType = Record<string, string>> extends MapEven
route: [RouteWithTrackPoints];
clearRoute: [RouteClear];
emit: { [eventName in RequestName]: [eventName, RequestData<eventName, DataType>] }[RequestName];
emitResolve: { [eventName in RequestName]: [eventName, ResponseData<eventName, DataType>] }[RequestName];
emitReject: [RequestName, Error];
emit: { [eventName in SocketRequestName<SocketVersion.V2>]: [eventName, SocketRequest<SocketVersion.V2, eventName>] }[SocketRequestName<SocketVersion.V2>];
emitResolve: { [eventName in SocketRequestName<SocketVersion.V2>]: [eventName, SocketResponse<SocketVersion.V2, eventName>] }[SocketRequestName<SocketVersion.V2>];
emitReject: [SocketRequestName<SocketVersion.V2>, Error];
}
const MANAGER_EVENTS: Array<EventName<ClientEvents>> = ['error', 'reconnect', 'reconnect_attempt', 'reconnect_error', 'reconnect_failed'];
@ -41,7 +32,7 @@ export interface TrackPoints {
length: number;
}
export interface LineWithTrackPoints<DataType = Record<string, string>> extends Line<DataType> {
export interface LineWithTrackPoints extends Line {
trackPoints: TrackPoints;
}
@ -50,64 +41,104 @@ export interface RouteWithTrackPoints extends Omit<Route, "trackPoints"> {
trackPoints: TrackPoints;
}
export default class Client<DataType = Record<string, string>> {
disconnected: boolean = true;
server!: string;
padId: string | undefined = undefined;
bbox: BboxWithZoom | undefined = undefined;
socket!: SocketIO;
padData: PadData | undefined = undefined;
readonly: boolean | undefined = undefined;
writable: Writable | undefined = undefined;
deleted: boolean = false;
markers: Record<ID, Marker<DataType>> = { };
lines: Record<ID, LineWithTrackPoints<DataType>> = { };
views: Record<ID, View> = { };
types: Record<ID, Type> = { };
history: Record<ID, HistoryEntry> = { };
route: RouteWithTrackPoints | undefined = undefined;
routes: Record<string, RouteWithTrackPoints> = { };
serverError: Error | undefined = undefined;
loading: number = 0;
interface ClientState {
disconnected: boolean;
server: string;
padId: string | undefined;
bbox: BboxWithZoom | undefined;
readonly: boolean | undefined;
writable: Writable | undefined;
deleted: boolean;
serverError: Error | undefined;
loading: number;
listeningToHistory: boolean;
}
_listeners: {
interface ClientData {
padData: (PadData & { writable: Writable }) | undefined;
markers: Record<ID, Marker>;
lines: Record<ID, LineWithTrackPoints>;
views: Record<ID, View>;
types: Record<ID, Type>;
history: Record<ID, HistoryEntry>;
route: RouteWithTrackPoints | undefined;
routes: Record<string, RouteWithTrackPoints>;
}
export default class Client {
private socket: SocketIO<SocketServerToClientEvents<SocketVersion.V2>, SocketClientToServerEvents<SocketVersion.V2>>;
private state: ClientState;
private data: ClientData;
private listeners: {
[E in EventName<ClientEvents>]?: Array<EventHandler<ClientEvents, E>>
} = { };
_listeningToHistory: boolean = false;
constructor(server: string, padId?: string) {
this._init(server, padId);
constructor(server: string, padId?: string, socketOptions?: Partial<ManagerOptions & SocketOptions>) {
this.state = this._makeReactive({
disconnected: true,
server,
padId,
bbox: undefined,
readonly: undefined,
writable: undefined,
deleted: false,
serverError: undefined,
loading: 0,
listeningToHistory: false
});
this.data = this._makeReactive({
padData: undefined,
markers: { },
lines: { },
views: { },
types: { },
history: { },
route: undefined,
routes: { }
});
const serverUrl = typeof location != "undefined" ? new URL(this.state.server, location.href) : new URL(this.state.server);
const socket = io(`${serverUrl.origin}/v2`, {
forceNew: true,
path: serverUrl.pathname.replace(/\/$/, "") + "/socket.io",
...socketOptions
});
this.socket = socket;
for(const i of Object.keys(this._handlers) as EventName<ClientEvents>[]) {
this.on(i, this._handlers[i] as EventHandler<ClientEvents, typeof i>);
}
void Promise.resolve().then(() => {
this._simulateEvent("loadStart");
});
this.once("connect", () => {
this._simulateEvent("loadEnd");
});
}
_set<O, K extends keyof O>(object: O, key: K, value: O[K]): void {
protected _makeReactive<O extends object>(object: O): O {
return object;
}
protected _set<O, K extends keyof O>(object: O, key: K, value: O[K]): void {
object[key] = value;
}
_delete<O>(object: O, key: keyof O): void {
protected _delete<O>(object: O, key: keyof O): void {
delete object[key];
}
_decodeData(data: Record<string, string>): DataType {
protected _decodeData(data: Record<string, string>): Record<string, string> {
const result = Object.create(null);
Object.assign(result, data);
return result;
}
_encodeData(data: DataType): Record<string, string> {
return data as any;
}
_fixRequestObject<T>(requestName: RequestName, obj: T): T {
if (typeof obj != "object" || !(obj as any)?.data || !["addMarker", "editMarker", "addLine", "editLine"].includes(requestName))
return obj;
return {
...obj,
data: this._encodeData((obj as any).data)
};
}
_fixResponseObject<T>(requestName: RequestName, obj: T): T {
private _fixResponseObject<T>(requestName: SocketRequestName<SocketVersion.V2>, obj: T): T {
if (typeof obj != "object" || !(obj as any)?.data || !["getMarker", "addMarker", "editMarker", "deleteMarker", "getLineTemplate", "addLine", "editLine", "deleteLine"].includes(requestName))
return obj;
@ -117,7 +148,7 @@ export default class Client<DataType = Record<string, string>> {
};
}
_fixEventObject<T extends any[]>(eventName: EventName<ClientEvents>, obj: T): T {
private _fixEventObject<T extends any[]>(eventName: EventName<ClientEvents>, obj: T): T {
if (typeof obj?.[0] != "object" || !obj?.[0]?.data || !["marker", "line"].includes(eventName))
return obj;
@ -130,63 +161,38 @@ export default class Client<DataType = Record<string, string>> {
] as T;
}
_init(server: string, padId: string | undefined): void {
// Needs to be in a separate method so that we can merge this class with a scope object in the frontend.
this._set(this, 'server', server);
this._set(this, 'padId', padId);
const serverUrl = typeof location != "undefined" ? new URL(server, location.href) : new URL(server);
const socket = io(serverUrl.origin, {
forceNew: true,
path: serverUrl.pathname.replace(/\/$/, "") + "/socket.io"
});
this._set(this, 'socket', socket);
for(const i of Object.keys(this._handlers) as EventName<ClientEvents<DataType>>[]) {
this.on(i, this._handlers[i] as EventHandler<ClientEvents<DataType>, typeof i>);
}
setTimeout(() => {
this._simulateEvent("loadStart");
}, 0);
this.once("connect", () => {
this._simulateEvent("loadEnd");
});
}
on<E extends EventName<ClientEvents>>(eventName: E, fn: EventHandler<ClientEvents<DataType>, E>): void {
if(!this._listeners[eventName]) {
on<E extends EventName<ClientEvents>>(eventName: E, fn: EventHandler<ClientEvents, E>): void {
if(!this.listeners[eventName]) {
(MANAGER_EVENTS.includes(eventName) ? this.socket.io as any : this.socket)
.on(eventName, (...[data]: ClientEvents<DataType>[E]) => { this._simulateEvent(eventName as any, data); });
.on(eventName, (...[data]: ClientEvents[E]) => { this._simulateEvent(eventName as any, data); });
}
this._set(this._listeners, eventName, [ ...(this._listeners[eventName] || [] as any), fn ]);
this.listeners[eventName] = [...(this.listeners[eventName] || [] as any), fn];
}
once<E extends EventName<ClientEvents>>(eventName: E, fn: EventHandler<ClientEvents<DataType>, E>): void {
once<E extends EventName<ClientEvents>>(eventName: E, fn: EventHandler<ClientEvents, E>): void {
const handler = ((data: any) => {
this.removeListener(eventName, handler);
(fn as any)(data);
}) as EventHandler<ClientEvents<DataType>, E>;
}) as EventHandler<ClientEvents, E>;
this.on(eventName, handler);
}
removeListener<E extends EventName<ClientEvents>>(eventName: E, fn: EventHandler<ClientEvents<DataType>, E>): void {
const listeners = this._listeners[eventName] as Array<EventHandler<ClientEvents<DataType>, E>> | undefined;
removeListener<E extends EventName<ClientEvents>>(eventName: E, fn: EventHandler<ClientEvents, E>): void {
const listeners = this.listeners[eventName] as Array<EventHandler<ClientEvents, E>> | undefined;
if(listeners) {
this._set(this._listeners, eventName, listeners.filter((listener) => (listener !== fn)) as any);
this.listeners[eventName] = listeners.filter((listener) => (listener !== fn)) as any;
}
}
async _emit<R extends RequestName>(eventName: R, ...[data]: RequestData<R, DataType> extends void ? [ ] : [ RequestData<R, DataType> ]): Promise<ResponseData<R, DataType>> {
private async _emit<R extends SocketRequestName<SocketVersion.V2>>(eventName: R, ...[data]: SocketRequest<SocketVersion.V2, R> extends undefined | null ? [ ] : [ SocketRequest<SocketVersion.V2, R> ]): Promise<SocketResponse<SocketVersion.V2, R>> {
try {
this._simulateEvent("loadStart");
this._simulateEvent("emit", eventName as any, data as any);
return await new Promise((resolve, reject) => {
this.socket.emit(eventName, this._fixRequestObject(eventName, data), (err: Error, data: ResponseData<R, DataType>) => {
this.socket.emit(eventName as any, data, (err: Error, data: SocketResponse<SocketVersion.V2, R>) => {
if(err) {
reject(err);
this._simulateEvent("emitReject", eventName as any, err);
@ -202,49 +208,49 @@ export default class Client<DataType = Record<string, string>> {
}
}
_handlers: {
[E in EventName<ClientEvents>]?: EventHandler<ClientEvents<DataType>, E>
private _handlers: {
[E in EventName<ClientEvents>]?: EventHandler<ClientEvents, E>
} = {
padData: (data) => {
this._set(this, 'padData', data);
this._set(this.data, 'padData', data);
if(data.writable != null) {
this._set(this, 'readonly', data.writable == 0);
this._set(this, 'writable', data.writable);
this._set(this.state, 'readonly', data.writable == 0);
this._set(this.state, 'writable', data.writable);
}
const id = this.writable == 2 ? data.adminId : this.writable == 1 ? data.writeId : data.id;
const id = this.state.writable == 2 ? data.adminId : this.state.writable == 1 ? data.writeId : data.id;
if(id != null)
this._set(this, 'padId', id);
this._set(this.state, 'padId', id);
},
deletePad: () => {
this._set(this, 'readonly', true);
this._set(this, 'writable', 0);
this._set(this, 'deleted', true);
this._set(this.state, 'readonly', true);
this._set(this.state, 'writable', 0);
this._set(this.state, 'deleted', true);
},
marker: (data) => {
this._set(this.markers, data.id, data);
this._set(this.data.markers, data.id, data);
},
deleteMarker: (data) => {
this._delete(this.markers, data.id);
this._delete(this.data.markers, data.id);
},
line: (data) => {
this._set(this.lines, data.id, {
this._set(this.data.lines, data.id, {
...data,
trackPoints: this.lines[data.id]?.trackPoints || { length: 0 }
trackPoints: this.data.lines[data.id]?.trackPoints || { length: 0 }
});
},
deleteLine: (data) => {
this._delete(this.lines, data.id);
this._delete(this.data.lines, data.id);
},
linePoints: (data) => {
const line = this.lines[data.id];
const line = this.data.lines[data.id];
if(line == null)
return console.error("Received line points for non-existing line "+data.id+".");
@ -252,16 +258,16 @@ export default class Client<DataType = Record<string, string>> {
},
routePoints: (data) => {
if(!this.route) {
if(!this.data.route) {
console.error("Received route points for non-existing route.");
return;
}
this._set(this.route, 'trackPoints', this._mergeTrackPoints(this.route.trackPoints, data));
this._set(this.data.route, 'trackPoints', this._mergeTrackPoints(this.data.route.trackPoints, data));
},
routePointsWithId: (data) => {
const route = this.routes[data.routeId];
const route = this.data.routes[data.routeId];
if(!route) {
console.error("Received route points for non-existing route.");
return;
@ -271,178 +277,209 @@ export default class Client<DataType = Record<string, string>> {
},
view: (data) => {
this._set(this.views, data.id, data);
this._set(this.data.views, data.id, data);
},
deleteView: (data) => {
this._delete(this.views, data.id);
if (this.padData) {
if(this.padData.defaultViewId == data.id)
this._set(this.padData, 'defaultViewId', null);
this._delete(this.data.views, data.id);
if (this.data.padData) {
if(this.data.padData.defaultViewId == data.id)
this._set(this.data.padData, 'defaultViewId', null);
}
},
type: (data) => {
this._set(this.types, data.id, data);
this._set(this.data.types, data.id, data);
},
deleteType: (data) => {
this._delete(this.types, data.id);
this._delete(this.data.types, data.id);
},
disconnect: () => {
this._set(this, 'disconnected', true);
this._set(this, 'markers', { });
this._set(this, 'lines', { });
this._set(this, 'views', { });
this._set(this, 'history', { });
this._set(this.state, 'disconnected', true);
this._set(this.data, 'markers', { });
this._set(this.data, 'lines', { });
this._set(this.data, 'views', { });
this._set(this.data, 'history', { });
},
connect: () => {
this._set(this, 'disconnected', false); // Otherwise it gets set when padData arrives
this._set(this.state, 'disconnected', false); // Otherwise it gets set when padData arrives
if(this.padId)
this._setPadId(this.padId).catch(() => undefined);
if(this.state.padId)
this._setPadId(this.state.padId).catch(() => undefined);
// TODO: Handle errors
if(this.bbox)
this.updateBbox(this.bbox).catch((err) => { console.error("Error updating bbox.", err); });
if(this.state.bbox)
this.updateBbox(this.state.bbox).catch((err) => { console.error("Error updating bbox.", err); });
if(this._listeningToHistory) // TODO: Execute after setPadId() returns
if(this.state.listeningToHistory) // TODO: Execute after setPadId() returns
this.listenToHistory().catch(function(err) { console.error("Error listening to history", err); });
if(this.route)
this.setRoute(this.route).catch((err) => { console.error("Error setting route.", err); });
for (const route of Object.values(this.routes))
if(this.data.route)
this.setRoute(this.data.route).catch((err) => { console.error("Error setting route.", err); });
for (const route of Object.values(this.data.routes))
this.setRoute(route).catch((err) => { console.error("Error setting route.", err); });
},
connect_error: (err) => {
if (!this.socket.active) { // Fatal error, client will not try to reconnect anymore
this._set(this.state, 'serverError', err);
this._simulateEvent("serverError", err);
}
},
history: (data) => {
this._set(this.history, data.id, data);
this._set(this.data.history, data.id, data);
// TODO: Limit to 50 entries
},
loadStart: () => {
this._set(this, 'loading', this.loading + 1);
this._set(this.state, 'loading', this.state.loading + 1);
},
loadEnd: () => {
this._set(this, 'loading', this.loading - 1);
this._set(this.state, 'loading', this.state.loading - 1);
}
};
setPadId(padId: PadId): Promise<void> {
if(this.padId != null)
async setPadId(padId: PadId): Promise<MultipleEvents<SocketEvents<SocketVersion.V2>>> {
if(this.state.padId != null)
throw new Error("Pad ID already set.");
return this._setPadId(padId);
return await this._setPadId(padId);
}
updateBbox(bbox: BboxWithZoom): Promise<void> {
this._set(this, 'bbox', bbox);
return this._emit("updateBbox", bbox).then((obj) => {
this._receiveMultiple(obj);
});
async updateBbox(bbox: BboxWithZoom): Promise<MultipleEvents<SocketEvents<SocketVersion.V2>>> {
const isZoomChange = this.bbox && bbox.zoom !== this.bbox.zoom;
this._set(this.state, 'bbox', bbox);
const obj = await this._emit("updateBbox", bbox);
if (isZoomChange) {
// Reset line points on zoom change to prevent us from accumulating too many unneeded line points.
// On zoom change the line points are sent from the server without applying the "except" rule for the last bbox,
// so we can be sure that we will receive all line points that are relevant for the new bbox.
obj.linePoints = obj.linePoints || [];
const linePointEventsById = new Map(obj.linePoints.map((e) => [e.id, e] as const));
for (const lineIdStr of Object.keys(this.data.lines)) {
const lineId = Number(lineIdStr);
const e = linePointEventsById.get(lineId);
if (e) {
e.reset = true;
} else {
obj.linePoints.push({
id: lineId,
trackPoints: [],
reset: true
});
}
}
}
this._receiveMultiple(obj);
return obj;
}
getPad(data: GetPadQuery): Promise<FindPadsResult | undefined> {
return this._emit("getPad", data);
async getPad(data: GetPadQuery): Promise<FindPadsResult | null> {
return await this._emit("getPad", data);
}
findPads(data: FindPadsQuery): Promise<PagedResults<FindPadsResult>> {
return this._emit("findPads", data);
async findPads(data: FindPadsQuery): Promise<PagedResults<FindPadsResult>> {
return await this._emit("findPads", data);
}
createPad(data: PadDataCreate): Promise<void> {
return this._emit("createPad", data).then((obj) => {
this._set(this, 'readonly', false);
this._set(this, 'writable', 2);
this._receiveMultiple(obj);
});
async createPad(data: PadData<CRU.CREATE>): Promise<MultipleEvents<SocketEvents<SocketVersion.V2>>> {
const obj = await this._emit("createPad", data);
this._set(this.state, 'serverError', undefined);
this._set(this.state, 'readonly', false);
this._set(this.state, 'writable', 2);
this._receiveMultiple(obj);
return obj;
}
editPad(data: PadDataUpdate): Promise<PadData> {
return this._emit("editPad", data);
async editPad(data: PadData<CRU.UPDATE>): Promise<PadData> {
return await this._emit("editPad", data);
}
deletePad(): Promise<void> {
return this._emit("deletePad");
async deletePad(): Promise<void> {
await this._emit("deletePad");
}
listenToHistory(): Promise<void> {
return this._emit("listenToHistory").then((obj) => {
this._set(this, '_listeningToHistory', true);
this._receiveMultiple(obj);
});
async listenToHistory(): Promise<MultipleEvents<SocketEvents<SocketVersion.V2>>> {
const obj = await this._emit("listenToHistory");
this._set(this.state, 'listeningToHistory', true);
this._receiveMultiple(obj);
return obj;
}
stopListeningToHistory(): Promise<void> {
this._set(this, '_listeningToHistory', false);
return this._emit("stopListeningToHistory");
async stopListeningToHistory(): Promise<void> {
this._set(this.state, 'listeningToHistory', false);
await this._emit("stopListeningToHistory");
}
revertHistoryEntry(data: ObjectWithId): Promise<void> {
return this._emit("revertHistoryEntry", data).then((obj) => {
this._set(this, 'history', { });
this._receiveMultiple(obj);
});
async revertHistoryEntry(data: ObjectWithId): Promise<MultipleEvents<SocketEvents<SocketVersion.V2>>> {
const obj = await this._emit("revertHistoryEntry", data);
this._set(this.data, 'history', {});
this._receiveMultiple(obj);
return obj;
}
async getMarker(data: ObjectWithId): Promise<Marker<DataType>> {
async getMarker(data: ObjectWithId): Promise<Marker> {
const marker = await this._emit("getMarker", data);
this._set(this.markers, marker.id, marker);
this._set(this.data.markers, marker.id, marker);
return marker;
}
async addMarker(data: MarkerCreate<DataType>): Promise<Marker<DataType>> {
async addMarker(data: Marker<CRU.CREATE>): Promise<Marker> {
const marker = await this._emit("addMarker", data);
// If the marker is out of view, we will not recieve it in an event. Add it here manually to make sure that we have it.
this._set(this.markers, marker.id, marker);
this._set(this.data.markers, marker.id, marker);
return marker;
}
editMarker(data: ObjectWithId & MarkerUpdate<DataType>): Promise<Marker<DataType>> {
return this._emit("editMarker", data);
async editMarker(data: Marker<CRU.UPDATE> & { id: ID }): Promise<Marker> {
return await this._emit("editMarker", data);
}
deleteMarker(data: ObjectWithId): Promise<Marker<DataType>> {
return this._emit("deleteMarker", data);
async deleteMarker(data: ObjectWithId): Promise<Marker> {
return await this._emit("deleteMarker", data);
}
getLineTemplate(data: LineTemplateRequest): Promise<Line<DataType>> {
return this._emit("getLineTemplate", data);
async getLineTemplate(data: LineTemplateRequest): Promise<LineTemplate> {
return await this._emit("getLineTemplate", data);
}
addLine(data: LineCreate<DataType>): Promise<Line<DataType>> {
return this._emit("addLine", data);
async addLine(data: Line<CRU.CREATE>): Promise<Line> {
return await this._emit("addLine", data);
}
editLine(data: ObjectWithId & LineUpdate<DataType>): Promise<Line<DataType>> {
return this._emit("editLine", data);
async editLine(data: Line<CRU.UPDATE> & { id: ID }): Promise<Line> {
return await this._emit("editLine", data);
}
deleteLine(data: ObjectWithId): Promise<Line<DataType>> {
return this._emit("deleteLine", data);
async deleteLine(data: ObjectWithId): Promise<Line> {
return await this._emit("deleteLine", data);
}
exportLine(data: LineExportRequest): Promise<string> {
return this._emit("exportLine", data);
async exportLine(data: LineExportRequest): Promise<string> {
return await this._emit("exportLine", data);
}
find(data: FindQuery & { loadUrls?: false }): Promise<SearchResult[]>;
find(data: FindQuery & { loadUrls: true }): Promise<string | SearchResult[]>; // eslint-disable-line no-dupe-class-members
find(data: FindQuery): Promise<string | SearchResult[]> { // eslint-disable-line no-dupe-class-members
return this._emit("find", data);
async find(data: FindQuery & { loadUrls?: false }): Promise<SearchResult[]>;
async find(data: FindQuery & { loadUrls: true }): Promise<string | SearchResult[]>; // eslint-disable-line no-dupe-class-members
async find(data: FindQuery): Promise<string | SearchResult[]> { // eslint-disable-line no-dupe-class-members
return await this._emit("find", data);
}
findOnMap(data: FindOnMapQuery): Promise<ResponseData<'findOnMap'>> {
return this._emit("findOnMap", data);
async findOnMap(data: FindOnMapQuery): Promise<SocketResponse<SocketVersion.V2, 'findOnMap'>> {
return await this._emit("findOnMap", data);
}
getRoute(data: RouteRequest): Promise<RouteInfo> {
return this._emit("getRoute", data);
async getRoute(data: RouteRequest): Promise<RouteInfo> {
return await this._emit("getRoute", data);
}
async setRoute(data: RouteCreate): Promise<RouteWithTrackPoints | undefined> {
@ -457,9 +494,9 @@ export default class Client<DataType = Record<string, string>> {
};
if (data.routeId)
this._set(this.routes, data.routeId, result);
this._set(this.data.routes, data.routeId, result);
else
this._set(this, "route", result);
this._set(this.data, "route", result);
this._simulateEvent("route", result);
return result;
@ -467,13 +504,13 @@ export default class Client<DataType = Record<string, string>> {
async clearRoute(data?: RouteClear): Promise<void> {
if (data?.routeId) {
this._delete(this.routes, data.routeId);
this._delete(this.data.routes, data.routeId);
this._simulateEvent("clearRoute", { routeId: data.routeId });
return this._emit("clearRoute", data);
} else if (this.route) {
this._set(this, 'route', undefined);
await this._emit("clearRoute", data);
} else if (this.data.route) {
this._set(this.data, 'route', undefined);
this._simulateEvent("clearRoute", { routeId: undefined });
return this._emit("clearRoute", data);
await this._emit("clearRoute", data);
}
}
@ -489,44 +526,44 @@ export default class Client<DataType = Record<string, string>> {
};
if (data.routeId)
this._set(this.routes, data.routeId, result);
this._set(this.data.routes, data.routeId, result);
else
this._set(this, "route", result);
this._set(this.data, "route", result);
this._simulateEvent("route", result);
return result;
}
exportRoute(data: RouteExportRequest): Promise<string> {
return this._emit("exportRoute", data);
async exportRoute(data: RouteExportRequest): Promise<string> {
return await this._emit("exportRoute", data);
}
addType(data: TypeCreate): Promise<Type> {
return this._emit("addType", data);
async addType(data: Type<CRU.CREATE>): Promise<Type> {
return await this._emit("addType", data);
}
editType(data: ObjectWithId & TypeUpdate): Promise<Type> {
return this._emit("editType", data);
async editType(data: Type<CRU.UPDATE> & { id: ID }): Promise<Type> {
return await this._emit("editType", data);
}
deleteType(data: ObjectWithId): Promise<Type> {
return this._emit("deleteType", data);
async deleteType(data: ObjectWithId): Promise<Type> {
return await this._emit("deleteType", data);
}
addView(data: ViewCreate): Promise<View> {
return this._emit("addView", data);
async addView(data: View<CRU.CREATE>): Promise<View> {
return await this._emit("addView", data);
}
editView(data: ObjectWithId & ViewUpdate): Promise<View> {
return this._emit("editView", data);
async editView(data: View<CRU.UPDATE> & { id: ID }): Promise<View> {
return await this._emit("editView", data);
}
deleteView(data: ObjectWithId): Promise<View> {
return this._emit("deleteView", data);
async deleteView(data: ObjectWithId): Promise<View> {
return await this._emit("deleteView", data);
}
geoip(): Promise<Bbox | null> {
return this._emit("geoip");
async geoip(): Promise<Bbox | null> {
return await this._emit("geoip");
}
disconnect(): void {
@ -534,38 +571,39 @@ export default class Client<DataType = Record<string, string>> {
this.socket.disconnect();
}
async _setPadId(padId: string): Promise<void> {
this._set(this, 'serverError', undefined);
this._set(this, 'padId', padId);
private async _setPadId(padId: string): Promise<MultipleEvents<SocketEvents<SocketVersion.V2>>> {
this._set(this.state, 'serverError', undefined);
this._set(this.state, 'padId', padId);
try {
const obj = await this._emit("setPadId", padId);
this._receiveMultiple(obj);
return obj;
} catch(err: any) {
this._set(this, 'serverError', err);
this._set(this.state, 'serverError', err);
this._simulateEvent("serverError", err);
throw err;
}
}
_receiveMultiple(obj?: MultipleEvents<ClientEvents<DataType>>): void {
private _receiveMultiple(obj?: MultipleEvents<ClientEvents>): void {
if (obj) {
for(const i of Object.keys(obj) as EventName<ClientEvents>[])
(obj[i] as Array<ClientEvents<DataType>[typeof i][0]>).forEach((it) => { this._simulateEvent(i, it as any); });
(obj[i] as Array<ClientEvents[typeof i][0]>).forEach((it) => { this._simulateEvent(i, it as any); });
}
}
_simulateEvent<E extends EventName<ClientEvents>>(eventName: E, ...data: ClientEvents<DataType>[E]): void {
private _simulateEvent<E extends EventName<ClientEvents>>(eventName: E, ...data: ClientEvents[E]): void {
const fixedData = this._fixEventObject(eventName, data);
const listeners = this._listeners[eventName] as Array<EventHandler<ClientEvents<DataType>, E>> | undefined;
const listeners = this.listeners[eventName] as Array<EventHandler<ClientEvents, E>> | undefined;
if(listeners) {
listeners.forEach(function(listener: EventHandler<ClientEvents<DataType>, E>) {
listeners.forEach(function(listener: EventHandler<ClientEvents, E>) {
listener(...fixedData);
});
}
}
_mergeTrackPoints(existingTrackPoints: Record<number, TrackPoint> | null, newTrackPoints: TrackPoint[]): TrackPoints {
private _mergeTrackPoints(existingTrackPoints: Record<number, TrackPoint> | null, newTrackPoints: TrackPoint[]): TrackPoints {
const ret = { ...(existingTrackPoints || { }) } as TrackPoints;
for(let i=0; i<newTrackPoints.length; i++) {
@ -580,4 +618,76 @@ export default class Client<DataType = Record<string, string>> {
return ret;
}
get disconnected(): boolean {
return this.state.disconnected;
}
get server(): string {
return this.state.server;
}
get padId(): string | undefined {
return this.state.padId;
}
get bbox(): BboxWithZoom | undefined {
return this.state.bbox;
}
get readonly(): boolean | undefined {
return this.state.readonly;
}
get writable(): Writable | undefined {
return this.state.writable;
}
get deleted(): boolean {
return this.state.deleted;
}
get serverError(): Error | undefined {
return this.state.serverError;
}
get loading(): number {
return this.state.loading;
}
get listeningToHistory(): boolean {
return this.state.listeningToHistory;
}
get padData(): PadData | undefined {
return this.data.padData;
}
get markers(): Record<ID, Marker> {
return this.data.markers;
}
get lines(): Record<ID, LineWithTrackPoints> {
return this.data.lines;
}
get views(): Record<ID, View> {
return this.data.views;
}
get types(): Record<ID, Type> {
return this.data.types;
}
get history(): Record<ID, HistoryEntry> {
return this.data.history;
}
get route(): RouteWithTrackPoints | undefined {
return this.data.route;
}
get routes(): Record<string, RouteWithTrackPoints> {
return this.data.routes;
}
}

Wyświetl plik

@ -1,14 +1,15 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"target": "es5",
"esModuleInterop": true,
"strict": true,
"sourceMap": true,
"declaration": true,
"outDir": "dist",
"moduleResolution": "node",
"noErrorTruncation": true,
"skipLibCheck": true
"outDir": "./out",
"composite": true,
"paths": {
"facilmap-types": ["../types/src/index.ts"]
}
},
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "../types/tsconfig.json" }
],
"include": ["src/**/*"]
}

Wyświetl plik

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "out.node"
},
"include": [
"vite.config.ts"
]
}

Wyświetl plik

@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import dtsPlugin from "vite-plugin-dts";
export default defineConfig({
plugins: [
dtsPlugin({ rollupTypes: true })
],
build: {
sourcemap: true,
minify: false,
lib: {
entry: './src/client.ts',
fileName: () => 'facilmap-client.mjs',
formats: ['es']
},
rollupOptions: {
external: (id) => !id.startsWith("./") && !id.startsWith("../") && /* resolved internal modules */ !id.startsWith("/")
}
}
});

Wyświetl plik

@ -1,53 +0,0 @@
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = (env, argv) => {
const isDev = argv.mode == "development";
return {
entry: `${__dirname}/src/client.ts`,
output: {
filename: "client.js",
path: __dirname + "/dist/",
library: ["FacilMap", "Client"],
libraryTarget: "umd",
libraryExport: "default"
},
resolve: {
extensions: [ ".js", ".ts" ]
},
mode: isDev ? "development" : "production",
devtool: isDev ? "eval-source-map" : "source-map",
module: {
rules: [
{ test: /\.js$/, enforce: "pre", use: ["source-map-loader"] },
{
resource: { and: [ /\.ts/, [
__dirname + "/src/"
] ] },
loader: 'ts-loader'
},
{
test: /\.css$/,
use: [ 'style-loader', 'css-loader' ]
}
]
},
externals: {
"socket.io-client": {
commonjs: 'socket.io-client',
commonjs2: 'socket.io-client',
amd: 'socket.io-client',
root: 'io'
}
},
plugins: [
//new BundleAnalyzerPlugin()
],
devServer: {
publicPath: "/dist",
disableHostCheck: true,
injectClient: false, // https://github.com/webpack/webpack-dev-server/issues/2484
port: 8083
}
};
};

Wyświetl plik

@ -1,8 +1,9 @@
# Copy this file to config.env when running FacilMap standalone.
# Find all the available configuration options in the documentation: https://docs.facilmap.org/developers/server/config.html
# HTTP requests made by the backend will send this User-Agent header. Please adapt to your URL and e-mail address.
#USER_AGENT=FacilMap (https://facilmap.org/, cdauth@cdauth.eu)
# On which IP the HTTP server will listen. Leave empty to listen to all IPs.
#HOST=
# On which port the HTTP server will listen.
#PORT=8080
@ -23,12 +24,13 @@
# Get an API key on https://www.mapbox.com/signup/
#MAPBOX_TOKEN=
# MapZen is used for getting elevation information
# Get an API key on https://mapzen.com/developers/sign_up
#MAPZEN_TOKEN=
# Maxmind configuration. If specified, the maxmind GeoLite2 database will be downloaded
# for Geo IP lookup (to show the initial map state) and kept in memory.
# Sign up here: https://www.maxmind.com/en/geolite2/signup
#MAXMIND_USER_ID=
#MAXMIND_LICENSE_KEY=
#MAXMIND_LICENSE_KEY=
# Lima Labs provides nice double resolution layers.
# If this is defined, they are used as the default layer instead of Mapnik.
# Get an API key here: https://maps.lima-labs.com/
#LIMA_LABS_TOKEN=

Wyświetl plik

@ -1,3 +1,6 @@
# This docker-compose file is for internal testing only.
# Please refer to the documentation on https://docs.facilmap.org/developers/server/docker.html for how to start FacilMap with docker.
version: "2"
services:
facilmap:
@ -6,14 +9,20 @@ services:
ports:
- "127.0.0.1:8080:8080"
environment:
DB_TYPE: mariadb
DB_HOST: mariadb
DB_TYPE: mysql
DB_HOST: mysql
# DB_TYPE: postgres
# DB_HOST: postgres
DB_NAME: facilmap
DB_USER: facilmap
DB_PASSWORD: facilmap
links:
- mysql
depends_on:
mysql:
condition: service_healthy
restart: on-failure
mysql:
image: mysql:5.7
environment:
@ -21,11 +30,26 @@ services:
MYSQL_USER: facilmap
MYSQL_PASSWORD: facilmap
MYSQL_RANDOM_ROOT_PASSWORD: "true"
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
healthcheck:
test: mysqladmin ping -h 127.0.0.1 -u $$MYSQL_USER --password=$$MYSQL_PASSWORD
ports:
- "127.0.0.1:40830:3306"
phpmyadmin:
image: phpmyadmin
links:
- mysql:db
ports:
- 127.0.0.1:8090:80
# postgres:
# image: postgis/postgis:16-3.4
# environment:
# POSTGRES_USER: facilmap
# POSTGRES_PASSWORD: facilmap
# POSTGRES_DB: facilmap
# healthcheck:
# test: pg_isready -d $$POSTGRES_DB
# ports:
# - "127.0.0.1:40831:5432"
# phpmyadmin:
# image: phpmyadmin
# links:
# - mysql:db
# ports:
# - 127.0.0.1:8090:80

Wyświetl plik

@ -1,5 +1,5 @@
import { defineClientConfig } from '@vuepress/client';
import QrcodeVue from 'qrcode.vue';
import { defineClientConfig } from "@vuepress/client";
import QrcodeVue from "qrcode.vue";
import Screencast from "./components/Screencast.vue";
import Screenshot from "./components/Screenshot.vue";

Wyświetl plik

@ -1,15 +1,15 @@
import { description } from '../../package.json';
import { defaultTheme, defineUserConfig } from 'vuepress';
import backToTopPlugin from '@vuepress/plugin-back-to-top';
import mediumZoomPlugin from '@vuepress/plugin-medium-zoom';
//import checkMdPluin from 'vuepress-plugin-check-md';
import { searchPlugin } from '@vuepress/plugin-search';
import { description } from "../../package.json";
import { defaultTheme, defineUserConfig } from "vuepress";
import backToTopPlugin from "@vuepress/plugin-back-to-top";
import mediumZoomPlugin from "@vuepress/plugin-medium-zoom";
//import checkMdPluin from "vuepress-plugin-check-md";
import { searchPlugin } from "@vuepress/plugin-search";
export default defineUserConfig({
title: 'FacilMap',
description: description,
head: [
['meta', { name: 'theme-color', content: '#3eaf7c' }],
['meta', { name: 'theme-color', content: '#3eaf7c' }], // TODO: Update
['meta', { name: 'apple-mobile-web-app-capable', content: 'yes' }],
['meta', { name: 'apple-mobile-web-app-status-bar-style', content: 'black' }]
],
@ -38,7 +38,8 @@ export default defineUserConfig({
"/users/",
"/users/help/",
"/users/releases/",
"/users/contribute/"
"/users/contribute/",
"/users/privacy/"
]
},
{
@ -53,8 +54,7 @@ export default defineUserConfig({
"/users/files/",
"/users/locate/",
"/users/share/",
"/users/app/",
"/users/privacy/"
"/users/app/"
]
},
{
@ -100,7 +100,8 @@ export default defineUserConfig({
"/developers/client/events",
"/developers/client/methods",
"/developers/client/types",
"/developers/client/advanced"
"/developers/client/advanced",
"/developers/client/changelog"
]
},
{

Wyświetl plik

@ -0,0 +1,4 @@
// :root {
// --c-brand: hsl(184, 100%, 30%);
// --c-brand-light: hsl(184, 100%, 36%);
// }

Wyświetl plik

@ -1,9 +0,0 @@
.home .hero .action-button.secondary {
color: inherit;
background-color: #e8e8e8;
border-bottom-color: #dedede;
&:hover {
background-color: #f3f5f7;
}
}

Wyświetl plik

@ -1,10 +0,0 @@
/**
* Custom palette here.
*
* refhttps://v1.vuepress.vuejs.org/zh/config/#palette-styl
*/
$accentColor = #3eaf7c
$textColor = #2c3e50
$borderColor = #eaecef
$codeBgColor = #282c34

Wyświetl plik

@ -8,6 +8,8 @@ The FacilMap client makes a connection to the FacilMap server using [socket.io](
Note that in the context of the client, a collaborative map will be referred to as __pad__. This is because the collaborative part of FacilMap used to be a separate software called FacilPad.
The socket on the server side maintains different API versions in an attempt to stay backwards compatible with older versions of the client. Have a look at the [./changelog.md](changelog) to find out what has changed when upgrading to a new version of the client.
## Setting it up
@ -17,15 +19,14 @@ Install facilmap-client as a dependency using npm or yarn:
npm install -S facilmap-client
```
or load the client directly from facilmap.org (along with socket.io, which is needed by facilmap-client):
or import the client from a CDN (only recommended for test purposes):
```html
<script src="https://unpkg.com/socket.io-client@4"></script>
<script src="https://unpkg.com/facilmap-client@3"></script>
<script type="module">
import Client from "https://esm.sh/facilmap-client";
</script>
```
The client class will be available as the global `FacilMap.Client` variable.
## TypeScript

Wyświetl plik

@ -6,44 +6,116 @@ When the FacilMap server sends an event to the client that an object has been cr
event and also persists it in its properties. So you have two ways to access the map data: By listening to the map events and
persisting the data somewhere else, or by accessing the properties on the Client object.
If you are using a UI framework that relies on a change detection mechanism (such as Vue.js or Angular), you can override the methods
`_set` and `_delete`. facilmap-client consistently uses these to update any data on its properties.
If you are using a UI framework that relies on a change detection mechanism (such as Vue.js or Angular), facilmap-client provides
a way to make its properties reactive. Internally, the client maintains a `state` object (for any properties related to the client
itself) and a `data` object (for any received map objects). These two objects are stored as private properties on the client object.
All public properties of the client object are just getters that return the data from the two private objects. The client modifies
these two objects consistently in the following way:
* When the client object is constructed, it constructs the private `state` and `data` objects by calling
`this.object = makeReactive(object)`.
* When the client sets a property inside the `state` and `data` objects, it does so by calling `this._set(this.object, key, value)`.
This includes setting nested properties.
* When the client deletes a property inside the `state` and `data` objects, it does so by calling `this._delete(this.object, key)`.
This includes deleting nested properties.
In Vue.js, it could look like this:
```javascript
const client = new Client("https://facilmap.org/");
client._set = Vue.set;
client._delete = Vue.delete;
```
In Angular.js, it could look like this:
```javascript
const client = new Client("https://facilmap.org/");
client._set = (object, key, value) => { $rootScope.$apply(() => { object[key] = value; }); };
client._delete = (object, key) => { $rootScope.$apply(() => { delete object[key]; }); };
```
This way your UI framework will detect changes to any properties on the client, and you can reference values like `client.padData.name`,
`client.disconnected` and `client.loading` in your UI components.
You can override the `_makeReactive`, `_set` and `_delete` methods to make the private properties (and as a consequence the public
getters) of facilmap-client reactive. This way your UI framework will detect changes to any properties on the client, and you can
reference values like `client.padData.name`, `client.disconnected` and `client.loading` in your UI components.
Note that client always replaces whole objects rather than updating individual properties. For example, when a new version of the map settings arrives, `client.padData` is replaced with a new object, or when a new marker arrives, `client.markers[markerId]` is replaced with a new object. This makes deep watches unnecessary in most cases.
### Vue.js 3
## Marker/line data
The data of a marker or a line maps the name of a field (for example `Description`) to a value. Since field names are user-defined, a user could potentially set field names that are at risk to cause errors or even prototype pollution in JavaScript, such as `__proto__`, `constructor` or `toString`. To avoid such problems, the `data` property is a null prototype object by default.
In some situations, using a null prototype object might not be enough. For example, Vue 2s reactivity system adds an `__ob` property to all objects. To handle such cases, facilmap-client allows to use a custom type for the `data` property by specifying the `_encodeData` and `_decodeData` methods to translate the `data` objects from a null prototype object to a custom type and back.
The following example uses an [ES6 Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) for the data:
```javascript
const client = new Client("https://facilmap.org/");
client._decodeData = (data) => new Map(Object.entries(data));
client._encodeData = (data) => Object.fromEntries([...data]);
class ReactiveClient extends Client {
_makeReactive(object) {
return Vue.reactive(object);
}
}
```
Doing this will change the type of the `data` property of the [`Marker`](./types.md#marker) and [`Line`](./types.md#line) types in all properties, methods and events that deal with such objects.
### Vue.js 2
In TypeScript, you can specify the data type using a generic (`Client<Map<string, string>>`).
```javascript
class ReactiveClient extends Client {
_set(object, key, value) {
Vue.set(object, key, value);
}
_delete(object, key) {
Vue.delete(object, key);
}
}
```
### Angular.js
```javascript
class ReactiveClient extends Client {
_set(object, key, value) {
$rootScope.$apply(() => {
object[key] = value;
});
}
_delete(object, key) {
$rootScope.$apply(() => {
delete object[key];
});
}
}
```
### React
```javascript
class ObservableClient extends Client {
_observers = new Set();
subscribe(callback) {
this._observers.add(callback);
return () => {
this._observers.delete(callback);
};
}
_triggerObservers() {
for (const observer of this._observers) {
observer();
}
}
_set(object, key, value) {
object[key] = value;
this._triggerObservers();
}
_delete(object, key) {
delete object[key];
this._triggerObservers();
}
}
function useClientObserver(client, selector) {
React.useSyncExternalStore(
(callback) => client.subscribe(callback),
() => selector(client)
);
}
const MarkerInfo = ({ client, markerId }) => {
const marker = useClientObserver(client, (client) => client.markers[markerId]);
return (
<div>
Marker name: {marker?.name}
</div>
);
}
```
Keep in mind that Reacts `useSyncExternalStore` will rerender the component if the resulting _object reference_ changes.
This means one the one hand that you cannot use this example implementation on a higher up object of the client (such as
`client` itself or `client.markers`), as their identity never changes, causing your component to never rerender. And on
the other hand that you should avoid using it on objects created in the selector (such as returning
`[client.padData.id, client.padData.name]` in order to get multiple values at once), as it will cause your component to
rerender every time the selector is called.

Wyświetl plik

@ -0,0 +1,9 @@
# Changelog
The websocket on the FacilMap server provides different API versions (implemented as socket.io namespaces such as `/` for version 1, `/v2` for version 2, etc.) in order to stay backwards compatible with older clients. Each release of facilmap-client is adapted to a particular API version of the server. When upgrading to a new version of the client, have a look at this page to find out what has changed.
## v4.0.0 (API v2)
* Before, creating a map with an empty name resulted in `padData.name` set to `"Unnamed map"`. Now, an empty name will result in `""` and the UI is responsible for displaying that in an appropriate way.
* Before, creating a marker with an empty name resulted in `marker.name` set to `"Untitled marker"`. Now an empty name will result in `""` and the UI is responsible for displaying that in an appropriate way.
* Before, creating a line with an empty name resulted in `line.name` set to `"Untitled line"`. Now an empty name will result in `""` and the UI is responsible for displaying that in an appropriate way.

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

@ -22,9 +22,9 @@ A bounding box that describes which part of the map the user is currently viewin
* `size` (number, min: 15): The height of the marker in pixels
* `symbol` (string): The symbol name for the marker. Default is an empty string.
* `shape` (string): The shape name for the marker. Default is an empty string (equivalent to `"drop"`).
* `elevation` (number): The elevation of this marker in metres (set by the server)
* `ele` (number or null): The elevation of this marker in metres (set by the server)
* `typeId` (number): The ID of the type of this marker
* `data` (<code>{ &#91;fieldName: string&#93;: string }</code>): The filled out form fields of the marker. By default, this is a null-prototype object to avoid prototype pollution. Have a look at [marker/line data](./advanced.md#marker-line-data) for more details.
* `data` (<code>{ &#91;fieldName: string&#93;: string }</code>): The filled out form fields of the marker. This is a null-prototype object to avoid prototype pollution.
## Line
@ -45,14 +45,15 @@ separately through `linePoints` events.
* `mode` (string): The routing mode, an empty string for no routing, or `car`, `bicycle`, `pedestrian`, or `track`
* `colour` (string): The colour of this marker as a 6-digit hex value, for example `0000ff`
* `width` (number, min: 1): The width of the line
* `stroke` (string): The stroke style of the line, an empty string for solid or `dashed` or `dotted`.
* `name` (string): The name of the line
* `distance` (number): The distance of the line in kilometers (set by the server)
* `ascent`, `descent` (number): The total ascent/descent of the line in metres (set by the server)
* `time` (number): The time it takes to travel the route in seconds (only if routing mode is `car`, `bicycle` or `pedestrian`) (set by the server)
* `left`, `top`, `right`, `bottom` (number): The bounding box of the line (set by the server)
* `extraInfo` (<code>{ &#91;type: string&#93;: Array<&#91;startIdx: number, endIdx: number, type: number&#93;>> }</code>): Extra details about the route (set by the server). `type` can be for example `steepness`, `surface` or `waytype`. `startIdx` and `endIdx` describe a segment on the trackpoints of the route, the meaning of `type` can be seen in the documentation of [Leaflet.Heightgraph](https://github.com/GIScience/Leaflet.Heightgraph/blob/master/example/mappings.js).
* `extraInfo` (<code>{ &#91;type: string&#93;: Array<&#91;startIdx: number, endIdx: number, type: number&#93;>> }</code> or null): Extra details about the route (set by the server). `type` can be for example `steepness`, `surface` or `waytype`. `startIdx` and `endIdx` describe a segment on the trackpoints of the route, the meaning of `type` can be seen in the documentation of [Leaflet.Heightgraph](https://github.com/GIScience/Leaflet.Heightgraph/blob/master/example/mappings.js).
* `typeId` (number): The ID of the type of this line
* `data` (<code>{ &#91;fieldName: string&#93;: string }</code>): The filled out form fields of the line. By default, this is a null-prototype object to avoid prototype pollution. Have a look at [marker/line data](./advanced.md#marker-line-data) for more details.
* `data` (<code>{ &#91;fieldName: string&#93;: string }</code>): The filled out form fields of the line. This is a null-prototype object to avoid prototype pollution.
* `trackPoints`:
* In the `lines` property of the client, an object of the format [<code>{ &#91;idx: number&#93;: TrackPoint }</code>](#trackpoint)
* When creating/updating a line with the routing mode `track`, an array of the format [`TrackPoint[]`](#trackpoint)
@ -68,7 +69,7 @@ their `idx` property.
* `lat` (number, min: -90, max: 90): The latitude of this point
* `lon` (number, min: -180, max: 180): The longitude of this point
* `zoom` (number, min: 1, max: 20): The miminum zoom level from which this track point makes sense to show
* `ele` (number): The elevation of this track point in metres (set by the server). Not set for high zoom levels.
* `ele` (number or null): The elevation of this track point in metres (set by the server). Not set for high zoom levels.
## PadData
@ -82,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
@ -107,20 +109,21 @@ their `idx` property.
* `id` (number): The ID of this type
* `name` (string): The name of this type
* `type` (string): `marker` or `line`
* `defaultColour`, `defaultSize`, `defaultSymbol`, `defaultShape`, `defaultWidth`, `defaultMode` (string/number): Default values for the
* `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`, `modeFixed` (boolean): Whether those values are fixed and
* `colourFixed`, `sizeFixed`, `symbolFixed`, `shapeFixed`, `widthFixed`, `strokeFixed`, `modeFixed` (boolean): Whether those values are fixed and
cannot be changed for an individual object
* `fields` ([object]): The form fields for this type. Each field has the following properties:
* `name` (string): The name of the field. This is at the same time the key in the `data` properties of markers and lines
* `oldName` (string): When renaming a field (using [`editType(data)`](./methods.md#edittype-data)), specify the former name here
* `type` (string): The type of field, one of `textarea`, `dropdown`, `checkbox`, `input`
* `controlColour`, `controlSize`, `controlSymbol`, `controlShape`, `controlWidth` (boolean): If this field is a dropdown, whether the different options set a specific property on the object
* `controlColour`, `controlSize`, `controlSymbol`, `controlShape`, `controlWidth`, `controlStroke` (boolean): If this field is a dropdown, whether the different options set a specific property on the object
* `default` (string/boolean): The default value of this field
* `options` ([object]): If this field is a dropdown or a checkbox, an array of objects with the following properties. For a checkbox, the array has to have 2 items, the first representing the unchecked and the second the checked state.
* `value` (string): The value of this option.
* `oldValue` (string): When renaming a dropdown option (using [`editType(data)`](./methods.md#edittype-data)), specify the former value here
* `colour`, `size`, `shape`, `symbol`, `width` (string/number): The property value if this field controls that property
* `colour`, `size`, `shape`, `symbol`, `width`, `stroke` (string/number): The property value if this field controls that property
## SearchResult

Wyświetl plik

@ -5,9 +5,7 @@
3. Copy `config.env.example` to `config.env` and adjust the settings
4. Run `yarn server` inside the `server` directory
For developing the frontend/client, FacilMap server can integrate webpack-dev-server. This server will automatically
recompile the frontend files when a file changes, and even delay reloads until the compilation has finished. To run
the dev server, run `yarn dev-server` instead of `yarn server` in the `server` directory. Note that changes in the `client`, `types` or `leaflet` directory still have to be built using `yarn build` in the respective directories for the dev-server to notice them.
For developing the frontend/client, FacilMap server can integrate Vite. This server will transpile frontend files on the fly can even apply changes to Vue components without having to reload the page. To run the dev server, run `yarn dev-server` instead of `yarn server` in the `server` directory. Note that changes in the `client`, `types` or `leaflet` directory still have to be built using `yarn build` in the respective directories for the dev-server to notice them.
While developing the server, run `yarn ts-server`, which will start the server straight from the TypeScript files (which makes it obsolete to run `yarn build` every time before restarting the server).

Wyświetl plik

@ -1,33 +1,58 @@
# Overview
The FacilMap frontend is a [Vue.js](https://vuejs.org/) app that provides the main FacilMap UI.
The FacilMap frontend is a [Vue.js](https://vuejs.org/) app that provides the main FacilMap UI. You can use it to integrate a modified or extended version of the FacilMap UI or its individual components into your app. If you just want to embed the whole FacilMap UI without any modifications, it is easier to [embed it as an iframe](../embed.md).
The FacilMap frontend is available as the [facilmap-frontend](https://www.npmjs.com/package/facilmap-frontend) package on NPM.
Right now there is no documentation of the individual UI components, nor are they designed to be reusable or have a stable interface. Use them at your own risk and have a look at the [source code](https://github.com/FacilMap/facilmap/tree/main/frontend/src/lib) to get an idea how to use them.
## Setup
The FacilMap frontend uses [Bootstrap-Vue](https://bootstrap-vue.org/), but does not install it by default to provide bigger flexibility. When using the FacilMap frontend, make sure to have it [set up](https://bootstrap-vue.org/docs#using-module-bundlers):
The FacilMap frontend is published as an ES module. It is meant to be used as part of an app that is built using a bundler, rather than importing it directly into a HTML file.
The frontend heavily relies on Vue, Bootstrap and other large libraries. These are not part of the bundle, but are imported using `import` JavaScript statements. This avoids duplicating these dependencies if you also use them elsewhere in your app.
To get FacilMap into your app, install the NPM package using `npm install -S facilmap-frontend` or `yarn add facilmap-frontend` and then import the components that you need.
```javascript
import Vue from "vue";
import { BootstrapVue } from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import { FacilMap } from "facilmap-frontend";
```
Vue.use(BootstrapVue);
The FacilMap UI uses a slightly adjusted version of [Bootstrap 5](https://getbootstrap.com/) for styling. To avoid duplication if you want to integrate FacilMap into an app that is already using Bootstrap, the Bootstrap CSS is not part of the main export. If you want to use FacilMaps default Bootstrap styles, import them separately from `facilmap-frontend/bootstrap.css`:
```javascript
import "facilmap-frontend/bootstrap.css";
```
## Structure
The FacilMap server renders a static HTML file, which already contains some metadata about the map (such as the map title and the search engines policy configured for the particular map). It then renders a Vue.js app that renders the FacilMap UI using the [`FacilMap`](./facilmap.md) component. It sets the props and listens to the events of the FacilMap app in a way that the URL and document title are updated as the user opens or closes collaborative maps or their metadata changes.
The [`<FacilMap>`](./facilmap.md) component renders the whole frontend, including a component that provides a connection to the FacilMap server. If you want to render the whole FacilMap UI, simply render that component rather than rendering all the components of the UI individually.
The FacilMap frontend makes heavy use of [provide/inject](https://vuejs.org/v2/api/#provide-inject) feature of Vue.js. Most FacilMap components require the presence of certain injected objects to work. When rendering the `FacilMap` component, the following component hierarchy is created: `FacilMap` (provides `Context`) → `ClientProvider` (provides `Client`) → `LeafletMap` (provides `MapComponents` and `MapContext`) → any UI components. The injected objects have the following purpose:
* `Context`: A reactive object that contains general details about the context in which this FacilMap runs, such as the props that were passed to the `FacilMap` component and the currently opened map ID.
* `Client`: A reactive instance of the FacilMap client.
* `MapComponents`: A non-reactive object that contains all the Leaflet components of the map.
* `MapContext`: A reactive object that contains information about the current state of some Leaflet components, for example the current position of the map. It also acts as an event emitter that is used for communication between different components of the UI.
```vue
<FacilMap
baseUrl="https://facilmap.org/"
serverUrl="https://facilmap.org/"
:padId="undefined"
></FacilMap>
```
By passing child components to the `FacilMap` component, you can yourself make use of these injected objects when building extensions for FacilMap. When using class components with [vue-property-decorator](https://github.com/kaorun343/vue-property-decorator), you can inject these objects by using the `InjectContext()`, `InjectMapComponents()`, … decorators. Otherwise, you can inject them by using `inject: [CONTEXT_INJECT_KEY, MAP_COMPONENTS_INJECT_KEY, …]`.
The `<FacilMapContextProvider>` component provides a reactive object that acts as the central hub for all components to provide their public state (for example the facilmap-client object, the current map ID, the current selection, the current map view) and their public API (for example to open a search box tab, to open a map, to set route destinations). All components that need to communicate with each other use this context for that. This means that if you want to render individual UI components, you need to make sure that they are rendered within a `<FacilMapContextProvider>` (or within a `<FacilMap>`, which renders the context provider for you). It also means that if you want to add your own custom UI components, they will benefit greatly from accessing the context.
The context is both injected and exposed by both the `<FacilMap>` and the `<FacilMapContextProvider>` component. To access the injected context, import the `injectContextRequired` function from `facilmap-frontend` and call it in the setup function of your component. For this, the component must be a child of `<FacilMap>` or `<FacilMapContextProvider>`. To access the exposed context, use a ref:
```vue
<script setup lang="ts">
import { FacilMap } from "facilmap-frontend";
const facilMapRef = ref<InstanceType<typeof FacilMap>>();
// Access the context as facilMapRef.value.context
</script>
<template>
<FacilMap ref="facilMapRef"></FacilMap>
</template>
```
For now there is no documentation of the context object. To get an idea of its API, have a look at its [source code](https://github.com/FacilMap/facilmap/blob/main/frontend/src/lib/components/facil-map-context-provider/facil-map-context.ts).
## Styling

Wyświetl plik

@ -3,53 +3,51 @@
The `FacilMap` component renders a complete FacilMap UI. It can be used like this in a Vue.js app:
```vue
<template>
<FacilMap base-url="/" server-url="https://facilmap.org/" pad-id="my-map"></FacilMap>
</template>
<script>
<script setup>
import { FacilMap } from "facilmap-frontend";
export default {
components: { FacilMap }
};
</script>
<template>
<FacilMap
baseUrl="https://facilmap.org/"
serverUrl="https://facilmap.org/"
padId="my-map"
></FacilMap>
</template>
```
In a non-Vue.js app, it can be embedded like this:
```javascript
import { FacilMap } from "facilmap-frontend";
import Vue from "vue";
import Vue, { createApp, defineComponent, h } from "vue";
new Vue({
el: "#facilmap", // A selector whose DOM element will be replaced with the FacilMap component
components: { FacilMap },
render: (h) => (
h("FacilMap", {
props: {
baseUrl: "/",
serverUrl: "https://facilmap.org/",
padId: "my-map"
}
})
)
});
createApp(defineComponent({
setup() {
return () => h(FacilMap, {
baseUrl: "https://facilmap.org/",
serverUrl: "https://facilmap.org/",
padId: "my-map"
});
}
})).mount(document.getElementById("facilmap")!); // A DOM element that be replaced with the FacilMap component
```
## Props
Note that all of these props are reactive and can be changed while the map is open.
* `baseUrl` (string, required): Collaborative maps should be reachable under `${baseUrl}${mapId}`, while the general map should be available under `${baseUrl}`. For the default FacilMap installation, `baseUrl` would be `https://facilmap.org/` (or simply `/`). It needs to end with a slash. It is used to create the map URL for example in the map settings or when switching between different maps (only in interactive mode).
* `baseUrl` (string, required): Collaborative maps should be reachable under `${baseUrl}${mapId}`, while the general map should be available under `${baseUrl}`. For the default FacilMap installation, `baseUrl` would be `https://facilmap.org/`. It needs to end with a slash. It is used to create the map URL for example in the map settings or when switching between different maps (only in interactive mode).
* `serverUrl` (string, required): The URL under which the FacilMap server is running, for example `https://facilmap.org/`. This is invisible to the user.
* `padId` (string, optional): The ID of the collaborative map that should be opened. If this is undefined, no map is opened. This is reactive, when a new value is passed, a new map is opened. Note that the map ID may change as the map is open, either because the ID of the map is changed in the map settings, or because the user navigates to a different map (only in interactive mode). Use `:padId.sync` to get a [two-way binding](https://vuejs.org/v2/guide/components-custom-events.html#sync-Modifier) (or listen to the `update:padId` event).
* `toolbox` (boolean, optional): Whether the toolbox should be shown. Default is `true`.
* `search` (boolean, optional): Whether the search box should be shown. Default is `true`.
* `autofocus` (boolean, optional): Whether the search field should be focused. Default is `false`.
* `legend` (boolean, optional): Whether the legend should be shown (if it is available). Default is `true`.
* `interactive` (boolean, optional): Whether [interactive mode](../embed.md#interactive-mode) should be enabled. Default is `true`.
* `linkLogo` (boolean, optional): If `true`, the FacilMap logo will be a link that opens the map in a new window. Default is `false`.
* `updateHash` (boolean, optional): Whether `location.hash` should be synchonised with the current map view. Default is `false`.
* `padId` (string or undefined, required): The ID of the collaborative map that should be opened. If this is undefined, no map is opened. This is reactive, when a new value is passed, a new map is opened. Note that the map ID may change as the map is open, either because the ID of the map is changed in the map settings, or because the user navigates to a different map (only in interactive mode). Use `v-model:padId` to get a [two-way binding](https://vuejs.org/guide/essentials/forms.html) (or listen to the `update:padId` event).
* `settings` (object, optional): An object with the following properties:
* `toolbox` (boolean, optional): Whether the toolbox should be shown. Default is `true`.
* `search` (boolean, optional): Whether the search box should be shown. Default is `true`.
* `autofocus` (boolean, optional): Whether the search field should be focused. Default is `false`.
* `legend` (boolean, optional): Whether the legend should be shown (if it is available). Default is `true`.
* `interactive` (boolean, optional): Whether [interactive mode](../embed.md#interactive-mode) should be enabled. Default is `true`.
* `linkLogo` (boolean, optional): If `true`, the FacilMap logo will be a link that opens the map in a new window. Default is `false`.
* `updateHash` (boolean, optional): Whether `location.hash` should be synchonised with the current map view. Default is `false`.
## Events

Wyświetl plik

@ -2,7 +2,7 @@
FacilMap comes with a large selection of icons (called “symbols” in the code) and marker shapes. The icons come from the following sources:
* All the [Open SVG Map Icons](https://github.com/twain47/Open-SVG-Map-Icons/) (these are the ones used by Nominatim for search results)
* All the [Glyphicons](https://getbootstrap.com/docs/3.4/components/#glyphicons-glyphs) of Bootstrap 3.
* A selection of [Glyphicons](https://getbootstrap.com/docs/3.4/components/#glyphicons-glyphs) from Bootstrap 3.
* A few icons from [Material Design Iconic Font](https://zavoloklom.github.io/material-design-iconic-font/).
* A selection of icons from [Font Awesome](https://fontawesome.com/).
@ -10,19 +10,23 @@ FacilMap uses these icons as part of markers on the map and in regular UI elemen
facilmap-leaflet includes all the icons and marker shapes and provides some helper methods to access them in different sizes and styles.
To make the bundle size smaller, the symbols are separated into two sets:
* The *core* symbols are included in the main facilmap-leaflet bundle. This includes all all symbols that are used by FacilMap as UI elements.
* The *extra* symbols are included in a separate file. When you call any of the methods below for the first time for an extra symbol, this separate file is loaded using an async import. You can also explicitly load the extra symbols at any point of time by calling `preloadExtraSymbols()`.
## Available symbols and shapes
`symbolList` and `shapeList` are arrays of strings that contain the names of the available symbols and marker shapes.
`symbolList` and `shapeList` are arrays of strings that contain the names of all the available symbols (core and extra) and marker shapes. The `coreSymbolList` array contains only the names of the core symbols.
In addition to the symbols listed in `symbolList`, any single character can be used as a symbol.
In addition to the symbols listed in `symbolList`, any single character can be used as a symbol. Single-character symbols are rendered in the browser, they dont require loading the extra symbols.
## Get a symbol
The following methods returns a simple monochrome icon.
* `getSymbolCode(colour, size, symbol)`: Returns a raw SVG object with the code of the symbol as a string.
* `getSymbolUrl(colour, size, symbol)`: Returns the symbol as a `data:` URL (that can be used as the `src` of an `img` for example)
* `getSymbolHtml(colour, size, symbol)`: Returns the symbol as an SVG element source code (as a string) that can be embedded straight into a HTML page.
* `async getSymbolCode(colour, size, symbol)`: Returns a raw SVG object with the code of the symbol as a string.
* `async getSymbolUrl(colour, size, symbol)`: Returns the symbol as a `data:` URL (that can be used as the `src` of an `img` for example)
* `async getSymbolHtml(colour, size, symbol)`: Returns the symbol as an SVG element source code (as a string) that can be embedded straight into a HTML page.
The following arguments are expected:
* `colour`: Any colour that would be acceptable in SVG, for example `#000000` or `currentColor`.
@ -33,10 +37,10 @@ The following arguments are expected:
The following methods returns a marker icon with the specified shape and the specified symbol inside.
* `getMarkerCode(colour, height, symbol, shape, highlight)`: Returns a raw SVG object with the code of the marker as a string.
* `getMarkerUrl(colour, height, symbol, shape, highlight)`: Returns the marker as a `data:` URL (that can be used as the `src` of an `img` for example)
* `getMarkerHtml(colour, height, symbol, shape, highlight)`: Returns the marker as an SVG element source code (as a string) that can be embedded straight into a HTML page.
* `getMarkerIcon(colour, height, symbol, shape, highlight)`: Returns the marker as a [Leaflet Icon](https://leafletjs.com/reference.html#icon) that can be used for Leaflet markers. The anchor point is set correctly.
* `async getMarkerCode(colour, height, symbol, shape, highlight)`: Returns a raw SVG object with the code of the marker as a string.
* `async getMarkerUrl(colour, height, symbol, shape, highlight)`: Returns the marker as a `data:` URL (that can be used as the `src` of an `img` for example)
* `async getMarkerHtml(colour, height, symbol, shape, highlight)`: Returns the marker as an SVG element source code (as a string) that can be embedded straight into a HTML page.
* `getMarkerIcon(colour, height, symbol, shape, highlight)`: Returns the marker as a [Leaflet Icon](https://leafletjs.com/reference.html#icon) that can be used for Leaflet markers. The anchor point is set correctly. The Icon object is returned synchronously and updates its `src` automatically as soon as it is loaded.
The following arguments are expected:
* `colour`: A colour in hex format, for example `#000000`.

Wyświetl plik

@ -25,6 +25,22 @@ const byName = (layerMap) => Object.fromEntries(Object.entries(layerMap).map(([k
L.control.layers(byName(layers.baseLayers), byName(layers.overlays)).addTo(map);
```
## Set the layer options
There are some global layer options that change the behaviour of the available layers:
* `limaLabsToken`: A [Lima Labs](https://maps.lima-labs.com/) API token. If defined, the Lima Labs layer will be available and used as the default layer instead of Mapnik. Lima Labs layers are very similar to Mapnik in style, but they are double resolution (so they dont look pixely on high-resolution screens) and have English place names in addition to the local language.
To set the global layer options, use the `setLayerOptions()` function:
```javascript
import { setLayerOptions } from "facilmap-leaflet";
setLayerOptions({
limaLabsToken: "..."
});
```
Note that to avoid unexpected inconsistencies, this should be called before `getLayers()` or any other of functions documented on this page are called.
## Change the available layers
To change the available layers for a particular Leaflet map, set the `_fmLayers` properties of that map.

Wyświetl plik

@ -64,7 +64,7 @@ document.getElementById("draw-line-button").addEventListener("click", async () =
let routePoints;
try {
routePoints = await linesLayer.drawLine({ colour: "0000ff", width: 7 });
routePoints = await linesLayer.drawLine({ colour: "0000ff", width: 7, stroke: "" });
} catch (error) {
alert(error);
} finally {

Wyświetl plik

@ -5,6 +5,9 @@ The config of the FacilMap server can be set either by using environment variabl
| Variable | Required | Default | Meaning |
|-----------------------|----------|-------------|----------------------------------------------------------------------------------------------------------------------------------|
| `USER_AGENT` | * | | Will be used for all HTTP requests (search, routing, GPX/KML/OSM/GeoJSON files). You better provide your e-mail address in here. |
| `APP_NAME` | | | If specified, will replace “FacilMap” as the name of the app throughout the UI. |
| `TRUST_PROXY` | | | Whether to trust the X-Forwarded-* headers. Can be `true` or a comma-separated list of IP subnets (see the [express documentation](https://expressjs.com/en/guide/behind-proxies.html)). Currently only used to calculate the base URL for the `opensearch.xml` file. |
| `BASE_URL` | | | If `TRUST_PROXY` does not work for your particular setup, you can manually specify the base URL where FacilMap can be publicly reached here. |
| `HOST` | | | The ip address to listen on (leave empty to listen on all addresses) |
| `PORT` | | `8080` | The port to listen on. |
| `DB_TYPE` | | `mysql` | The type of database. Either `mysql`, `postgres`, `mariadb`, `sqlite`, or `mssql`. |
@ -15,11 +18,31 @@ The config of the FacilMap server can be set either by using environment variabl
| `DB_PASSWORD` | | `facilmap` | The password to connect to the database with. |
| `ORS_TOKEN` | * | | [OpenRouteService API key](https://openrouteservice.org/). |
| `MAPBOX_TOKEN` | * | | [Mapbox API key](https://www.mapbox.com/signup/). |
| `MAPZEN_TOKEN` | | | [Mapzen API key](https://mapzen.com/developers/sign_up). |
| `MAXMIND_USER_ID` | | | [MaxMind user ID](https://www.maxmind.com/en/geolite2/signup). |
| `MAXMIND_LICENSE_KEY` | | | MaxMind license key. |
| `LIMA_LABS_TOKEN` | | | [Lima Labs](https://maps.lima-labs.com/) API key |
| `HIDE_COMMERCIAL_MAP_LINKS` | | | Set to `1` to hide the links to Google/Bing Maps in the “Map style” menu. |
| `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.
* Maxmind provides a free database that maps IP addresses to approximate locations. FacilMap downloads this database to decide the initial map view for users (IP addresses are looked up in FacilMaps copy of the database, on IP addresses are sent to Maxmind). This API key is optional, if it is not set, the default view will be the whole world.
* Mapzen is used to look up the elevation info for search results. The API key is optional, if it is not set, no elevation info will be available for search results.
* Lima Labs is used for nicer and higher resolution map tiles than Mapnik. The API key is optional, if it is not set, Mapnik will be the default map style instead.
## Custom CSS file
To include a custom CSS file in the UI, set the `CUSTOM_CSS_FILE` environment variable to the file path.
When running FacilMap with docker, you can mount your CSS file as a volume into the container, for example with the following docker-compose configuration:
```yaml
environment:
CUSTOM_CSS_FILE: /opt/facilmap/custom.css
volumes:
- ./custom.css:/opt/facilmap/custom.css
```
Your custom CSS file will be included in the map UI and in the table export. You can distinguish between the two by using the `html.fm-facilmap-map` and `html.fm-facilmap-table` selectors.

Wyświetl plik

@ -20,9 +20,13 @@ services:
ports:
- 8080
links:
- db
- mysql
depends_on:
mysql:
condition: service_healthy
environment:
USER_AGENT: My FacilMap (https://facilmap.example.org/, facilmap@example.org)
TRUST_PROXY: "true"
DB_TYPE: mysql
DB_HOST: db
DB_NAME: facilmap
@ -30,18 +34,20 @@ services:
DB_PASSWORD: password
ORS_TOKEN: # Get an API key on https://go.openrouteservice.org/ (needed for routing)
MAPBOX_TOKEN: # Get an API key on https://www.mapbox.com/signup/ (needed for routing)
MAPZEN_TOKEN: # Get an API key on https://mapzen.com/developers/sign_up (needed for elevation info)
MAXMIND_USER_ID: # Sign up here https://www.maxmind.com/en/geolite2/signup (needed for geoip lookup to show initial map state)
MAXMIND_LICENSE_KEY:
LIMA_LABS_TOKEN: # Get an API key on https://maps.lima-labs.com/ (optional, needed for double-resolution tiles)
restart: unless-stopped
db:
mysql:
image: mariadb
environment:
MYSQL_DATABASE: facilmap
MYSQL_USER: facilmap
MYSQL_PASSWORD: password
MYSQL_RANDOM_ROOT_PASSWORD: "true"
cmd: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
healthcheck:
test: mysqladmin ping -h 127.0.0.1 -u $$MYSQL_USER --password=$$MYSQL_PASSWORD
restart: unless-stopped
```
@ -55,9 +61,13 @@ services:
ports:
- 8080
links:
- db
- postgres
depends_on:
postgres:
condition: service_healthy
environment:
USER_AGENT: My FacilMap (https://facilmap.example.org/, facilmap@example.org)
TRUST_PROXY: "true"
DB_TYPE: postgres
DB_HOST: db
DB_NAME: facilmap
@ -65,16 +75,18 @@ services:
DB_PASSWORD: password
ORS_TOKEN: # Get an API key on https://go.openrouteservice.org/ (needed for routing)
MAPBOX_TOKEN: # Get an API key on https://www.mapbox.com/signup/ (needed for routing)
MAPZEN_TOKEN: # Get an API key on https://mapzen.com/developers/sign_up (needed for elevation info)
MAXMIND_USER_ID: # Sign up here https://www.maxmind.com/en/geolite2/signup (needed for geoip lookup to show initial map state)
MAXMIND_LICENSE_KEY:
LIMA_LABS_TOKEN: # Get an API key on https://maps.lima-labs.com/ (optional, needed for double-resolution tiles)
restart: unless-stopped
db:
postgres:
image: postgis/postgis
environment:
POSTGRES_USER: facilmap
POSTGRES_PASSWORD: password
POSTGRES_DB: facilmap
healthcheck:
test: pg_isready -d $$POSTGRES_DB
restart: unless-stopped
```
@ -86,5 +98,5 @@ To manually create the necessary docker containers, use these commands:
```bash
docker create --name=facilmap_db -e MYSQL_DATABASE=facilmap -e MYSQL_USER=facilmap -e MYSQL_PASSWORD=password -e MYSQL_RANDOM_ROOT_PASSWORD=true --restart=unless-stopped mariadb --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
docker create --link=facilmap_db -p 8080 --name=facilmap -e "USER_AGENT=My FacilMap (https://facilmap.example.org/, facilmap@example.org)" -e DB_TYPE=mysql -e DB_HOST=facilmap_db -e DB_NAME=facilmap -e DB_USER=facilmap -e DB_PASSWORD=facilmap -e ORS_TOKEN= -e MAPBOX_TOKEN= -e MAPZEN_TOKEN= -e MAXMIND_USER_ID= -e MAXMIND_LICENSE_KEY= --restart=unless-stopped facilmap/facilmap
docker create --link=facilmap_db -p 8080 --name=facilmap -e "USER_AGENT=My FacilMap (https://facilmap.example.org/, facilmap@example.org)" -e TRUST_PROXY=true -e DB_TYPE=mysql -e DB_HOST=facilmap_db -e DB_NAME=facilmap -e DB_USER=facilmap -e DB_PASSWORD=facilmap -e ORS_TOKEN= -e MAPBOX_TOKEN= -e MAXMIND_USER_ID= -e MAXMIND_LICENSE_KEY= -e LIMA_LABS_TOKEN= --restart=unless-stopped facilmap/facilmap
```

Wyświetl plik

@ -1,7 +1,7 @@
# Standalone
The FacilMap server is written in [node.js](https://nodejs.org/en/). To run the FacilMap server, the following dependencies are needed:
* You need to have a recent version of node.js and npm installed.
The FacilMap server runs on [Node.js](https://nodejs.org/en/). To run the FacilMap server, the following dependencies are needed:
* You need to have a recent version of Node.js and npm installed.
* You need to create a database on one of the systems supported by [Sequelize](https://sequelize.org/master/), it is recommended to use MySQL/MariaDB.
* When creating a MySQL/MariaDB database for FacilMap, make sure to use the `utf8mb4` charset/collation to make sure that characters from all languages can be used on a map. By default, MySQL/MariaDB uses the `latin1` charset, which mostly supports only basic latin characters. When you start the FacilMap server for the first time, the necessary tables are created using the charset of the database.
* When using PostgreSQL, the PostGIS extensions must be enabled.
@ -16,7 +16,7 @@ A bundled version of the FacilMap server is published on NPM as [facilmap-server
3. Create a `config.env` file based on [`config.env.example`](https://github.com/FacilMap/facilmap/blob/main/config.env.example) and to adjust the [configuration](./config.md).
4. Start the FacilMap server by running `~/.local/bin/facilmap-server dotenv_config_path=config.env`.
FacilMap will need write access to the directory `~/.local/lib/node_modules/facilmap-server/cache`. All other files and directories can be read-only. To harden the FacilMap installation, make the whole installation folder owned by root, but create the cache directory and make it owned by the facilmap user.
FacilMap will need write access to the directory `~/.local/lib/node_modules/.cache/facilmap-server` (or specify another directory in the `CACHE_DIR` environment variable). All other files and directories can be read-only. To harden the FacilMap installation, make the whole installation folder owned by root, but create the cache directory and make it owned by the facilmap user.
## Run the development version

Wyświetl plik

@ -12,8 +12,9 @@ Financial contributions are very much appreciated and can be sent through the fo
width: 740,
gap: 20,
links: {
'GitHub Sponsors': 'https://github.com/sponsors/FacilMap',
'GitHub': 'https://github.com/sponsors/FacilMap',
'Liberapay': 'https://liberapay.com/facilmap/',
'Ko-fi': 'https://ko-fi.com/facilmap',
'PayPal': 'https://www.paypal.com/donate?hosted_button_id=FWR59UXY6HGGS',
'Patreon': 'https://www.patreon.com/facilmap',
'Bitcoin': 'bitcoin:1PEfenaGXC9qNGQSuL5o6f6doZMYXRFiCv'

Wyświetl plik

@ -2,26 +2,39 @@
A map can be exported as a file, in order to use it in another application or to create a backup.
To export a map, in the [toolbox](../ui/#toolbox), click “Tools” and then one of the “Export” options. Note that when a [filter](../filter/) is active, only the objects that match the filter are exported.
To export a map, in the [toolbox](../ui/#toolbox), click “Tools” and then “Export”. This opens a dialog where you can configure in what format to export the map.
The exports are available under their own URL. In the context menu (right click) of the export links, you can copy the URL to use it elsewhere.
## Formats
## GeoJSON
### GPX
When exporting a map as GeoJSON, all markers and lines (or, if a [filter](../filter/) is active, those that match the filter) including their data fields, all [saved views](../views/), and all [types](../types/) that represent the exported markers/lines are exported. This means that if no filter is active, this is suitable to make a backup of the whole map (apart from the [map settings](../map-settings/) and the [edit history](../history/)). Such a file can also be [imported](../import/) again into a map to restore the backup.
GPX is a format that is understood by many geographic apps and navigation devices. Exporting a map as GPX will export all markers and lines on the map.
## GPX
When exporting to GPX, one of three “Route type” options needs to be selected:
* **Track points:** Lines that are calculated routes will be exported as GPX tracks. This means that the whole course of the route is saved in the file.
* **Track points, one file per line (ZIP file):** Like “Track points”, but rather than creating one GPX file containing all markers/lines of the map, a ZIP file will be generated that contains one GPX file with all markers and a folder with one GPX file per line. This is useful for importing the file into OsmAnd, which only supports one track per file.
* **Route points:** Lines that are calculated routes will be exported as GPX routes. This means that only the route destinations are saved in the file, and the app or device that opens the file is responsible for calculating the best route.
GPX is a format that is understood by many geographic apps and navigation devices. Exporting a map as GPX will export all markers and lines on the map (or, if a [filter](../filter/) is active, those that match the filter). There are two options:
* **Export as GPX (tracks):** Lines that are calculated routes will be exported as GPX tracks. This means that the whole course of the route is saved in the file.
* **Export as GPX (routes):** Lines that are calculated routes will be exported as GPX routes. This means that only the route destinations are saved in the file, and the app or device that opens the file is responsible for calculating the best route.
The marker/line description and any [custom fields](../types/) will be saved in the description of the GPX objects. The marker/line styles are not exported, with the exception of some basic style settings supported by OsmAnd.
The marker/line description and any [custom fields](../types/) will be saved in the description of the GPX objects.
### GeoJSON
## Table
When exporting a map as GeoJSON, all markers and lines including their data fields, all [saved views](../views/), and all [types](../types/) that represent the exported markers/lines are exported. This means that if no filter is active, this is suitable to make a backup of the whole map (apart from the [map settings](../map-settings/) and the [edit history](../history/)). Such a file can also be [imported](../import/) again into a map to restore the backup.
The table export is a static HTML export of all the markers and lines (or, if a [filter](../filter/) is active, those that match the filter) in table form. All the field values of the markers/lines are shown, along with some metadata (name, coordinates, distance, time).
### HTML
A separate table for each [type](../types/) is shown. Individual types can be hidden by clicking the down arrow next to their heading.
The HTML export will render a web page that contains a table for each [type](../types/) that lists each marker/line of that type along with all field values and some metadata (name, coordinates, distance, time). Types can be shown/hidden by clicking on the down arrow next to their heading, and the table can be sorted by an individual data attribute by clicking on its column header.
Table columns can be sorted by clicking on their header.
For HTML exports, there additional “Copy to clipboard” export method is available. This will copy the table for a single type into the clipboard. Such a table can be pasted directly into a spreadsheet application such as EtherCalc or LibreOffice Calc.
### CSV
CSV files can be opened by most spreadsheet applications. A CSV export will contain all the markers/lines of a single type, along with their field values and some metadata (name, coordinates, distance, time). Note that CSV only supports plain text, so any rich text formatting will be lost.
## Generate a link
By default, the export dialog will create a file and download or open it. By selecting “Generate link” as the export method, you can copy a URL to the exported file instead. This URL will always generate the file with the lastest map data according to the export settings that you have defined, so you can use it to link to the export from a website or a browser bookmark or to integrate the export with another tool that should periodically create copies of your map data.
## Apply a filter
If you have an active [filter](../filter/), the export dialog will show an additional “Apply filter” option. When this option is enabled, the exported file will only contain the map objects that match the filter.

Wyświetl plik

@ -1,6 +1,6 @@
# Filters
Filters provide a way to temporarily show/hide certain markers/lines based on user-defined criteria. Filters are only affect what you currently see on the map, they are not persisted and do not affect what other people see on the map. Filters can be persisted as part of [saved views](../views/).
Filters provide a way to temporarily show/hide certain markers/lines based on user-defined criteria. Filters only affect what you currently see on the map, they are not persisted and do not affect what other people see on the map. Filters can be persisted as part of [saved views](../views/).
If you simply want to show/hide objects of a certain type, clicking items in the [legend](../legend/) will toggle the visibility of those types by automatically applying a filter expression.
@ -16,6 +16,8 @@ Field values are available as `data`. By default, markers and objects have only
Be aware that in filter expression, comparison is case sensitive. So the above expression would not match an object whose status is “done”. For case-insensitive comparison, compare the lower-case values: `lower(data.Status) == "done"`.
Checkbox field values are internally represented by the values `0` (unchecked) and `1` (checked). For example, to how only values where the checkbox field “Confirmed” is checked, use `data.Confirmed == 1`.
The regular expression operator `~=` allows for more advanced text matching. For example, to show all objects whose name contains “Untitled”, use `lower(name) ~= "untitled"`. Regular expressions allow to define very complex criteria what the text should look like. There are plenty of tutorials online how to use regular expressions, for example [RegexOne](https://regexone.com/).
### Filter by type

Wyświetl plik

@ -1,5 +1,5 @@
# Getting help
If you have a question that is not answered by this documentation, feel free to to open a [discussion on GitHub](https://github.com/FacilMap/facilmap/discussions).
If you have a question that is not answered by this documentation, feel free to to open a [discussion on GitHub](https://github.com/FacilMap/facilmap/discussions) or join the [Matrix chat room](https://matrix.to/#/#facilmap:rankenste.in).
If you have found an error or have a suggestion how FacilMap or this documentation could be improved, please raise an [issue on GitHub](https://github.com/FacilMap/facilmap/issues).

Wyświetl plik

@ -20,6 +20,7 @@ Base layers set the main style of the map. Only one base layer can be shown at t
| Base layer | Source | Purpose |
|------------|--------|---------|
| Lima Labs | [Lima Labs](https://maps.lima-labs.com/) | Good for a general geographic overview. Has a focus on car infrastructure and political borders. Supports high resolution displays. |
| Mapnik | [OpenStreetMap](https://www.openstreetmap.org/) | Good for a general geographic overview. Has a focus on car infrastructure and political borders. |
| TopPlus | [German state](https://gdz.bkg.bund.de/index.php/default/wms-topplusopen-wms-topplus-open.html) | Good for a general geographic overview, with more details than Mapnik. Has a focus on car infrastructure, political borders and topography. Unfortunately all labels are in German. |
| Map1.eu | [Map1.eu](https://www.map1.eu/) | Has a focus on cities/towns, car infrastructure and political borders. Aims to have the same level of detail as a paper map. Only available for Europe. |

Wyświetl plik

@ -17,7 +17,7 @@
# Lines
A line is a connection of two or more points on the map and has a name, a certain style (width, colour) and some data (such as a description). It can be a straight line or a calculated route, depending on its route mode. When you add a line to a map, it is permanently saved there and visible for anyone who is viewing the map.
A line is a connection of two or more points on the map and has a name, a certain style (width, colour, stroke) and some data (such as a description). It can be a straight line or a calculated route, depending on its route mode. When you add a line to a map, it is permanently saved there and visible for anyone who is viewing the map.
By default, a collaborative map has one type of line called “Line” that has a description field and whose style can be configured freely. Other types of lines with fixed styles and additional fields can be defined using [custom types](../types/). For simplicity, the descriptions on this page are assuming that you are working with the default “Line” type.
@ -62,6 +62,7 @@ To edit the details of a line, select the line and then click “Edit data” in
* **Routing mode:** The route mode of the line. By default, “straight line” is selected, but you can select something else to make the line points into route destinations. More information about route modes can be found under [routes](../route/#route-modes).
* **Colour:** The colour of the line.
* **Width:** The width of the line (in pixels). Use the + and &minus; buttons to change the value.
* **Stroke:** The stroke style of the line (solid, dashed or dotted).
* **Description:** The description of the line. Will be shown in the line details. You can format the text using [Markdown](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet).
Click “Save” to save your changes.

Wyświetl plik

@ -29,7 +29,7 @@ Give the type a name and select whether it is a marker or a line type. The meani
## Default style
By default, markers are red, 25 pixels tall, have no icon and a drop shape. Lines by default are blue, 4 pixels wide and use the “straight line” route mode. These settings can be changed for each individual marker.
By default, markers are red, 30 pixels tall, have no icon and a drop shape. Lines by default are blue, 4 pixels wide, solid and use the “straight line” route mode. These settings can be changed for each individual marker/line.
Using custom types, you can change these defaults. You can also make some or all of the values fixed, which means that they cannot be changed for an individual marker/line but are the same for all markers/lines of this type.

Wyświetl plik

@ -27,7 +27,7 @@ The zoom buttons allow you to zoom in and out of the map. Alternatively, you can
The search box allows you to [search for places](../search/) and to [calculate a route](../route/). On small screens, it also contains the [legend](../legend/) if enabled.
On top of the search box, you can click the different tabs to switch between different functions. By default, the search box contains two tabs, the search form and the route form. Different functions of the map may temporarily or permanently add additional tabs.
On top of the search box, you can click the different tabs to switch between different functions. By default, the search box contains three tabs, the search form, the route form and the POI tab. Different functions of the map may temporarily or permanently add additional tabs.
On big screens, you can drag the resize handle on the bottom right of the search box to resize it. Click the resize handle to bring it back to its original size.\
On small screens, the search box appears at the bottom of the screen. You can drag it into and out of view as it fits by dragging the tab bar on top of the search box.

Wyświetl plik

@ -1,37 +0,0 @@
# FacilMap configuration file. See https://github.com/motdotla/dotenv#rules for the syntax.
# Database configuration. Possible database types: mysql, postgres, mariadb, sqlite
#DB_TYPE=mysql
#DB_HOST=localhost
#DB_PORT=
#DB_NAME=facilmap
#DB_USER=facilmap
#DB_PASSWORD=password
# Where the FacilMap server should listen
#HOST=
#PORT=8080
# The User-Agent header that the FacilMap server will use when talking to other services (for example to OpenStreetMap, Mapbox, OpenRouteService, ...)
#USER_AGENT=FacilMap (https://facilmap.org/)
# Mapbox configuration. Needed for routing without any advanced mode settings. Get an API key on https://www.mapbox.com/signup/.
#MAPBOX_TOKEN=
# OpenRouteService configuration. Needed for routing with any advanced mode settings. Get a token on https://go.openrouteservice.org/.
#ORS_TOKEN=
# MapZen configuration. Needed to get the elevation of markers, search results or GPS tracks. Get an API key on https://mapzen.com/developers/sign_up.
#MAPZEN_TOKEN=
# Maxmind configuration. If specified, the maxmind GeoLite2 database will be downloaded for Geo IP lookup (to show the initial map state) and kept it in memory
# Sign up here: https://www.maxmind.com/en/geolite2/signup
#MAXMIND_USER_ID=
#MAXMIND_LICENSE_KEY=
# Set to true to enable dev mode and webpack dev middleware
#FM_DEV=
# Set to a comma-separated list of values (or *) to enable debug output by particular components. See https://github.com/visionmedia/debug for the syntax.
# Some possible values: sql, express:*
#DEBUG=

Wyświetl plik

@ -0,0 +1,3 @@
**facilmap-frontend** is the Vue.js frontend for [FacilMap](https://github.com/facilmap/facilmap).
Some information on how to use it can be found in the [developer documentation](https://docs.facilmap.org/developers/frontend/).

16
frontend/build.d.ts vendored 100644
Wyświetl plik

@ -0,0 +1,16 @@
import { InlineConfig, ViteDevServer } from "vite";
export const paths: {
root: string;
dist: string;
base: string;
mapEntry: string;
mapEjs: string;
tableEntry: string;
tableEjs: string;
viteManifest: string;
pwaManifest: string;
opensearchXmlEjs: string;
};
export function serve(inlineConfig?: InlineConfig): Promise<ViteDevServer>;

27
frontend/build.js 100644
Wyświetl plik

@ -0,0 +1,27 @@
import { createServer } from "vite";
import { dirname } from "path";
import { fileURLToPath } from "url";
const root = dirname(fileURLToPath(import.meta.url));
const dist = `${root}/dist/app`;
export const paths = {
root,
dist,
base: '/_app/',
mapEntry: "src/map/map.ts",
mapEjs: `${root}/src/map/map.ejs`,
tableEntry: "src/table/table.ts",
tableEjs: `${root}/src/table/table.ejs`,
viteManifest: `${dist}/.vite/manifest.json`,
pwaManifest: `${root}/src/manifest.json`,
opensearchXmlEjs: `${root}/src/opensearch.xml.ejs`,
};
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export async function serve(inlineConfig = {}) {
return await createServer({
root,
...inlineConfig
});
}

Wyświetl plik

@ -1,3 +0,0 @@
module.exports = {
preset: 'ts-jest',
};

Wyświetl plik

@ -3,7 +3,6 @@
"version": "3.4.0",
"description": "A fully-featured OpenStreetMap-based map where markers and lines can be added with live collaboration.",
"keywords": [
"webpack",
"maps",
"osm"
],
@ -17,99 +16,86 @@
"type": "git",
"url": "https://github.com/FacilMap/facilmap.git"
},
"main": "./dist/frontend.js",
"typings": "./dist/src/lib/index.d.ts",
"type": "module",
"main": "./dist/lib/facilmap-frontend.mjs",
"types": "./dist/lib/facilmap-frontend.d.ts",
"files": [
"dist",
"src",
"static",
"iframe-test.html",
"README.md",
"tsconfig.json"
"tsconfig.json",
"build.js",
"build.d.ts",
"public"
],
"scripts": {
"build": "webpack",
"build": "yarn build:lib && yarn build:app",
"build:lib": "vite --config vite-lib.config.ts build",
"build:app": "NODE_OPTIONS='--import tsx' vite build",
"clean": "rimraf dist",
"dev-server": "webpack serve --config-name app --mode development"
"dev-server": "NODE_OPTIONS='--import tsx' vite",
"test": "NODE_OPTIONS='--import tsx' vitest run",
"test-watch": "NODE_OPTIONS='--import tsx' vitest",
"check-types": "vue-tsc -b --emitDeclarationOnly"
},
"dependencies": {
"@tmcw/togeojson": "^4.3.0",
"@ckpack/vue-color": "^1.5.0",
"@tmcw/togeojson": "^5.8.1",
"@vitejs/plugin-vue": "^5.0.4",
"blob": "^0.1.0",
"bootstrap": "4",
"bootstrap-touchspin": "^4.3.0",
"bootstrap-vue": "^2.21.1",
"clipboard": "^2.0.8",
"copy-to-clipboard": "^3.3.1",
"decode-uri-component": "^0.2.0",
"domutils": "^2.5.0",
"facilmap-client": "3.4.0",
"facilmap-leaflet": "3.4.0",
"facilmap-types": "3.4.0",
"facilmap-utils": "3.4.0",
"bootstrap": "^5.3.3",
"copy-to-clipboard": "^3.3.3",
"decode-uri-component": "^0.4.1",
"facilmap-client": "workspace:^",
"facilmap-leaflet": "workspace:^",
"facilmap-types": "workspace:^",
"facilmap-utils": "workspace:^",
"file-saver": "^2.0.5",
"hammerjs": "^2.0.8",
"jquery": "^3.6.0",
"jquery-ui": "^1.12.1",
"leaflet": "^1.7.1",
"leaflet-draggable-lines": "^1.0.11",
"leaflet-graphicscale": "^0.0.2",
"leaflet-mouse-position": "^1.0.4",
"jquery": "^3.7.1",
"leaflet": "^1.9.4",
"leaflet-draggable-lines": "^2.0.0",
"leaflet-graphicscale": "^0.0.4",
"leaflet-mouse-position": "^1.2.0",
"leaflet.heightgraph": "^1.4.0",
"leaflet.locatecontrol": "^0.73.0",
"lodash": "^4.17.21",
"leaflet.locatecontrol": "^0.79.0",
"lodash-es": "^4.17.21",
"markdown": "^0.5.0",
"osmtogeojson": "^3.0.0-beta.4",
"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",
"portal-vue": "^2.1.7",
"qrcode.vue": "^3.4.1",
"tablesorter": "^2.31.3",
"vee-validate": "3",
"vue": "^2.6.12",
"vue-class-component": "^7.2.6",
"vue-color": "^2.8.1",
"vue-nonreactive": "^0.1.0",
"vue-property-decorator": "^9.1.2",
"vuedraggable": "^2.24.3"
"vite": "^5.1.5",
"vite-plugin-css-injected-by-js": "^3.4.0",
"vite-plugin-dts": "^3.7.3",
"vue": "^3.4.21",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@types/copy-webpack-plugin": "^8.0.0",
"@types/decode-uri-component": "^0.2.0",
"@types/file-saver": "^2.0.1",
"@types/hammerjs": "^2.0.39",
"@types/jest": "^26.0.21",
"@types/jquery": "^3.5.5",
"@types/leaflet": "^1.7.0",
"@types/leaflet-mouse-position": "^1.2.0",
"@types/leaflet.locatecontrol": "^0.60.7",
"@types/pluralize": "^0.0.29",
"@types/scrollparent": "^2.0.0",
"@types/webpack-bundle-analyzer": "^4.4.0",
"@types/webpack-dev-server": "^3.11.4",
"@types/webpack-node-externals": "^2.5.1",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.8.1",
"ejs-compiled-loader": "^3.1.0",
"html-loader": "^4.2.0",
"html-webpack-plugin": "^5.5.3",
"jest": "^26.6.3",
"mini-svg-data-uri": "^1.3.3",
"rimraf": "^5.0.1",
"sass": "^1.34.0",
"sass-loader": "^13.3.2",
"source-map-loader": "^4.0.1",
"style-loader": "^3.3.3",
"svgo": "^2.2.2",
"ts-jest": "^26.5.4",
"ts-loader": "^9.4.4",
"ts-node": "^9.1.1",
"typescript": "^5.2.2",
"vue-template-compiler": "^2.6.12",
"vue-template-loader": "^1.1.0",
"webpack": "^5.88.2",
"webpack-bundle-analyzer": "^4.9.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1",
"webpack-hot-middleware": "^2.25.4",
"webpack-node-externals": "^3.0.0"
"@types/bootstrap": "^5.2.10",
"@types/decode-uri-component": "^0.2.2",
"@types/file-saver": "^2.0.7",
"@types/hammerjs": "^2.0.45",
"@types/jquery": "^3.5.29",
"@types/leaflet": "^1.9.8",
"@types/leaflet-mouse-position": "^1.2.4",
"@types/leaflet.locatecontrol": "^0.74.4",
"@types/lodash-es": "^4.17.12",
"@types/pluralize": "^0.0.33",
"happy-dom": "^13.6.2",
"rimraf": "^5.0.5",
"sass": "^1.71.1",
"svgo": "^3.2.0",
"tsx": "^4.7.1",
"typescript": "^5.4.2",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.3.1",
"vue-tsc": "^2.0.5"
}
}

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 5.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 5.5 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 17 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 17 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 1.6 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.6 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 4.2 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 4.2 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 1.1 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.1 KiB

Wyświetl plik

@ -0,0 +1,46 @@
<script setup lang="ts">
import "bootstrap/dist/css/bootstrap.css";
import { FacilMap } from "../lib";
import { ref } from "vue";
const serverUrl = "http://localhost:40829/";
const padId1 = ref("test");
const padName1 = ref<string>();
const padId2 = ref("test");
const padName2 = ref<string>();
const map1Ref = ref<InstanceType<typeof FacilMap>>();
const map2Ref = ref<InstanceType<typeof FacilMap>>();
</script>
<template>
<FacilMap
:baseUrl="serverUrl"
:serverUrl="serverUrl"
v-model:padId="padId1"
@update:padName="padName1 = $event"
ref="map1Ref"
>
<template #before>
<div>{{padId1}} | {{padName1}}</div>
<div>
#{{map1Ref?.context?.components.map?.hash}}
</div>
</template>
</FacilMap>
<FacilMap
:baseUrl="serverUrl"
:serverUrl="serverUrl"
v-model:padId="padId2"
@update:padName="padName2 = $event"
ref="map2Ref"
>
<template #before>
<div>{{padId2}} | {{padName2}}</div>
<div>
#{{map2Ref?.context?.components.map?.hash}}
</div>
</template>
</FacilMap>
</template>

Wyświetl plik

@ -25,6 +25,7 @@
width: 50%;
}
</style>
<script type="module" src="./example.ts"></script>
</head>
<body>
<div id="app"></div>

Wyświetl plik

@ -1,21 +1,11 @@
import Vue from "vue";
import { BootstrapVue } from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import withRender from "./example.vue";
import { FacilMap } from "../lib";
import MapControl from "./map-control";
import { createApp, defineComponent, h } from "vue";
import ExampleRoot from "./example-root.vue";
import "../lib/bootstrap.scss";
Vue.use(BootstrapVue);
const Root = defineComponent({
setup() {
return () => h(ExampleRoot);
}
});
new Vue(withRender({
el: "#app",
data: {
serverUrl: "http://localhost:40829/",
padId1: "test",
padName1: undefined,
padId2: "test",
padName2: undefined
},
components: { FacilMap, MapControl }
}));
createApp(Root).mount(document.getElementById("app")!);

Wyświetl plik

@ -1,15 +0,0 @@
<div>
<FacilMap :base-url="serverUrl" :server-url="serverUrl" :pad-id.sync="padId1" @update:padName="padName1 = $event">
<template #before>
<div>{{padId1}} | {{padName2}}</div>
<MapControl></MapControl>
</template>
</FacilMap>
<FacilMap :base-url="serverUrl" :server-url="serverUrl" :pad-id.sync="padId2" @update:padName="padName2 = $event">
<template #before>
<div>{{padId2}} | {{padName2}}</div>
<MapControl></MapControl>
</template>
</FacilMap>
</div>

Wyświetl plik

@ -1,12 +0,0 @@
import WithRender from "./map-control.vue";
import Vue from "vue";
import { Component } from "vue-property-decorator";
import { InjectMapContext, MapContext } from "../lib";
@WithRender
@Component({})
export default class MapControl extends Vue {
@InjectMapContext() mapContext!: MapContext;
}

Wyświetl plik

@ -1,3 +0,0 @@
<div>
#{{mapContext.hash}}
</div>

Wyświetl plik

@ -0,0 +1,112 @@
$btn-transition: color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out; // Delete background-color from transition
$enable-shadows: true;
$enable-gradients: true;
$secondary: #fff;
// Bootstrap import, see https://getbootstrap.com/docs/5.3/customize/optimize/
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/variables-dark";
@import "bootstrap/scss/maps";
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/utilities";
@import "bootstrap/scss/root";
@import "bootstrap/scss/reboot";
@import "bootstrap/scss/type";
//@import "bootstrap/scss/images";
@import "bootstrap/scss/containers";
@import "bootstrap/scss/grid";
@import "bootstrap/scss/tables";
@import "bootstrap/scss/forms";
@import "bootstrap/scss/buttons";
//@import "bootstrap/scss/transitions";
@import "bootstrap/scss/dropdown";
@import "bootstrap/scss/button-group";
@import "bootstrap/scss/nav";
@import "bootstrap/scss/navbar";
@import "bootstrap/scss/card";
//@import "bootstrap/scss/accordion";
//@import "bootstrap/scss/breadcrumb";
@import "bootstrap/scss/pagination";
@import "bootstrap/scss/badge";
@import "bootstrap/scss/alert";
//@import "bootstrap/scss/progress";
@import "bootstrap/scss/list-group";
@import "bootstrap/scss/close";
@import "bootstrap/scss/toasts";
@import "bootstrap/scss/modal";
@import "bootstrap/scss/tooltip";
@import "bootstrap/scss/popover";
@import "bootstrap/scss/carousel";
@import "bootstrap/scss/spinners";
//@import "bootstrap/scss/offcanvas";
//@import "bootstrap/scss/placeholders";
@import "bootstrap/scss/helpers";
@import "bootstrap/scss/utilities/api";
// Customizations
html,body {
font-size: 14px;
}
// Set button style to bootstrap v3 theme
@each $color, $value in $theme-colors {
.btn-#{$color} {
$border: darken($value, 14%);
--#{$prefix}btn-border-color: #{$border};
--#{$prefix}btn-hover-border-color: #{darken($border, 12%)};
--#{$prefix}btn-active-border-color: #{$border};
--#{$prefix}btn-hover-bg: #{darken($value, 12%)};
--#{$prefix}btn-active-bg: #{darken($value, 12%)};
--#{$prefix}btn-disabled-bg: #{darken($value, 12%)};
--#{$prefix}btn-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
--#{$prefix}btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
@include gradient-y($start-color: $value, $end-color: darken($value, 12%));
background-repeat: repeat-x;
&:hover,
&:focus-visible {
background-position: 0 -15px;
}
.btn-check + &:hover {
// Bootstrap disables hover styles for checkbox buttons. Here we reset them to the
// default button hover styles.
color: var(--#{$prefix}btn-hover-color);
background-color: var(--#{$prefix}btn-hover-bg);
border-color: var(--#{$prefix}btn-hover-border-color);
}
.btn-check:checked + &,
:not(.btn-check) + &:active,
&:first-child:active,
&.active,
&.show,
&:disabled,
&.disabled,
fieldset:disabled & {
// Disable gradient-y
background-image: none;
}
}
}
// Reset btn-outline-secondary colour scheme to original, as #fff makes the text invisible
.btn-outline-secondary {
@include button-outline-variant(#6c757d);
}
.list-group-item.active a {
color: inherit;
}

Wyświetl plik

@ -0,0 +1,13 @@
// Bootstrap import, see https://getbootstrap.com/docs/5.3/customize/optimize/#lean-javascript
import 'bootstrap/js/dist/alert';
import 'bootstrap/js/dist/button';
import 'bootstrap/js/dist/carousel';
// import 'bootstrap/js/dist/collapse';
import 'bootstrap/js/dist/dropdown';
import 'bootstrap/js/dist/modal';
// import 'bootstrap/js/dist/offcanvas';
import 'bootstrap/js/dist/popover';
// import 'bootstrap/js/dist/scrollspy';
import 'bootstrap/js/dist/tab';
import 'bootstrap/js/dist/toast';
import 'bootstrap/js/dist/tooltip';

Wyświetl plik

@ -0,0 +1,105 @@
<script setup lang="ts">
import { getLayers } from "facilmap-leaflet";
import { type Layer, Util } from "leaflet";
import { computed } from "vue";
import ModalDialog from "./ui/modal-dialog.vue";
import { injectContextRequired, requireMapContext } from "./facil-map-context-provider/facil-map-context-provider.vue";
const context = injectContextRequired();
const mapContext = requireMapContext(context);
const emit = defineEmits<{
hidden: [];
}>();
const layers = computed((): Layer[] => {
const { baseLayers, overlays } = getLayers(mapContext.value.components.map);
return [...Object.values(baseLayers), ...Object.values(overlays)];
});
const fmVersion = __FM_VERSION__;
</script>
<template>
<ModalDialog
:title="`About FacilMap ${fmVersion}`"
class="fm-about"
size="lg"
@hidden="emit('hidden')"
>
<p><a href="https://github.com/facilmap/facilmap" target="_blank"><strong>FacilMap</strong></a> is available under the <a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank">GNU Affero General Public License, Version 3</a>.</p>
<p>If something does not work or you have a suggestion for improvement, please report on the <a href="https://github.com/FacilMap/facilmap/issues" target="_blank">issue tracker</a>.</p>
<p>If you have a question, please have a look at the <a href="https://docs.facilmap.org/users/" target="_blank">documentation</a>, raise a question in the <a href="https://github.com/FacilMap/facilmap/discussions" target="_blank">discussion forum</a> or ask in the <a href="https://matrix.to/#/#facilmap:rankenste.in" target="_blank">Matrix chat</a>.</p>
<p><a href="https://docs.facilmap.org/users/privacy/" target="_blank">Privacy information</a></p>
<h4>Map data</h4>
<dl class="row">
<template v-for="layer in layers">
<template v-if="layer.options.attribution">
<dt :key="`name-${Util.stamp(layer)}`" class="col-sm-3">{{layer.options.fmName}}</dt>
<dd :key="`attribution-${Util.stamp(layer)}`" class="col-sm-9" v-html="layer.options.attribution"></dd>
</template>
</template>
<dt class="col-sm-3">Search</dt>
<dd class="col-sm-9"><a href="https://nominatim.openstreetmap.org/" target="_blank">Nominatim</a> / <a href="https://www.openstreetmap.org/copyright" target="_blank">OSM Contributors</a></dd>
<dt class="col-sm-3">POIs</dt>
<dd class="col-sm-9"><a href="https://overpass-api.de/" target="_blank">Overpass API</a> / <a href="https://www.openstreetmap.org/copyright" target="_blank">OSM Contributors</a></dd>
<dt class="col-sm-3">Directions</dt>
<dd class="col-sm-9"><a href="https://www.mapbox.com/api-documentation/#directions">Mapbox Directions API</a> / <a href="https://openrouteservice.org/">OpenRouteService</a> / <a href="https://www.openstreetmap.org/copyright" target="_blank">OSM Contributors</a></dd>
<dt class="col-sm-3">GeoIP</dt>
<dd class="col-sm-9">This product includes GeoLite2 data created by MaxMind, available from <a href="https://www.maxmind.com">https://www.maxmind.com</a>.</dd>
</dl>
<h4>Programs/libraries</h4>
<ul>
<li><a href="https://nodejs.org/" target="_blank">Node.js</a></li>
<li><a href="https://sequelize.org/" target="_blank">Sequelize</a></li>
<li><a href="https://socket.io/" target="_blank">socket.io</a></li>
<li><a href="https://www.typescriptlang.org/" target="_blank">TypeScript</a></li>
<li><a href="https://vuejs.org/" target="_blank">Vue.js</a></li>
<li><a href="https://vitejs.dev/" target="_blank">Vite</a></li>
<li><a href="https://getbootstrap.com/" target="_blank">Bootstrap</a></li>
<li><a href="https://leafletjs.com/" target="_blank">Leaflet</a></li>
<li><a href="http://project-osrm.org/" target="_blank">OSRM</a></li>
<li><a href="https://openrouteservice.org/" target="_blank">OpenRouteService</a></li>
<li><a href="https://nominatim.openstreetmap.org/" target="_blank">Nominatim</a></li>
<li><a href="https://github.com/joewalnes/filtrex" target="_blank">Filtrex</a></li>
<li><a href="https://github.com/chjj/marked" target="_blank">Marked</a></li>
<li><a href="https://github.com/cure53/DOMPurify" target="_blank">DOMPurify</a></li>
<li><a href="https://expressjs.com/" target="_blank">Express</a></li>
<li><a href="https://vuepress.vuejs.org/" target="_blank">Vuepress</a></li>
</ul>
<h4>Icons</h4>
<ul>
<li><a href="https://github.com/twain47/Open-SVG-Map-Icons/" target="_blank">Open SVG Map Icons</a></li>
<li><a href="https://glyphicons.com/" target="_blank">Glyphicons</a></li>
<li><a href="https://zavoloklom.github.io/material-design-iconic-font/index.html" target="_blank">Material Design Iconic Font</a></li>
<li><a href="https://fontawesome.com/" target="_blank">Font Awesome</a></li>
</ul>
</ModalDialog>
</template>
<style lang="scss">
.fm-about {
ul {
margin-left: 0;
padding-left: 0;
display: grid;
grid-template-columns: repeat(auto-fit, 180px);
gap: 5px;
li {
border: 1px solid rgba(0,0,0,.125);
display: flex;
a {
flex-grow: 1;
padding: 5px 10px;
}
}
}
}
</style>

Wyświetl plik

@ -1,19 +0,0 @@
.fm-about {
ul {
margin-left: 0;
padding-left: 0;
display: grid;
grid-template-columns: repeat(auto-fit, 180px);
gap: 5px;
li {
border: 1px solid rgba(0,0,0,.125);
display: flex;
a {
flex-grow: 1;
padding: 5px 10px;
}
}
}
}

Wyświetl plik

@ -1,29 +0,0 @@
import Component from "vue-class-component";
import Vue from "vue";
import packageJson from "../../../../package.json";
import WithRender from "./about.vue";
import { getLayers } from "facilmap-leaflet";
import { Prop } from "vue-property-decorator";
import "./about.scss";
import { InjectMapComponents } from "../../utils/decorators";
import { MapComponents } from "../leaflet-map/leaflet-map";
import { Layer } from "leaflet";
@WithRender
@Component({
components: { }
})
export default class About extends Vue {
@InjectMapComponents() mapComponents!: MapComponents;
@Prop({ type: String, required: true }) id!: string;
get layers(): Layer[] {
const { baseLayers, overlays } = getLayers(this.mapComponents.map);
return [...Object.values(baseLayers), ...Object.values(overlays)];
}
fmVersion = packageJson.version;
}

Wyświetl plik

@ -1,50 +0,0 @@
<b-modal :id="id" :title="`About FacilMap ${fmVersion}`" ok-only ok-title="Close" size="lg" dialog-class="fm-about">
<p><a href="https://github.com/facilmap/facilmap" target="_blank"><strong>FacilMap</strong></a> is available under the <a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank">GNU Affero General Public License, Version 3</a>.</p>
<p>If something does not work or you have a suggestion for improvement, please report on the <a href="https://github.com/FacilMap/facilmap/issues" target="_blank">issue tracker</a>.</p>
<p>If you have a question, please have a look at the <a href="https://docs.facilmap.org/users/">documentation</a> or raise a question in the <a href="https://github.com/FacilMap/facilmap/discussions">discussion forum</a>.</p>
<p><a href="https://docs.facilmap.org/users/privacy/">Privacy information</a></p>
<h4>Map data</h4>
<dl class="row">
<template v-for="layer in layers" v-if="layer.options.attribution">
<dt class="col-sm-3">{{layer.options.fmName}}</dt>
<dd class="col-sm-9" v-html="layer.options.attribution"></dd>
</template>
<dt class="col-sm-3">Search</dt>
<dd class="col-sm-9"><a href="https://nominatim.openstreetmap.org/" target="_blank">Nominatim</a> / <a href="https://www.openstreetmap.org/copyright" target="_blank">OSM Contributors</a></dd>
<dt class="col-sm-3">POIs</dt>
<dd class="col-sm-9"><a href="https://overpass-api.de/" target="_blank">Overpass API</a> / <a href="https://www.openstreetmap.org/copyright" target="_blank">OSM Contributors</a></dd>
<dt class="col-sm-3">Directions</dt>
<dd class="col-sm-9"><a href="https://www.mapbox.com/api-documentation/#directions">Mapbox Directions API</a> / <a href="https://openrouteservice.org/">OpenRouteService</a> / <a href="https://www.openstreetmap.org/copyright" target="_blank">OSM Contributors</a></dd>
<dt class="col-sm-3">GeoIP</dt>
<dd class="col-sm-9">This product includes GeoLite2 data created by MaxMind, available from <a href="https://www.maxmind.com">https://www.maxmind.com</a>.</dd>
</dl>
<h4>Programs/libraries</h4>
<ul>
<li><a href="https://nodejs.org/" target="_blank">Node.js</a></li>
<li><a href="https://sequelize.org/" target="_blank">Sequelize</a></li>
<li><a href="https://socket.io/" target="_blank">socket.io</a></li>
<li><a href="https://www.typescriptlang.org/" target="_blank">TypeScript</a></li>
<li><a href="https://webpack.js.org/" target="_blank">Webpack</a></li>
<li><a href="https://jquery.com/" target="_blank">jQuery</a></li>
<li><a href="https://vuejs.org/" target="_blank">Vue.js</a></li>
<li><a href="https://github.com/chjj/marked" target="_blank">Marked</a></li>
<li><a href="https://getbootstrap.com/" target="_blank">Bootstrap</a></li>
<li><a href="https://bootstrap-vue.org/" target="_blank">BootstrapVue</a></li>
<li><a href="https://leafletjs.com/" target="_blank">Leaflet</a></li>
<li><a href="http://project-osrm.org/" target="_blank">OSRM</a></li>
<li><a href="https://openrouteservice.org/" target="_blank">OpenRouteService</a></li>
<li><a href="https://nominatim.openstreetmap.org/" target="_blank">Nominatim</a></li>
<li><a href="https://github.com/joewalnes/filtrex" target="_blank">Filtrex</a></li>
</ul>
<h4>Icons</h4>
<ul>
<li><a href="https://github.com/twain47/Open-SVG-Map-Icons/" target="_blank">Open SVG Map Icons</a></li>
<li><a href="https://glyphicons.com/" target="_blank">Glyphicons</a></li>
<li><a href="https://zavoloklom.github.io/material-design-iconic-font/index.html" target="_blank">Material Design Iconic Font</a></li>
<li><a href="https://fontawesome.com/" target="_blank">Font Awesome</a></li>
</ul>
</b-modal>

Wyświetl plik

@ -0,0 +1,126 @@
<script setup lang="ts">
import type { SearchResult } from "facilmap-types";
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, 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, 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 searchBoxContext = requireSearchBoxContext(context);
type Tab = {
id: number;
result: SearchResult;
layer: Raw<SearchResultsLayer>;
isLoading: boolean;
};
const tabs = ref<Tab[]>([]);
useEventListener(mapContext, "open-selection", handleOpenSelection);
const layerIds = computed(() => tabs.value.map((tab) => Util.stamp(tab.layer)));
watch(() => mapContext.value.selection, () => {
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(tabs.value[i]);
}
});
let idCounter = 1;
const clickMarkerTabContext = ref<WritableClickMarkerTabContext>({
async openClickMarker(point) {
const result = reactive(getFallbackLonLatResult({ lat: point.lat, lon: point.lon, zoom: mapContext.value.zoom }));
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
});
tabs.value.push(tab);
mapContext.value.components.selectionHandler.setSelectedItems([{ type: "searchResult", result, layerId: Util.stamp(layer) }]);
await nextTick();
searchBoxContext.value.activateTab(`fm${context.id}-click-marker-tab-${tabs.value.length - 1}`, { expand: true });
(async () => {
const results = await mapContext.value.runOperation(async () => await find(`geo:${round(point.lat, 5)},${round(point.lon, 5)}?z=${mapContext.value.zoom}`));
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]);
}
}
});
context.provideComponent("clickMarkerTab", toRef(readonly(clickMarkerTabContext)));
function handleOpenSelection(): void {
for (let i = 0; i < layerIds.value.length; i++) {
if (mapContext.value.selection.some((item) => item.type == "searchResult" && item.layerId == layerIds.value[i])) {
searchBoxContext.value.activateTab(`fm${context.id}-click-marker-tab-${i}`, { expand: true });
break;
}
}
}
function close(tab: Tab): void {
const idx = tabs.value.indexOf(tab);
if (idx == -1)
return;
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="(tab, idx) in tabs" :key="tab.id">
<SearchBoxTab
:id="`fm${context.id}-click-marker-tab-${idx}`"
:title="tab.result.short_name"
isCloseable
@close="close(tab)"
>
<SearchResultInfo :result="tab.result" :isLoading="tab.isLoading"></SearchResultInfo>
</SearchBoxTab>
</template>
</template>

Wyświetl plik

@ -1,172 +0,0 @@
import WithRender from "./click-marker.vue";
import Vue from "vue";
import { Component, Watch } from "vue-property-decorator";
import { InjectClient, Client, InjectMapComponents, InjectMapContext, InjectContext } from "../../utils/decorators";
import { MapComponents, MapContext } from "../leaflet-map/leaflet-map";
import { LineCreate, MarkerCreate, Point, SearchResult, Type } from "facilmap-types";
import { round } from "facilmap-utils";
import { lineStringToTrackPoints, mapSearchResultToType } from "../search-results/utils";
import { showErrorToast } from "../../utils/toasts";
import { SearchResultsLayer } from "facilmap-leaflet";
import SearchResultInfo from "../search-result-info/search-result-info";
import Icon from "../ui/icon/icon";
import { Util } from "leaflet";
import StringMap from "../../utils/string-map";
import { Portal } from "portal-vue";
import { Context } from "../facilmap/facilmap";
@WithRender
@Component({
components: { Icon, Portal, SearchResultInfo }
})
export default class ClickMarker extends Vue {
@InjectContext() context!: Context;
@InjectMapContext() mapContext!: MapContext;
@InjectMapComponents() mapComponents!: MapComponents;
@InjectClient() client!: Client;
lastClick = 0;
results: SearchResult[] = [];
layers!: SearchResultsLayer[]; // Don't make layer objects reactive
isAdding = false;
mounted(): void {
this.layers = [];
this.mapContext.$on("fm-map-long-click", this.handleMapLongClick);
this.mapContext.$on("fm-open-selection", this.handleOpenSelection);
}
beforeDestroy(): void {
this.mapContext.$off("fm-map-long-click", this.handleMapLongClick);
this.mapContext.$off("fm-open-selection", this.handleOpenSelection);
}
get layerIds(): number[] {
return this.results.map((result, i) => { // Iterate files instead of layers because it is reactive
return Util.stamp(this.layers[i]);
});
}
@Watch("mapContext.selection")
handleSelectionChange(): void {
for (let i = this.results.length - 1; i >= 0; i--) {
if (!this.mapContext.selection.some((item) => item.type == "searchResult" && item.layerId == this.layerIds[i]))
this.close(this.results[i]);
}
}
handleOpenSelection(): void {
for (let i = 0; i < this.layerIds.length; i++) {
if (this.mapContext.selection.some((item) => item.type == "searchResult" && item.layerId == this.layerIds[i])) {
this.mapContext.$emit("fm-search-box-show-tab", `fm${this.context.id}-click-marker-tab-${i}`);
break;
}
}
}
async handleMapLongClick(pos: Point): Promise<void> {
const now = Date.now();
this.lastClick = now;
const results = await this.client.find({
query: `geo:${round(pos.lat, 5)},${round(pos.lon, 5)}?z=${this.mapContext.zoom}`,
loadUrls: false,
elevation: true
});
if (now !== this.lastClick) {
// There has been another click since the one we are reacting to.
return;
}
if (results.length > 0) {
const layer = new SearchResultsLayer([results[0]]).addTo(this.mapComponents.map);
this.mapComponents.selectionHandler.addSearchResultLayer(layer);
this.results.push(results[0]);
this.layers.push(layer);
this.mapComponents.selectionHandler.setSelectedItems([{ type: "searchResult", result: results[0], layerId: Util.stamp(layer) }]);
setTimeout(() => {
this.mapContext.$emit("fm-search-box-show-tab", `fm${this.context.id}-click-marker-tab-${this.results.length - 1}`);
}, 0);
}
}
close(result: SearchResult): void {
const idx = this.results.indexOf(result);
if (idx == -1)
return;
this.mapComponents.selectionHandler.removeSearchResultLayer(this.layers[idx]);
this.layers[idx].remove();
this.results.splice(idx, 1);
this.layers.splice(idx, 1);
}
clear(): void {
for (let i = this.results.length - 1; i >= 0; i--)
this.close(this.results[i]);
}
async addToMap(result: SearchResult, type: Type): Promise<void> {
this.$bvToast.hide(`fm${this.context.id}-click-marker-add-error`);
this.isAdding = true;
try {
const obj: Partial<MarkerCreate<StringMap> & LineCreate<StringMap>> = {
name: result.short_name,
data: mapSearchResultToType(result, type)
};
if(type.type == "marker") {
const marker = await this.client.addMarker({
...obj,
lat: result.lat!,
lon: result.lon!,
typeId: type.id
});
this.mapComponents.selectionHandler.setSelectedItems([{ type: "marker", id: marker.id }], true);
} else if(type.type == "line") {
const trackPoints = lineStringToTrackPoints(result.geojson as any);
const line = await this.client.addLine({
...obj,
typeId: type.id,
routePoints: [trackPoints[0], trackPoints[trackPoints.length-1]],
trackPoints: trackPoints,
mode: "track"
});
this.mapComponents.selectionHandler.setSelectedItems([{ type: "line", id: line.id }], true);
}
this.close(result);
} catch (err) {
showErrorToast(this, `fm${this.context.id}-click-marker-add-error`, "Error adding to map", err);
} finally {
this.isAdding = false;
}
}
useAs(result: SearchResult, event: "fm-route-set-from" | "fm-route-add-via" | "fm-route-set-to"): void {
this.mapContext.$emit(event, result.short_name, [result], [], result);
this.mapContext.$emit("fm-search-box-show-tab", `fm${this.context.id}-route-form-tab`);
}
useAsFrom(result: SearchResult): void {
this.useAs(result, "fm-route-set-from");
}
useAsVia(result: SearchResult): void {
this.useAs(result, "fm-route-add-via");
}
useAsTo(result: SearchResult): void {
this.useAs(result, "fm-route-set-to");
}
}

Wyświetl plik

@ -1,18 +0,0 @@
<portal to="fm-search-box">
<b-tab v-for="(result, idx) in results" :id="`fm${context.id}-click-marker-tab-${idx}`">
<template #title>
<span class="closeable-tab-title">
<span>{{result.short_name}}</span>
<object><a href="javascript:" @click="close(result)"><Icon icon="remove" alt="Close"></Icon></a></object>
</span>
</template>
<SearchResultInfo
:result="result"
:is-adding="isAdding"
@add-to-map="addToMap(result, $event)"
@use-as-from="useAsFrom(result)"
@use-as-via="useAsVia(result)"
@use-as-to="useAsTo(result)"
></SearchResultInfo>
</b-tab>
</portal>

Wyświetl plik

@ -0,0 +1,188 @@
<script lang="ts">
import { onBeforeUnmount, reactive, ref, toRaw, watch } from "vue";
import Client from "facilmap-client";
import type { PadData, PadId } from "facilmap-types";
import PadSettingsDialog from "./pad-settings-dialog/pad-settings-dialog.vue";
import storage from "../utils/storage";
import { useToasts } from "./ui/toasts/toasts.vue";
import Toast from "./ui/toasts/toast.vue";
import type { ClientContext } from "./facil-map-context-provider/client-context";
import { injectContextRequired } from "./facil-map-context-provider/facil-map-context-provider.vue";
function isPadNotFoundError(serverError: Client["serverError"]): boolean {
return !!serverError?.message?.includes("does not exist");
}
</script>
<script setup lang="ts">
const context = injectContextRequired();
const toasts = useToasts();
const client = ref<ClientContext>();
const connectingClient = ref<ClientContext>();
const props = defineProps<{
padId: string | undefined;
serverUrl: string;
}>();
const emit = defineEmits<{
"update:padId": [padId: string | undefined];
}>();
function openPad(padId: string | undefined): void {
emit("update:padId", padId);
}
watch([
() => props.padId,
() => props.serverUrl
], async () => {
const existingClient = connectingClient.value || client.value;
if (existingClient && existingClient.server == props.serverUrl && existingClient.padId == props.padId)
return;
toasts.hideToast(`fm${context.id}-client-connecting`);
toasts.hideToast(`fm${context.id}-client-error`);
toasts.hideToast(`fm${context.id}-client-deleted`);
if (props.padId)
toasts.showToast(`fm${context.id}-client-connecting`, "Loading", "Loading map…", { spinner: true, noCloseButton: true });
else
toasts.showToast(`fm${context.id}-client-connecting`, "Connecting", "Connecting to server…", { spinner: true, noCloseButton: true });
class CustomClient extends Client implements ClientContext {
_makeReactive<O extends object>(obj: O) {
return reactive(obj) as O;
}
openPad(padId: string | undefined) {
openPad(padId);
}
get isCreatePad() {
return context.settings.interactive && isPadNotFoundError(super.serverError);
}
}
const newClient = new CustomClient(props.serverUrl, props.padId);
connectingClient.value = newClient;
let lastPadId: PadId | undefined = undefined;
let lastPadData: PadData | undefined = undefined;
newClient.on("padData", () => {
for (const bookmark of storage.bookmarks) {
if (lastPadId && bookmark.id == lastPadId)
bookmark.id = newClient.padId!;
if (lastPadData && lastPadData.id == bookmark.padId)
bookmark.padId = newClient.padData!.id;
if (bookmark.padId == newClient.padData!.id)
bookmark.name = newClient.padData!.name;
}
lastPadId = newClient.padId;
lastPadData = newClient.padData;
});
newClient.on("deletePad", () => {
toasts.showToast(`fm${context.id}-client-deleted`, "Map deleted", "This map has been deleted.", {
noCloseButton: true,
variant: "danger",
actions: context.settings.interactive ? [
{
label: "Close map",
href: context.baseUrl,
onClick: (e) => {
if (!e.ctrlKey && !e.shiftKey && !e.metaKey && !e.altKey) {
e.preventDefault();
openPad(undefined);
}
}
}
] : []
});
})
await new Promise<void>((resolve) => {
newClient.once(props.padId ? "padData" : "connect", () => { resolve(); });
newClient.on("serverError", () => { resolve(); });
});
if (toRaw(connectingClient.value) !== newClient) {
// Another client has been initiated in the meantime
newClient.disconnect();
return;
}
// Bootstrap-Vue uses animation frames to show the connecting toast. If the map is loading in a background tab, the toast might not be shown
// yet when we are trying to hide it, so the hide operation is skipped and once the loading toast is shown, it stays forever.
// We need to wait for two animation frames to make sure that the toast is shown.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
toasts.hideToast(`fm${context.id}-client-connecting`);
});
});
if (newClient.serverError && !newClient.isCreatePad) {
if (newClient.disconnected || !props.padId) {
toasts.showErrorToast(`fm${context.id}-client-error`, "Error connecting to server", newClient.serverError, {
noCloseButton: !!props.padId
});
} else {
toasts.showErrorToast(`fm${context.id}-client-error`, "Error opening map", newClient.serverError, {
noCloseButton: true,
actions: context.settings.interactive ? [
{
label: "Close map",
href: context.baseUrl,
onClick: (e) => {
if (!e.ctrlKey && !e.shiftKey && !e.metaKey && !e.altKey) {
e.preventDefault();
newClient.openPad(undefined);
}
}
}
] : []
});
}
}
connectingClient.value = undefined;
client.value?.disconnect();
client.value = newClient;
}, { immediate: true });
onBeforeUnmount(() => {
client.value?.disconnect();
});
context.provideComponent("client", client);
function handleCreateDialogHide() {
if (client.value?.isCreatePad) {
client.value.openPad(undefined);
}
}
</script>
<template>
<Toast
v-if="client && client.disconnected && !client.serverError"
:id="`fm${context.id}-client-disconnected`"
variant="danger"
title="Disconnected"
message="The connection to the server was lost. Trying to reconnect…"
auto-hide
no-close-button visible
spinner
/>
<PadSettingsDialog
v-if="client?.isCreatePad"
isCreate
:proposedAdminId="client.padId"
@hide="handleCreateDialogHide"
></PadSettingsDialog>
</template>

Wyświetl plik

@ -1,5 +0,0 @@
.fm-client-provider {
display: flex;
flex-direction: column;
flex-grow: 1;
}

Wyświetl plik

@ -1,158 +0,0 @@
import { Component, ProvideReactive, Watch } from "vue-property-decorator";
import Vue from "vue";
import FmClient from "facilmap-client";
import "./client.scss";
import WithRender from "./client.vue";
import { PadData, PadId } from "facilmap-types";
import PadSettings from "../pad-settings/pad-settings";
import { Client, CLIENT_INJECT_KEY, InjectContext } from "../../utils/decorators";
import StringMap from "../../utils/string-map";
import storage from "../../utils/storage";
import { Context } from "../facilmap/facilmap";
import { showErrorToast, showToast } from "../../utils/toasts";
@WithRender
@Component({
components: { PadSettings }
})
export class ClientProvider extends Vue {
@InjectContext() readonly context!: Context;
@ProvideReactive(CLIENT_INJECT_KEY) client: Client | null = null;
counter = 1;
newClient: Client | null = null;
createId: string | null = null;
created(): void {
this.connect();
}
beforeDestroy(): void {
this.client?.disconnect();
}
async connect(): Promise<void> {
const existingClient = this.newClient || this.client;
if (existingClient && existingClient.server == this.context.serverUrl && existingClient.padId == this.context.activePadId)
return;
this.$bvToast.hide(`fm${this.context.id}-client-connecting`);
this.$bvToast.hide(`fm${this.context.id}-client-error`);
this.$bvToast.hide(`fm${this.context.id}-client-deleted`);
this.createId = null;
if (this.context.activePadId)
showToast(this, `fm${this.context.id}-client-connecting`, "Loading", "Loading map…", { spinner: true });
else
showToast(this, `fm${this.context.id}-client-connecting`, "Connecting", "Connecting to server…", { spinner: true });
const client = new FmClient<StringMap>(this.context.serverUrl, this.context.activePadId);
client._set = Vue.set;
client._delete = Vue.delete;
client._encodeData = (data) => data.toObject();
client._decodeData = (data) => new StringMap(data);
this.newClient = client;
let lastPadId: PadId | undefined = undefined;
let lastPadData: PadData | undefined = undefined;
client.on("padData", () => {
for (const bookmark of storage.bookmarks) {
if (lastPadId && bookmark.id == lastPadId)
bookmark.id = client.padId!;
if (lastPadData && lastPadData.id == bookmark.padId)
bookmark.padId = client.padData!.id;
if (bookmark.padId == client.padData!.id)
bookmark.name = client.padData!.name;
}
lastPadId = client.padId;
lastPadData = client.padData;
this.context.activePadId = client.padId;
this.context.activePadName = client.padData?.name;
});
client.on("deletePad", () => {
showToast(this, `fm${this.context.id}-client-deleted`, "Map deleted", "This map has been deleted.", {
variant: "danger",
actions: this.context.interactive ? [
{
label: "Close map",
href: this.context.baseUrl,
onClick: (e) => {
if (!e.ctrlKey && !e.shiftKey && !e.metaKey && !e.altKey) {
e.preventDefault();
this.context.activePadId = undefined;
}
}
}
] : []
});
})
await new Promise<void>((resolve) => {
client.once(this.context.activePadId ? "padData" : "connect", () => { resolve(); });
client.on("serverError", () => { resolve(); });
});
if (this.newClient !== client) {
// Another client has been initiated in the meantime
client.disconnect();
return;
}
// Bootstrap-Vue uses animation frames to show the connecting toast. If the map is loading in a background tab, the toast might not be shown
// yet when we are trying to hide it, so the hide operation is skipped and once the loading toast is shown, it stays forever.
// We need to wait for two animation frames to make sure that the toast is shown.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.$bvToast.hide(`fm${this.context.id}-client-connecting`);
});
});
if (client.serverError?.message?.includes("does not exist") && this.context.interactive) {
this.createId = client.padId!;
client.padId = undefined;
client.serverError = undefined;
setTimeout(() => {
this.$bvModal.show(`fm${this.context.id}-client-create-pad`);
}, 0);
} else if (client.serverError) {
showErrorToast(this, `fm${this.context.id}-client-error`, "Error opening map", client.serverError, {
noCloseButton: true,
actions: this.context.interactive ? [
{
label: "Close map",
href: this.context.baseUrl,
onClick: (e) => {
if (!e.ctrlKey && !e.shiftKey && !e.metaKey && !e.altKey) {
e.preventDefault();
this.context.activePadId = undefined;
}
}
}
] : []
});
}
this.counter++;
this.newClient = null;
this.client?.disconnect();
this.client = client;
}
@Watch("context.activePadId")
handlePadIdChange(): void {
this.connect();
}
@Watch("context.serverUrl")
handleServerUrlChange(): void {
this.connect();
}
}

Wyświetl plik

@ -1,10 +0,0 @@
<div class="fm-client-provider" :key="counter">
<slot v-if="client"/>
<b-toast v-if="client && client.disconnected" :id="`fm${context.id}-client-disconnected`" variant="danger" title="Disconnected" no-auto-hide no-close-button visible>
<b-spinner small></b-spinner>
The connection to the server was lost. Trying to reconnect
</b-toast>
<PadSettings v-if="createId" :id="`fm${context.id}-client-create-pad`" is-create no-cancel :proposed-admin-id="createId"></PadSettings>
</div>

Wyświetl plik

@ -1,21 +1,69 @@
<FormModal
:id="id"
title="Filter"
dialog-class="fm-edit-filter"
:is-modified="isModified"
@submit="save"
@show="initialize"
ok-title="Apply"
>
<template v-if="filter != null">
<script setup lang="ts">
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";
import ValidatedField from "./ui/validated-form/validated-field.vue";
const context = injectContextRequired();
const mapContext = requireMapContext(context);
const client = requireClientContext(context);
const emit = defineEmits<{
hidden: [];
}>();
const modalRef = ref<InstanceType<typeof ModalDialog>>();
const filter = ref(mapContext.value.filter ?? "");
const types = computed(() => getOrderedTypes(client.value.types));
function validateFilter(filter: string) {
return filterHasError(filter)?.message;
}
const isModified = computed(() => {
return filter.value != (mapContext.value.filter ?? "");
});
function save(): void {
mapContext.value.components.map.setFmFilter(filter.value || undefined);
modalRef.value?.modal.hide();
}
</script>
<template>
<ModalDialog
title="Filter"
class="fm-edit-filter"
:isModified="isModified"
@submit="save"
:okLabel="isModified ? 'Apply' : undefined"
ref="modalRef"
@hidden="emit('hidden')"
>
<p>Here you can set an advanced expression to show/hide certain markers/lines based on their attributes. The filter expression only applies to your view of the map, but it can be persisted as part of a saved view or a shared link.</p>
<ValidationProvider name="Filter" v-slot="v" rules="filter">
<b-form-group :state="v | validationState(true)">
<b-textarea v-model="filter" rows="5" :state="v | validationState(true)" class="text-monospace"></b-textarea>
<template #invalid-feedback><pre>{{v.errors[0]}}</pre></template>
</b-form-group>
</ValidationProvider>
<ValidatedField
:value="filter"
:validators="[
validateFilter
]"
:reportValid="!!filter"
immediate
>
<template #default="slotProps">
<textarea
class="form-control text-monospace"
v-model="filter"
rows="5"
:ref="slotProps.inputRef"
></textarea>
<div class="invalid-feedback" v-if="slotProps.validationError">
<pre>{{slotProps.validationError}}</pre>
</div>
</template>
</ValidatedField>
<hr />
@ -44,13 +92,20 @@
<tr>
<td><code>typeId</code></td>
<td><span v-for="(type, idx) in types"><span v-if="idx != 0"> / </span> <code>{{type.id}}</code> ({{type.name}})</span></td>
<td><code>typeId == {{types[0].id || 1}}</code></td>
<td>
<span v-for="(type, idx) in types" :key="type.id">
<span v-if="idx != 0"> / </span> <code>{{type.id}}</code> <span class="text-break">({{type.name}})</span>
</span>
</td>
<td><code>typeId == {{types[0]?.id || 1}}</code></td>
</tr>
<tr>
<td><code>data.&lt;field&gt;</code> / <code>prop(data, &lt;field&gt;)</code></td>
<td>Field values (example: <code>data.Description</code> or <code>prop(data, &quot;Description&quot;)</code></td>
<td>
Field values (example: <code>data.Description</code> or <code>prop(data, &quot;Description&quot;)</code>).<br />
For checkbox fields, the value is <code>0</code> (unchecked) or <code>1</code> (checked).
</td>
<td><code>lower(data.Description) ~= &quot;camp&quot;</code></td>
</tr>
@ -92,7 +147,7 @@
<tr>
<td><code>mode</code></td>
<td>Line routing mode (<code>""</code> / <code>"car"</code> / <code>"bicycle"</code> / <code>"pedestrian"</code> / <code>"track"</code>)</td>
<td>Line routing mode (<code>&quot;&quot;</code> / <code>&quot;car&quot;</code> / <code>&quot;bicycle&quot;</code> / <code>&quot;pedestrian&quot;</code> / <code>&quot;track&quot;</code>)</td>
<td><code>mode in (&quot;bicycle&quot;, &quot;pedestrian&quot;)</code></td>
</tr>
@ -102,6 +157,12 @@
<td><code>width &gt; 10</code></td>
</tr>
<tr>
<td><code>stroke</code></td>
<td>Line stroke (<code>`&quot;&quot;`</code> (solid) / <code>`&quot;dashed&quot;`</code> / <code>&quot;dotted&quot;</code>)</td>
<td><code>shape == &quot;dotted&quot;</code></td>
</tr>
<tr>
<td><code>distance</code></td>
<td>Line distance in kilometers</td>
@ -206,5 +267,32 @@
</tbody>
</table>
</div>
</template>
</FormModal>
</ModalDialog>
</template>
<style lang="scss">
.fm-edit-filter {
.modal-body.modal-body, form {
display: flex;
flex-direction: column;
min-height: 0;
}
hr {
width: 100%;
}
.fm-edit-filter-syntax {
overflow: auto;
margin-right: -16px;
padding-right: 16px;
min-height: 150px;
max-width: 100%;
}
pre {
color: inherit;
font-size: inherit;
}
}
</style>

Wyświetl plik

@ -1,24 +0,0 @@
.fm-edit-filter {
.modal-body.modal-body, form {
display: flex;
flex-direction: column;
min-height: 0;
}
hr {
width: 100%;
}
.fm-edit-filter-syntax {
overflow: auto;
margin-right: -16px;
padding-right: 16px;
min-height: 150px;
max-width: 100%;
}
pre {
color: inherit;
font-size: inherit;
}
}

Wyświetl plik

@ -1,47 +0,0 @@
import WithRender from "./edit-filter.vue";
import "./edit-filter.scss";
import Vue from "vue";
import { extend, ValidationProvider } from 'vee-validate';
import { filterHasError } from 'facilmap-utils';
import { Component, Prop } from "vue-property-decorator";
import { Client, InjectClient, InjectMapComponents, InjectMapContext } from "../../utils/decorators";
import { Type } from "facilmap-types";
import FormModal from "../ui/form-modal/form-modal";
import { MapComponents, MapContext } from "../leaflet-map/leaflet-map";
extend("filter", (filter: string): string | true => {
return filterHasError(filter)?.message ?? true;
});
@WithRender
@Component({
components: { FormModal, ValidationProvider }
})
export default class EditFilter extends Vue {
@InjectMapContext() mapContext!: MapContext;
@InjectMapComponents() mapComponents!: MapComponents;
@InjectClient() client!: Client;
@Prop({ type: String, required: true }) id!: string;
filter: string = null as any;
get types(): Type[] {
return Object.values(this.client.types);
}
initialize(): void {
this.filter = this.mapContext.filter ?? "";
}
get isModified(): boolean {
return this.filter != (this.mapContext.filter ?? "");
}
save(): void {
this.mapComponents.map.setFmFilter(this.filter || undefined);
this.$bvModal.hide(this.id);
}
}

Wyświetl plik

@ -0,0 +1,165 @@
<script setup lang="ts">
import { lineValidator, type ID } from "facilmap-types";
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";
import ColourPicker from "./ui/colour-picker.vue";
import FieldInput from "./ui/field-input.vue";
import RouteMode from "./ui/route-mode.vue";
import WidthPicker from "./ui/width-picker.vue";
import { computed, ref, toRef, watch } from "vue";
import { useToasts } from "./ui/toasts/toasts.vue";
import DropdownMenu from "./ui/dropdown-menu.vue";
import { injectContextRequired, requireClientContext } from "./facil-map-context-provider/facil-map-context-provider.vue";
import ValidatedField from "./ui/validated-form/validated-field.vue";
import StrokePicker from "./ui/stroke-picker.vue";
const context = injectContextRequired();
const client = requireClientContext(context);
const toasts = useToasts();
const props = defineProps<{
lineId: ID;
}>();
const emit = defineEmits<{
hidden: [];
}>();
const id = getUniqueId("fm-edit-line-dialog");
const modalRef = ref<InstanceType<typeof ModalDialog>>();
const originalLine = toRef(() => client.value.lines[props.lineId]);
const line = ref(cloneDeep(originalLine.value));
const isModified = computed(() => !isEqual(line.value, originalLine.value));
const types = computed(() => getOrderedTypes(client.value.types).filter((type) => type.type === "line"));
const resolvedCanControl = computed(() => canControl(client.value.types[line.value.typeId]));
watch(originalLine, (newLine, oldLine) => {
if (!newLine) {
modalRef.value?.modal.hide();
// TODO: Show message
} else {
mergeObject(oldLine, newLine, line.value);
}
});
async function save(): Promise<void> {
toasts.hideToast(`fm${context.id}-edit-line-error`);
try {
await client.value.editLine(omit(line.value, "trackPoints"));
modalRef.value?.modal.hide();
} catch (err) {
toasts.showErrorToast(`fm${context.id}-edit-line-error`, "Error saving line", err);
}
}
</script>
<template>
<ModalDialog
title="Edit Line"
class="fm-edit-line"
:isModified="isModified"
@submit="$event.waitUntil(save())"
@hidden="emit('hidden')"
ref="modalRef"
>
<template #default>
<div class="row mb-3">
<label :for="`${id}-name-input`" class="col-sm-3 col-form-label">Name</label>
<ValidatedField
:value="line.name"
:validators="[getZodValidator(lineValidator.update.shape.name)]"
class="col-sm-9 position-relative"
>
<template #default="slotProps">
<input class="form-control" :id="`${id}-name-input`" v-model="line.name" :ref="slotProps.inputRef" />
<div class="invalid-tooltip">
{{slotProps.validationError}}
</div>
</template>
</ValidatedField>
</div>
<div v-if="resolvedCanControl.includes('mode') && line.mode !== 'track'" class="row mb-3">
<label class="col-sm-3 col-form-label">Routing mode</label>
<div class="col-sm-9">
<RouteMode v-model="line.mode"></RouteMode>
</div>
</div>
<template v-if="resolvedCanControl.includes('colour')">
<div class="row mb-3">
<label :for="`${id}-colour-input`" class="col-sm-3 col-form-label">Colour</label>
<div class="col-sm-9">
<ColourPicker
:id="`${id}-colour-input`"
v-model="line.colour"
:validators="[validateRequired]"
></ColourPicker>
</div>
</div>
</template>
<template v-if="resolvedCanControl.includes('width')">
<div class="row mb-3">
<label :for="`${id}-width-input`" class="col-sm-3 col-form-label">Width</label>
<div class="col-sm-9">
<WidthPicker
:id="`${id}-width-input`"
v-model="line.width"
class="fm-custom-range-with-label"
></WidthPicker>
</div>
</div>
</template>
<template v-if="resolvedCanControl.includes('stroke')">
<div class="row mb-3">
<label :for="`${id}-stroke-input`" class="col-sm-3 col-form-label">Stroke</label>
<div class="col-sm-9">
<StrokePicker
:id="`${id}-stroke-input`"
v-model="line.stroke"
></StrokePicker>
</div>
</div>
</template>
<template v-for="(field, idx) in client.types[line.typeId].fields" :key="field.name">
<div class="row mb-3">
<label :for="`${id}-${idx}-input`" class="col-sm-3 col-form-label text-break">{{field.name}}</label>
<div class="col-sm-9">
<FieldInput
:id="`${id}-${idx}-input`"
:field="field"
v-model="line.data[field.name]"
></FieldInput>
</div>
</div>
</template>
</template>
<template #footer-left>
<DropdownMenu v-if="types.length > 1" class="dropup" label="Change type">
<template v-for="type in types" :key="type.id">
<li>
<a
href="javascript:"
class="dropdown-item"
:class="{ active: type.id == line.typeId }"
@click="line.typeId = type.id"
>{{type.name}}</a>
</li>
</template>
</DropdownMenu>
</template>
</ModalDialog>
</template>

Wyświetl plik

@ -1,87 +0,0 @@
import WithRender from "./edit-line.vue";
import Vue from "vue";
import { ID, Line, Type } from "facilmap-types";
import { Client, InjectClient, InjectContext } from "../../utils/decorators";
import { Component, Prop, Watch } from "vue-property-decorator";
import { canControl, IdType, mergeObject } from "../../utils/utils";
import { clone } from "facilmap-utils";
import { isEqual, omit } from "lodash";
import { showErrorToast } from "../../utils/toasts";
import FormModal from "../ui/form-modal/form-modal";
import { ValidationProvider } from "vee-validate";
import ColourField from "../ui/colour-field/colour-field";
import SymbolField from "../ui/symbol-field/symbol-field";
import ShapeField from "../ui/shape-field/shape-field";
import FieldInput from "../ui/field-input/field-input";
import RouteMode from "../ui/route-mode/route-mode";
import WidthField from "../ui/width-field/width-field";
import StringMap from "../../utils/string-map";
import { Context } from "../facilmap/facilmap";
@WithRender
@Component({
components: { ColourField, FieldInput, FormModal, RouteMode, ShapeField, SymbolField, ValidationProvider, WidthField }
})
export default class EditLine extends Vue {
@InjectContext() context!: Context;
@InjectClient() client!: Client;
@Prop({ type: String, required: true }) id!: string;
@Prop({ type: IdType, required: true }) lineId!: ID;
line: Line<StringMap> = null as any;
isSaving = false;
initialize(): void {
this.line = clone(this.client.lines[this.lineId]);
}
clear(): void {
this.line = null as any;
}
get isModified(): boolean {
return !isEqual(this.line, this.client.lines[this.lineId]);
}
get originalLine(): Line<StringMap> | undefined {
return this.client.lines[this.lineId];
}
get types(): Type[] {
return Object.values(this.client.types).filter((type) => type.type === "line");
}
get canControl(): Array<keyof Line> {
return canControl(this.client.types[this.line.typeId]);
}
@Watch("originalLine")
handleChangeLine(newLine: Line<StringMap> | undefined, oldLine: Line<StringMap>): void {
if (this.line) {
if (!newLine) {
this.$bvModal.hide(this.id);
// TODO: Show message
} else {
mergeObject(oldLine, newLine, this.line);
}
}
}
async save(): Promise<void> {
this.isSaving = true;
this.$bvToast.hide(`fm${this.context.id}-edit-line-error`);
try {
await this.client.editLine(omit(this.line, "trackPoints"));
this.$bvModal.hide(this.id);
} catch (err) {
showErrorToast(this, `fm${this.context.id}-edit-line-error`, "Error saving line", err);
} finally {
this.isSaving = false;
}
}
}

Wyświetl plik

@ -1,43 +0,0 @@
<FormModal
:id="id"
title="Edit Line"
dialog-class="fm-edit-line"
:is-saving="isSaving"
:is-modified="isModified"
@submit="save"
@hidden="clear"
@show="initialize"
>
<template v-if="line">
<b-form-group label="Name" :label-for="`${id}-name-input`" label-cols-sm="3">
<b-form-input :id="`${id}-name-input`" v-model="line.name"></b-form-input>
</b-form-group>
<b-form-group label="Routing mode" v-if="canControl.includes('mode') && line.mode != 'track'" label-cols-sm="3">
<RouteMode v-model="line.mode"></RouteMode>
</b-form-group>
<ValidationProvider v-if="canControl.includes('colour')" name="Colour" v-slot="v" rules="required|colour">
<b-form-group label="Colour" :label-for="`${id}-colour-input`" label-cols-sm="3" :state="v | validationState">
<ColourField :id="`${id}-colour-input`" v-model="line.colour" :state="v | validationState"></ColourField>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
<ValidationProvider v-if="canControl.includes('width')" name="Width" v-slot="v" rules="width">
<b-form-group label="Width" :label-for="`${id}-width-input`" label-cols-sm="3">
<WidthField :id="`${id}-width-input`" v-model="line.width"></WidthField>
</b-form-group>
</ValidationProvider>
<b-form-group v-for="(field, idx in client.types[line.typeId].fields" :label="field.name" :label-for="`fm-edit-line-${idx}-input`" label-cols-sm="3">
<FieldInput :id="`${id}-${idx}-input`" :field="field" :value="line.data.get(field.name)" @input="line.data.set(field.name, $event)"></FieldInput>
</b-form-group>
</template>
<template #footer-left>
<b-dropdown dropup v-if="types.length > 1" text="Change type">
<b-dropdown-item v-for="type in types" :active="type.id == line.typeId" @click="line.typeId = type.id">{{type.name}}</b-dropdown-item>
</b-dropdown>
</template>
</FormModal>

Wyświetl plik

@ -0,0 +1,163 @@
<script setup lang="ts">
import { markerValidator, type ID } from "facilmap-types";
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";
import ColourPicker from "./ui/colour-picker.vue";
import SymbolPicker from "./ui/symbol-picker.vue";
import ShapePicker from "./ui/shape-picker.vue";
import FieldInput from "./ui/field-input.vue";
import SizePicker from "./ui/size-picker.vue";
import { computed, ref, toRef, watch } from "vue";
import { useToasts } from "./ui/toasts/toasts.vue";
import DropdownMenu from "./ui/dropdown-menu.vue";
import { injectContextRequired, requireClientContext } from "./facil-map-context-provider/facil-map-context-provider.vue";
import ValidatedField from "./ui/validated-form/validated-field.vue";
const context = injectContextRequired();
const client = requireClientContext(context);
const toasts = useToasts();
const props = defineProps<{
markerId: ID;
}>();
const emit = defineEmits<{
hidden: [];
}>();
const id = getUniqueId("edit-marker-dialog");
const modalRef = ref<InstanceType<typeof ModalDialog>>();
const originalMarker = toRef(() => client.value.markers[props.markerId]);
const marker = ref(cloneDeep(originalMarker.value));
const isModified = computed(() => !isEqual(marker.value, client.value.markers[props.markerId]));
const types = computed(() => getOrderedTypes(client.value.types).filter((type) => type.type === "marker"));
const resolvedCanControl = computed(() => canControl(client.value.types[marker.value.typeId]));
watch(originalMarker, (newMarker, oldMarker) => {
if (!newMarker) {
modalRef.value?.modal.hide();
// TODO: Show message
} else {
mergeObject(oldMarker, newMarker, marker.value);
}
});
async function save(): Promise<void> {
toasts.hideToast(`fm${context.id}-edit-marker-error`);
try {
await client.value.editMarker(marker.value);
modalRef.value?.modal.hide();
} catch (err) {
toasts.showErrorToast(`fm${context.id}-edit-marker-error`, "Error saving marker", err);
}
}
</script>
<template>
<ModalDialog
title="Edit Marker"
class="fm-edit-marker"
:isModified="isModified"
ref="modalRef"
@submit="$event.waitUntil(save())"
@hidden="emit('hidden')"
>
<template #default>
<div class="row mb-3">
<label :for="`${id}-name-input`" class="col-sm-3 col-form-label">Name</label>
<ValidatedField
:value="marker.name"
:validators="[getZodValidator(markerValidator.update.shape.name)]"
class="col-sm-9 position-relative"
>
<template #default="slotProps">
<input class="form-control" :id="`${id}-name-input`" v-model="marker.name" :ref="slotProps.inputRef" />
<div class="invalid-tooltip">
{{slotProps.validationError}}
</div>
</template>
</ValidatedField>
</div>
<template v-if="resolvedCanControl.includes('colour')">
<div class="row mb-3">
<label :for="`${id}-colour-input`" class="col-sm-3 col-form-label">Colour</label>
<div class="col-sm-9">
<ColourPicker
:id="`${id}-colour-input`"
v-model="marker.colour"
:validators="[validateRequired]"
></ColourPicker>
</div>
</div>
</template>
<template v-if="resolvedCanControl.includes('size')">
<div class="row mb-3">
<label :for="`${id}-size-input`" class="col-sm-3 col-form-label">Size</label>
<div class="col-sm-9">
<SizePicker
:id="`${id}-size-input`"
v-model="marker.size"
class="fm-custom-range-with-label"
></SizePicker>
</div>
</div>
</template>
<template v-if="resolvedCanControl.includes('symbol')">
<div class="row mb-3">
<label :for="`${id}-symbol-input`" class="col-sm-3 col-form-label">Icon</label>
<div class="col-sm-9">
<SymbolPicker :id="`${id}-symbol-input`" v-model="marker.symbol"></SymbolPicker>
</div>
</div>
</template>
<template v-if="resolvedCanControl.includes('shape')">
<div class="row mb-3">
<label :for="`${id}-shape-input`" class="col-sm-3 col-form-label">Shape</label>
<div class="col-sm-9">
<ShapePicker :id="`${id}-shape-input`" v-model="marker.shape"></ShapePicker>
</div>
</div>
</template>
<template v-for="(field, idx) in client.types[marker.typeId].fields" :key="field.name">
<div class="row mb-3">
<label :for="`${id}-${idx}-input`" class="col-sm-3 col-form-label text-break">{{field.name}}</label>
<div class="col-sm-9">
<FieldInput
:id="`fm-edit-marker-${idx}-input`"
:field="field"
v-model="marker.data[field.name]"
></FieldInput>
</div>
</div>
</template>
</template>
<template #footer-left>
<DropdownMenu v-if="types.length > 1" class="dropup" label="Change type">
<template v-for="type in types" :key="type.id">
<li>
<a
href="javascript:"
class="dropdown-item"
:class="{ active: type.id == marker.typeId }"
@click="marker.typeId = type.id"
>{{type.name}}</a>
</li>
</template>
</DropdownMenu>
</template>
</ModalDialog>
</template>

Wyświetl plik

@ -1,86 +0,0 @@
import WithRender from "./edit-marker.vue";
import Vue from "vue";
import { ID, Marker, Type } from "facilmap-types";
import { Client, InjectClient, InjectContext } from "../../utils/decorators";
import { Component, Prop, Watch } from "vue-property-decorator";
import { canControl, IdType, mergeObject } from "../../utils/utils";
import { clone } from "facilmap-utils";
import { isEqual } from "lodash";
import { showErrorToast } from "../../utils/toasts";
import FormModal from "../ui/form-modal/form-modal";
import { ValidationProvider } from "vee-validate";
import ColourField from "../ui/colour-field/colour-field";
import SymbolField from "../ui/symbol-field/symbol-field";
import ShapeField from "../ui/shape-field/shape-field";
import FieldInput from "../ui/field-input/field-input";
import SizeField from "../ui/size-field/size-field";
import StringMap from "../../utils/string-map";
import { Context } from "../facilmap/facilmap";
@WithRender
@Component({
components: { ColourField, FieldInput, FormModal, ShapeField, SizeField, SymbolField, ValidationProvider }
})
export default class EditMarker extends Vue {
@InjectContext() context!: Context;
@InjectClient() client!: Client;
@Prop({ type: String, required: true }) id!: string;
@Prop({ type: IdType, required: true }) markerId!: ID;
marker: Marker<StringMap> = null as any;
isSaving = false;
initialize(): void {
this.marker = clone(this.client.markers[this.markerId]);
}
clear(): void {
this.marker = null as any;
}
get isModified(): boolean {
return !isEqual(this.marker, this.client.markers[this.markerId]);
}
get originalMarker(): Marker<StringMap> | undefined {
return this.client.markers[this.markerId];
}
get types(): Type[] {
return Object.values(this.client.types).filter((type) => type.type === "marker");
}
get canControl(): Array<keyof Marker> {
return canControl(this.client.types[this.marker.typeId]);
}
@Watch("originalMarker")
handleChangeMarker(newMarker: Marker<StringMap> | undefined, oldMarker: Marker<StringMap>): void {
if (this.marker) {
if (!newMarker) {
this.$bvModal.hide(this.id);
// TODO: Show message
} else {
mergeObject(oldMarker, newMarker, this.marker);
}
}
}
async save(): Promise<void> {
this.isSaving = true;
this.$bvToast.hide(`fm${this.context.id}-edit-marker-error`);
try {
await this.client.editMarker(this.marker);
this.$bvModal.hide(this.id);
} catch (err) {
showErrorToast(this, `fm${this.context.id}-edit-marker-error`, "Error saving marker", err);
} finally {
this.isSaving = false;
}
}
}

Wyświetl plik

@ -1,53 +0,0 @@
<FormModal
:id="id"
title="Edit Marker"
dialog-class="fm-edit-marker"
:is-saving="isSaving"
:is-modified="isModified"
@submit="save"
@show="initialize"
@hidden="clear"
>
<template v-if="marker">
<b-form-group label="Name" label-for="`${id}-name-input`" label-cols-sm="3">
<b-form-input :id="`${id}-name-input`" v-model="marker.name"></b-form-input>
</b-form-group>
<ValidationProvider v-if="canControl.includes('colour')" name="Colour" v-slot="v" rules="required|colour">
<b-form-group label="Colour" :label-for="`${id}-colour-input`" label-cols-sm="3" :state="v | validationState">
<ColourField :id="`${id}-colour-input`" v-model="marker.colour" :state="v | validationState"></ColourField>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
<ValidationProvider v-if="canControl.includes('size')" name="Size" v-slot="v" rules="size">
<b-form-group label="Size" :label-for="`${id}-size-input`" label-cols-sm="3">
<SizeField :id="`${id}-size-input`" v-model="marker.size"></SizeField>
</b-form-group>
</ValidationProvider>
<ValidationProvider v-if="canControl.includes('symbol')" name="Icon" v-slot="v" rules="symbol">
<b-form-group label="Icon" :label-for="`${id}-symbol-input`" label-cols-sm="3" :state="v | validationState">
<SymbolField :id="`${id}-symbol-input`" v-model="marker.symbol" :state="v | validationState"></SymbolField>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
<ValidationProvider v-if="canControl.includes('shape')" name="Shape" v-slot="v" rules="shape">
<b-form-group label="Shape" :label-for="`${id}-shape-input`" label-cols-sm="3" :state="v | validationState">
<ShapeField :id="`${id}-shape-input`" v-model="marker.shape" :state="v | validationState"></ShapeField>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
<b-form-group v-for="(field, idx in client.types[marker.typeId].fields" :label="field.name" :label-for="`fm-edit-marker-${idx}-input`" label-cols-sm="3">
<FieldInput :id="`fm-edit-marker-${idx}-input`" :field="field" :value="marker.data.get(field.name)" @input="marker.data.set(field.name, $event)"></FieldInput>
</b-form-group>
</template>
<template #footer-left>
<b-dropdown dropup v-if="types.length > 1" text="Change type">
<b-dropdown-item v-for="type in types" :active="type.id == marker.typeId" @click="marker.typeId = type.id">{{type.name}}</b-dropdown-item>
</b-dropdown>
</template>
</FormModal>

Wyświetl plik

@ -1,3 +1,4 @@
import { expect, test } from "vitest";
import { mergeTypeObject } from "../edit-type-utils";
function merge(oldType: any, newType: any, targetType: any): any {

Wyświetl plik

@ -0,0 +1,484 @@
<script setup lang="ts">
import { typeValidator, type Field, type ID, type Type, type CRU } from "facilmap-types";
import { canControl } from "facilmap-utils";
import { getUniqueId, getZodValidator, validateRequired } from "../../utils/utils";
import { mergeTypeObject } from "./edit-type-utils";
import { cloneDeep, isEqual } from "lodash-es";
import { useToasts } from "../ui/toasts/toasts.vue";
import ColourPicker from "../ui/colour-picker.vue";
import ShapePicker from "../ui/shape-picker.vue";
import SymbolPicker from "../ui/symbol-picker.vue";
import RouteMode from "../ui/route-mode.vue";
import Draggable from "vuedraggable";
import FieldInput from "../ui/field-input.vue";
import Icon from "../ui/icon.vue";
import WidthPicker from "../ui/width-picker.vue";
import SizePicker from "../ui/size-picker.vue";
import EditTypeDropdownDialog from "./edit-type-dropdown-dialog.vue";
import { computed, ref, watch } from "vue";
import ModalDialog from "../ui/modal-dialog.vue";
import { showConfirm } from "../ui/alert.vue";
import { injectContextRequired, requireClientContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import ValidatedField from "../ui/validated-form/validated-field.vue";
import StrokePicker from "../ui/stroke-picker.vue";
const context = injectContextRequired();
const client = requireClientContext(context);
const toasts = useToasts();
const props = defineProps<{
typeId: ID | "createMarkerType" | "createLineType";
}>();
const emit = defineEmits<{
hidden: [];
}>();
const id = getUniqueId("fm-edit-type-dialog");
const isCreate = computed(() => props.typeId === "createMarkerType" || props.typeId === "createLineType");
const originalType = computed(() => {
return typeof props.typeId === "number" ? client.value.types[props.typeId] : undefined;
});
const initialType = computed<Type<CRU.CREATE_VALIDATED | CRU.READ>>(() => {
let type: Type<CRU.CREATE_VALIDATED | CRU.READ>;
if (props.typeId === "createMarkerType") {
type = {
...typeValidator.create.parse({ name: "-", type: "marker" } satisfies Type<CRU.CREATE>),
name: ""
};
} else if (props.typeId === "createLineType") {
type = {
...typeValidator.create.parse({ name: "-", type: "line" } satisfies Type<CRU.CREATE>),
name: ""
};
} else {
type = cloneDeep(originalType.value)!;
}
for(const field of type.fields) {
(field as any).oldName = field.name;
}
return type;
});
const type = ref(cloneDeep(initialType.value));
const editField = ref<Field>();
const modalRef = ref<InstanceType<typeof ModalDialog>>();
const isModified = computed(() => {
return !isEqual(type.value, initialType.value);
});
const resolvedCanControl = computed(() => canControl(type.value, null));
watch(originalType, (newType, oldType) => {
if (oldType && type.value) {
if (!newType) {
modalRef.value?.modal.hide();
// TODO: Show message
} else {
mergeTypeObject(oldType, newType, type.value);
}
}
});
function createField(): void {
type.value.fields.push({ name: "", type: "input", "default": "" });
}
async function deleteField(field: Field): Promise<void> {
if (!await showConfirm({
title: "Delete field",
message: `Do you really want to delete the field “${field.name}”?`,
variant: "danger",
okLabel: "Delete"
}))
return;
var idx = type.value.fields.indexOf(field);
if(idx != -1)
type.value.fields.splice(idx, 1);
}
async function save(): Promise<void> {
toasts.hideToast(`fm${context.id}-edit-type-error`);
try {
if (isCreate.value)
await client.value.addType(type.value);
else
await client.value.editType(type.value as Type);
modalRef.value?.modal.hide();
} catch (err) {
toasts.showErrorToast(`fm${context.id}-edit-type-error`, isCreate.value ? "Error creating type" : "Error saving type", err);
}
}
function editDropdown(field: Field): void {
editField.value = field;
}
function handleUpdateField(field: Field) {
const idx = type.value.fields.indexOf(editField.value!);
if (idx === -1)
toasts.showErrorToast(`fm${context.id}-edit-type-dropdown-error`, "Error updating field", new Error("The field cannot be found on the type anymore."));
type.value.fields[idx] = field;
}
function validateFieldName(name: string) {
if (type.value.fields.filter((field) => field.name == name).length > 1) {
return "Multiple fields cannot have the same name.";
}
}
</script>
<template>
<ModalDialog
title="Edit Type"
class="fm-edit-type"
:isModified="isModified"
:isCreate="isCreate"
ref="modalRef"
@submit="$event.waitUntil(save())"
@hidden="emit('hidden')"
>
<div class="row mb-3">
<label :for="`${id}-name-input`" class="col-sm-3 col-form-label">Name</label>
<ValidatedField
:value="type.name"
:validators="[validateRequired, getZodValidator(typeValidator.update.shape.name)]"
class="col-sm-9 position-relative"
>
<template #default="slotProps">
<input class="form-control" :id="`${id}-name-input`" v-model="type.name" :ref="slotProps.inputRef" />
<div class="invalid-tooltip">
{{slotProps.validationError}}
</div>
</template>
</ValidatedField>
</div>
<div class="row mb-3">
<label :for="`${id}-type-input`" class="col-sm-3 col-form-label">Type</label>
<div class="col-sm-9">
<select
:id="`${id}-type-input`"
v-model="type.type"
class="form-select"
disabled
>
<option value="marker">Marker</option>
<option value="line">Line</option>
</select>
</div>
</div>
<template v-if="resolvedCanControl.length > 0">
<hr/>
<p class="text-muted">
These styles are applied when a new object of this type is created. If Fixed is enabled, the style is applied to all objects
of this type and cannot be changed for an individual object anymore. For more complex style control, dropdown or checkbox fields
can be configured below to change the style based on their selected value.
</p>
<template v-if="resolvedCanControl.includes('colour')">
<div class="row mb-3">
<label :for="`${id}-default-colour-input`" class="col-sm-3 col-form-label">Default colour</label>
<div class="col-sm-9">
<div class="row align-items-center">
<div class="col-sm-9">
<ColourPicker
:id="`${id}-default-colour-input`"
v-model="type.defaultColour"
></ColourPicker>
</div>
<div class="col-sm-3">
<div class="form-check">
<input
type="checkbox"
class="form-check-input"
:id="`${id}-default-colour-fixed`"
v-model="type.colourFixed"
/>
<label :for="`${id}-default-colour-fixed`" class="form-check-label">Fixed</label>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-if="resolvedCanControl.includes('size')">
<div class="row mb-3">
<label :for="`${id}-default-size-input`" class="col-sm-3 col-form-label">Default size</label>
<div class="col-sm-9">
<div class="row align-items-center">
<div class="col-sm-9">
<SizePicker
:id="`${id}-default-size-input`"
v-model="type.defaultSize"
class="fm-custom-range-with-label"
></SizePicker>
</div>
<div class="col-sm-3">
<div class="form-check fm-form-check-with-label">
<input
type="checkbox"
class="form-check-input"
:id="`${id}-default-size-fixed`"
v-model="type.sizeFixed"
/>
<label :for="`${id}-default-size-fixed`" class="form-check-label">Fixed</label>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-if="resolvedCanControl.includes('symbol')">
<div class="row mb-3">
<label :for="`${id}-default-symbol-input`" class="col-sm-3 col-form-label">Default icon</label>
<div class="col-sm-9">
<div class="row align-items-center">
<div class="col-sm-9">
<SymbolPicker
:id="`${id}-default-symbol-input`"
v-model="type.defaultSymbol"
></SymbolPicker>
</div>
<div class="col-sm-3">
<div class="form-check">
<input
type="checkbox"
class="form-check-input"
:id="`${id}-default-symbol-fixed`"
v-model="type.symbolFixed"
/>
<label :for="`${id}-default-symbol-fixed`" class="form-check-label">Fixed</label>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-if="resolvedCanControl.includes('shape')">
<div class="row mb-3">
<label :for="`${id}-default-shape-input`" class="col-sm-3 col-form-label">Default shape</label>
<div class="col-sm-9">
<div class="row align-items-center">
<div class="col-sm-9">
<ShapePicker
:id="`${id}-default-shape-input`"
v-model="type.defaultShape"
></ShapePicker>
</div>
<div class="col-sm-3">
<div class="form-check">
<input
type="checkbox"
class="form-check-input"
:id="`${id}-default-shape-fixed`"
v-model="type.shapeFixed"
/>
<label :for="`${id}-default-shape-fixed`" class="form-check-label">Fixed</label>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-if="resolvedCanControl.includes('width')">
<div class="row mb-3">
<label :for="`${id}-default-width-input`" class="col-sm-3 col-form-label">Default width</label>
<div class="col-sm-9">
<div class="row align-items-center">
<div class="col-sm-9">
<WidthPicker
:id="`${id}-default-width-input`"
v-model="type.defaultWidth"
class="fm-custom-range-with-label"
></WidthPicker>
</div>
<div class="col-sm-3">
<div class="form-check fm-form-check-with-label">
<input
type="checkbox"
class="form-check-input"
:id="`${id}-default-width-fixed`"
v-model="type.widthFixed"
/>
<label :for="`${id}-default-width-fixed`" class="form-check-label">Fixed</label>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-if="resolvedCanControl.includes('stroke')">
<div class="row mb-3">
<label :for="`${id}-default-stroke-input`" class="col-sm-3 col-form-label">Default stroke</label>
<div class="col-sm-9">
<div class="row align-items-center">
<div class="col-sm-9">
<StrokePicker
:id="`${id}-default-stroke-input`"
v-model="type.defaultStroke"
></StrokePicker>
</div>
<div class="col-sm-3">
<div class="form-check fm-form-check-with-label">
<input
type="checkbox"
class="form-check-input"
:id="`${id}-default-stroke-fixed`"
v-model="type.strokeFixed"
/>
<label :for="`${id}-default-stroke-fixed`" class="form-check-label">Fixed</label>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-if="resolvedCanControl.includes('mode')">
<div class="row mb-3">
<label :for="`${id}-default-mode-input`" class="col-sm-3 col-form-label">Default routing mode</label>
<div class="col-sm-9">
<div class="row align-items-center">
<div class="col-sm-9">
<RouteMode
:id="`${id}-default-mode-input`"
v-model="type.defaultMode"
></RouteMode>
</div>
<div class="col-sm-3">
<div class="form-check">
<input
type="checkbox"
class="form-check-input"
:id="`${id}-default-mode-fixed`"
v-model="type.modeFixed"
/>
<label :for="`${id}-default-mode-fixed`" class="form-check-label">Fixed</label>
</div>
</div>
</div>
</div>
</div>
</template>
<hr/>
</template>
<div class="row mb-3">
<label :for="`${id}-show-in-legend-input`" class="col-sm-3 col-form-label">Legend</label>
<div class="col-sm-9">
<div class="form-check fm-form-check-with-label">
<input
type="checkbox"
class="form-check-input"
:id="`${id}-show-in-legend-input`"
v-model="type.showInLegend"
/>
<label :for="`${id}-show-in-legend-input`" class="form-check-label">Show in legend</label>
</div>
<div class="form-text">
An item for this type will be shown in the legend. Any fixed style attributes are applied to it. Dropdown or checkbox fields that control the style generate additional legend items.
</div>
</div>
</div>
<h2>Fields</h2>
<div class="table-responseive">
<table class="table table-hover table-striped">
<thead>
<tr>
<th style="width: 35%; min-width: 150px">Name</th>
<th style="width: 35%; min-width: 120px">Type</th>
<th style="width: 35%; min-width: 150px">Default value</th>
<th>Delete</th>
<th></th>
</tr>
</thead>
<Draggable
v-model="type.fields"
tag="tbody"
handle=".fm-drag-handle"
:itemKey="(field: any) => type.fields.indexOf(field)"
>
<template #item="{ element: field }">
<tr>
<ValidatedField
tag="td"
class="position-relative"
:value="field.name"
:validators="[validateRequired, validateFieldName]"
>
<template #default="slotProps">
<input
class="form-control"
v-model="field.name"
:ref="slotProps.inputRef"
/>
<div class="invalid-tooltip">
{{slotProps.validationError}}
</div>
</template>
</ValidatedField>
<td>
<div class="input-group">
<select class="form-select" v-model="field.type">
<option value="input">Text field</option>
<option value="textarea">Text area</option>
<option value="dropdown">Dropdown</option>
<option value="checkbox">Checkbox</option>
</select>
<template v-if="['dropdown', 'checkbox'].includes(field.type)">
<button type="button" class="btn btn-secondary" @click="editDropdown(field)">Edit</button>
</template>
</div>
</td>
<td class="text-center">
<FieldInput :field="field" v-model="field.default" ignore-default></FieldInput>
</td>
<td class="td-buttons">
<button type="button" class="btn btn-secondary" @click="deleteField(field)">Delete</button>
</td>
<td class="td-buttons">
<button type="button" class="btn btn-secondary fm-drag-handle"><Icon icon="resize-vertical" alt="Reorder"></Icon></button>
</td>
</tr>
</template>
</Draggable>
<tfoot>
<tr>
<td colspan="4">
<button type="button" class="btn btn-secondary" @click="createField()"><Icon icon="plus" alt="Add"></Icon></button>
</td>
<td class="move"></td>
</tr>
</tfoot>
</table>
</div>
<EditTypeDropdownDialog
v-if="editField != null"
:type="type"
:field="editField"
@update:field="handleUpdateField($event)"
@hidden="editField = undefined"
></EditTypeDropdownDialog>
</ModalDialog>
</template>

Wyświetl plik

@ -0,0 +1,363 @@
<script setup lang="ts">
import type { CRU, Field, FieldOptionUpdate, FieldUpdate, Type } from "facilmap-types";
import { canControl, mergeObject } from "facilmap-utils";
import { getUniqueId } from "../../utils/utils";
import { cloneDeep, isEqual } from "lodash-es";
import ColourPicker from "../ui/colour-picker.vue";
import Draggable from "vuedraggable";
import Icon from "../ui/icon.vue";
import ModalDialog from "../ui/modal-dialog.vue";
import ShapePicker from "../ui/shape-picker.vue";
import SizePicker from "../ui/size-picker.vue";
import SymbolPicker from "../ui/symbol-picker.vue";
import WidthPicker from "../ui/width-picker.vue";
import { useToasts } from "../ui/toasts/toasts.vue";
import { computed, ref, watch } from "vue";
import { showConfirm } from "../ui/alert.vue";
import { injectContextRequired } from "../facil-map-context-provider/facil-map-context-provider.vue";
import ValidatedField from "../ui/validated-form/validated-field.vue";
import StrokePicker from "../ui/stroke-picker.vue";
function getControlNumber(type: Type<CRU.READ | CRU.CREATE_VALIDATED>, field: FieldUpdate): number {
return [
field.controlColour,
...(type.type == "marker" ? [
field.controlSize,
field.controlSymbol,
field.controlShape
] : []),
...(type.type == "line" ? [
field.controlWidth,
field.controlStroke
] : [])
].filter((v) => v).length;
}
const context = injectContextRequired();
const toasts = useToasts();
const props = defineProps<{
type: Type<CRU.READ | CRU.CREATE_VALIDATED>;
field: Field;
}>();
const emit = defineEmits<{
"update:field": [field: Field];
hidden: [];
}>();
const id = getUniqueId("fm-edit-type-dropdown-dialog");
const modalRef = ref<InstanceType<typeof ModalDialog>>();
const initialField = computed(() => {
const field: FieldUpdate = cloneDeep(props.field);
if(field.type == 'checkbox') {
if(!field.options || field.options.length != 2) {
field.options = [
{ value: '' },
{ value: field.name }
]
}
// Convert legacy format
if(field.options[0].value == "0")
field.options[0].value = "";
if(field.options[1].value == "1")
field.options[1].value = field.name;
}
for(let option of (field.options || []))
option.oldValue = option.value;
return field;
});
const fieldValue = ref(cloneDeep(initialField.value));
watch(() => props.field, (newField, oldField) => {
if (fieldValue.value) {
if (newField == null) {
modalRef.value?.modal.hide();
// TODO: Show message
} else {
mergeObject(oldField, newField, fieldValue.value);
}
}
}, { deep: true });
const isModified = computed(() => !isEqual(fieldValue.value, initialField.value));
const resolvedCanControl = computed(() => canControl(props.type, props.field));
function addOption(): void {
if(fieldValue.value.options == null)
fieldValue.value.options = [ ];
fieldValue.value.options!.push({ value: "" });
}
async function deleteOption(option: FieldOptionUpdate): Promise<void> {
if (!await showConfirm({
title: "Delete option",
message: `Do you really want to delete the option “${option.value}”?`,
variant: "danger",
okLabel: "Delete"
}))
return;
var idx = fieldValue.value.options!.indexOf(option);
if(idx != -1)
fieldValue.value.options!.splice(idx, 1);
}
function save(): void {
toasts.hideToast(`fm${context.id}-edit-type-dropdown-error`);
emit("update:field", fieldValue.value);
modalRef.value?.modal.hide();
}
const controlNumber = computed(() => getControlNumber(props.type, fieldValue.value));
const columns = computed(() => controlNumber.value + (fieldValue.value.type === "checkbox" ? 2 : 3));
function validateOptionValue(value: string): string | undefined {
if (fieldValue.value.type !== "checkbox" && fieldValue.value.options!.filter((op) => op.value === value).length > 1) {
return "Multiple options cannot have the same label.";
}
}
const formValidationError = computed(() => {
if (controlNumber.value > 0 && (fieldValue.value.options?.length ?? 0) === 0) {
return "Controlling fields need to have at least one option.";
} else {
return undefined;
}
});
</script>
<template>
<ModalDialog
:title="`Edit ${fieldValue.type == 'checkbox' ? 'Checkbox' : 'Dropdown'}`"
class="fm-edit-type-dropdown"
:isModified="isModified"
@submit="save()"
@hidden="emit('hidden')"
:size="fieldValue && controlNumber > 2 ? 'xl' : 'lg'"
:okLabel="isModified ? 'OK' : undefined"
:formValidationError="formValidationError"
ref="modalRef"
>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Control</label>
<div class="col-sm-9">
<div class="form-check fm-form-check-with-label">
<input
:id="`${id}-control-colour`"
class="form-check-input"
type="checkbox"
v-model="fieldValue.controlColour"
:disabled="!resolvedCanControl.includes('colour')"
/>
<label
class="form-check-label"
:for="`${id}-control-colour`"
>
Control {{type.type}} colour
</label>
</div>
<div v-if="type.type == 'marker'" class="form-check">
<input
:id="`${id}-control-size`"
class="form-check-input"
type="checkbox"
v-model="fieldValue.controlSize"
:disabled="!resolvedCanControl.includes('size')"
/>
<label
class="form-check-label"
:for="`${id}-control-size`"
>
Control {{type.type}} size
</label>
</div>
<div v-if="type.type == 'marker'" class="form-check">
<input
:id="`${id}-control-symbol`"
class="form-check-input"
type="checkbox"
v-model="fieldValue.controlSymbol"
:disabled="!resolvedCanControl.includes('symbol')"
/>
<label
class="form-check-label"
:for="`${id}-control-symbol`"
>
Control {{type.type}} icon
</label>
</div>
<div v-if="type.type == 'marker'" class="form-check">
<input
:id="`${id}-control-shape`"
class="form-check-input"
type="checkbox"
v-model="fieldValue.controlShape"
:disabled="!resolvedCanControl.includes('shape')"
/>
<label
class="form-check-label"
:for="`${id}-control-shape`"
>
Control {{type.type}} shape
</label>
</div>
<div v-if="type.type == 'line'" class="form-check">
<input
:id="`${id}-control-width`"
class="form-check-input"
type="checkbox"
v-model="fieldValue.controlWidth"
:disabled="!resolvedCanControl.includes('width')"
/>
<label
class="form-check-label"
:for="`${id}-control-width`"
>
Control {{type.type}} width
</label>
</div>
<div v-if="type.type == 'line'" class="form-check">
<input
:id="`${id}-control-stroke`"
class="form-check-input"
type="checkbox"
v-model="fieldValue.controlStroke"
:disabled="!resolvedCanControl.includes('stroke')"
/>
<label
class="form-check-label"
:for="`${id}-control-stroke`"
>
Control {{type.type}} stroke
</label>
</div>
</div>
</div>
<table v-if="fieldValue.type != 'checkbox' || controlNumber > 0" class="table table-striped table-hover">
<thead>
<tr>
<th>Option</th>
<th v-if="fieldValue.type == 'checkbox'">Label (for legend)</th>
<th v-if="fieldValue.controlColour">Colour</th>
<th v-if="fieldValue.controlSize">Size</th>
<th v-if="fieldValue.controlSymbol">Icon</th>
<th v-if="fieldValue.controlShape">Shape</th>
<th v-if="fieldValue.controlWidth">Width</th>
<th v-if="fieldValue.controlStroke">Stroke</th>
<th v-if="fieldValue.type != 'checkbox'"></th>
<th v-if="fieldValue.type != 'checkbox'" class="move"></th>
</tr>
</thead>
<Draggable
v-model="fieldValue.options"
tag="tbody"
handle=".fm-drag-handle"
:itemKey="(option: any) => fieldValue.options!.indexOf(option)"
>
<template #item="{ element: option, index: idx }">
<tr>
<td v-if="fieldValue.type == 'checkbox'">
<strong>{{idx === 0 ? '✘' : '✔'}}</strong>
</td>
<ValidatedField
tag="td"
class="field position-relative"
:value="option.value"
:validators="[validateOptionValue]"
>
<template #default="slotProps">
<input
class="form-control"
v-model="option.value"
:ref="slotProps.inputRef"
/>
<div class="invalid-tooltip">
{{slotProps.validationError}}
</div>
</template>
</ValidatedField>
<td v-if="fieldValue.controlColour" class="field">
<ColourPicker
:modelValue="option.colour ?? type.defaultColour"
@update:modelValue="option.colour = $event"
></ColourPicker>
</td>
<td v-if="fieldValue.controlSize" class="field">
<SizePicker
:modelValue="option.size ?? type.defaultSize"
@update:modelValue="option.size = $event"
class="fm-custom-range-with-label"
></SizePicker>
</td>
<td v-if="fieldValue.controlSymbol" class="field">
<SymbolPicker
:modelValue="option.symbol ?? type.defaultSymbol"
@update:modelValue="option.symbol = $event"
></SymbolPicker>
</td>
<td v-if="fieldValue.controlShape" class="field">
<ShapePicker
:modelValue="option.shape ?? type.defaultShape"
@update:modelValue="option.shape = $event"
></ShapePicker>
</td>
<td v-if="fieldValue.controlWidth" class="field">
<WidthPicker
:modelValue="option.width ?? type.defaultWidth"
@update:modelValue="option.width = $event"
class="fm-custom-range-with-label"
></WidthPicker>
</td>
<td v-if="fieldValue.controlStroke" class="field">
<StrokePicker
:modelValue="option.stroke ?? type.defaultStroke"
@update:modelValue="option.stroke = $event"
></StrokePicker>
</td>
<td v-if="fieldValue.type != 'checkbox'" class="td-buttons">
<button type="button" class="btn btn-secondary" @click="deleteOption(option)"><Icon icon="minus" alt="Remove"></Icon></button>
</td>
<td v-if="fieldValue.type != 'checkbox'" class="td-buttons">
<button type="button" class="btn btn-secondary fm-drag-handle"><Icon icon="resize-vertical" alt="Reorder"></Icon></button>
</td>
</tr>
</template>
</Draggable>
<tfoot v-if="fieldValue.type != 'checkbox'">
<tr>
<td :colspan="columns">
<button type="button" class="btn btn-secondary" @click="addOption()"><Icon icon="plus" alt="Add"></Icon></button>
</td>
</tr>
</tfoot>
</table>
<div class="fm-form-invalid-feedback" v-if="formValidationError">
{{formValidationError}}
</div>
</ModalDialog>
</template>
<style lang="scss">
.fm-edit-type-dropdown {
td.field {
min-width: 10rem;
}
}
</style>

Wyświetl plik

@ -1,7 +1,6 @@
import { FieldUpdate, Type, TypeUpdate } from "facilmap-types";
import { clone } from "facilmap-utils";
import Vue from "vue";
import { mergeObject } from "../../utils/utils";
import type { CRU, FieldUpdate, Type } from "facilmap-types";
import { mergeObject } from "facilmap-utils";
import { cloneDeep } from "lodash-es";
function getIdxForInsertingField(targetFields: FieldUpdate[], targetField: FieldUpdate, mergedFields: FieldUpdate[]): number {
// Check which field comes after the field in the target field list, and return the index of that field in mergedFields
@ -28,7 +27,7 @@ function mergeFields(oldFields: FieldUpdate[], newFields: FieldUpdate[], customF
else if(!customField)
return Object.assign({}, newField, {oldName: newField.name});
let mergedField = clone(customField);
let mergedField = cloneDeep(customField);
mergeObject(oldField, newField, mergedField);
return mergedField;
@ -41,10 +40,10 @@ function mergeFields(oldFields: FieldUpdate[], newFields: FieldUpdate[], customF
return mergedFields;
}
export function mergeTypeObject(oldObject: Type, newObject: Type, targetObject: Type & TypeUpdate): void {
let customFields = clone(targetObject.fields);
export function mergeTypeObject(oldObject: Type, newObject: Type, targetObject: Type<CRU.CREATE_VALIDATED | CRU.READ>): void {
let customFields = cloneDeep(targetObject.fields);
mergeObject(oldObject, newObject, targetObject);
Vue.set(targetObject, "fields", mergeFields(oldObject.fields, newObject.fields, customFields));
targetObject.fields = mergeFields(oldObject.fields, newObject.fields, customFields);
};

Wyświetl plik

@ -1,5 +0,0 @@
.fm-edit-type-dropdown {
td.field {
min-width: 10rem;
}
}

Wyświetl plik

@ -1,161 +0,0 @@
import WithRender from "./edit-type-dropdown.vue";
import Vue from "vue";
import { Component, Prop, Ref, Watch } from "vue-property-decorator";
import { Field, FieldOptionUpdate, FieldUpdate, Line, Marker, Type } from "facilmap-types";
import { clone } from "facilmap-utils";
import { canControl, mergeObject } from "../../utils/utils";
import { isEqual } from "lodash";
import { showErrorToast } from "../../utils/toasts";
import ColourField from "../ui/colour-field/colour-field";
import draggable from "vuedraggable";
import Icon from "../ui/icon/icon";
import FormModal from "../ui/form-modal/form-modal";
import ShapeField from "../ui/shape-field/shape-field";
import SizeField from "../ui/size-field/size-field";
import SymbolField from "../ui/symbol-field/symbol-field";
import WidthField from "../ui/width-field/width-field";
import { extend, ValidationProvider } from "vee-validate";
import "./edit-type-dropdown.scss";
import { Context } from "../facilmap/facilmap";
import { InjectContext } from "../../utils/decorators";
extend("uniqueFieldOptionValue", {
validate: (value: string, args: any) => {
const field: Field | undefined = args.field?.field;
return !field || field.options!.filter((option) => option.value == value).length <= 1;
},
message: "Multiple options cannot have the same label.",
params: ["field"],
computesRequired: true // To check empty values as well
});
extend("fieldOptionNumber", {
validate: ({ field, type }: { field: FieldUpdate, type: Type }, args: any) => {
return getControlNumber(type, field) == 0 || (!!field.options && field.options.length > 0);
},
message: "Controlling fields need to have at least one option.",
params: ["controlNumber"]
});
function getControlNumber(type: Type, field: FieldUpdate): number {
return [
field.controlColour,
...(type.type == "marker" ? [
field.controlSize,
field.controlSymbol,
field.controlShape
] : []),
...(type.type == "line" ? [
field.controlWidth
] : [])
].filter((v) => v).length;
}
@WithRender
@Component({
components: { ColourField, draggable, Icon, FormModal, ShapeField, SizeField, SymbolField, ValidationProvider, WidthField }
})
export default class EditTypeDropdown extends Vue {
@InjectContext() context!: Context;
@Prop({ type: String, required: true }) id!: string;
@Prop({ type: Object, required: true }) type!: Type;
@Prop({ type: Object, required: true }) field!: Field;
@Ref() fieldValidationProvider?: InstanceType<typeof ValidationProvider>;
fieldValue: FieldUpdate = null as any;
initialize(): void {
this.fieldValue = clone(this.initialField);
}
clear(): void {
this.fieldValue = null as any;
}
get initialField(): FieldUpdate {
const field: FieldUpdate = clone(this.field);
if(field.type == 'checkbox') {
if(!field.options || field.options.length != 2) {
field.options = [
{ value: '' },
{ value: field.name }
]
}
// Convert legacy format
if(field.options[0].value == "0")
field.options[0].value = "";
if(field.options[1].value == "1")
field.options[1].value = field.name;
}
for(let option of (field.options || []))
option.oldValue = option.value;
return field;
}
@Watch("field", { deep: true })
handleFieldChange(newField: Field, oldField: Field): void {
if (this.fieldValue) {
if (newField == null) {
this.$bvModal.hide(this.id);
// TODO: Show message
} else {
mergeObject(oldField, newField, this.fieldValue);
}
}
}
@Watch("fieldValue", { deep: true })
handleChange(field: FieldUpdate): void {
this.fieldValidationProvider?.validate({ type: this.type, field });
}
get isModified(): boolean {
return !isEqual(this.fieldValue, this.initialField);
}
get canControl(): Array<keyof Marker | keyof Line> {
return canControl(this.type, this.field);
}
addOption(): void {
if(this.fieldValue.options == null)
Vue.set(this.fieldValue, "options", [ ]);
this.fieldValue.options!.push({ value: "" });
}
async deleteOption(option: FieldOptionUpdate): Promise<void> {
if (!await this.$bvModal.msgBoxConfirm(`Do you really want to delete the option “${option.value}”?`))
return;
var idx = this.fieldValue.options!.indexOf(option);
if(idx != -1)
this.fieldValue.options!.splice(idx, 1);
}
save(): void {
this.$bvToast.hide(`fm${this.context.id}-edit-type-dropdown-error`);
const idx = this.type.fields.indexOf(this.field);
if (idx === -1)
showErrorToast(this, `fm${this.context.id}-edit-type-dropdown-error`, "Error updating field", new Error("The field cannot be found on the type anymore."));
else {
Vue.nextTick(() => {
Vue.set(this.type.fields, idx, this.fieldValue);
});
this.$bvModal.hide(this.id);
}
}
get controlNumber(): number {
return getControlNumber(this.type, this.fieldValue);
}
}

Wyświetl plik

@ -1,108 +0,0 @@
<FormModal
:id="id"
:title="fieldValue && `Edit ${fieldValue.type == 'checkbox' ? 'Checkbox' : 'Dropdown'}`"
dialog-class="fm-edit-type-dropdown"
:is-modified="isModified"
@submit="save"
@show="initialize"
@hidden="clear"
:size="fieldValue && controlNumber > 2 ? 'xl' : 'lg'"
ok-title="OK"
>
<template v-if="fieldValue">
<b-form-group label="Control" label-cols-sm="3">
<b-checkbox v-model="fieldValue.controlColour" :disabled="!canControl.includes('colour')">Control {{type.type}} colour</b-checkbox>
<b-checkbox v-if="type.type == 'marker'" v-model="fieldValue.controlSize" :disabled="!canControl.includes('size')">Control {{type.type}} size</b-checkbox>
<b-checkbox v-if="type.type == 'marker'" v-model="fieldValue.controlSymbol" :disabled="!canControl.includes('symbol')">Control {{type.type}} icon</b-checkbox>
<b-checkbox v-if="type.type == 'marker'" v-model="fieldValue.controlShape" :disabled="!canControl.includes('shape')">Control {{type.type}} shape</b-checkbox>
<b-checkbox v-if="type.type == 'line'" v-model="fieldValue.controlWidth" :disabled="!canControl.includes('width')">Control {{type.type}} width</b-checkbox>
</b-form-group>
<b-table-simple striped hover v-if="fieldValue.type != 'checkbox' || controlNumber > 0">
<b-thead>
<b-tr>
<b-th>Option</b-th>
<b-th v-if="fieldValue.type == 'checkbox'">Label (for legend)</b-th>
<b-th v-if="fieldValue.controlColour">Colour</b-th>
<b-th v-if="fieldValue.controlSize">Size</b-th>
<b-th v-if="fieldValue.controlSymbol">Icon</b-th>
<b-th v-if="fieldValue.controlShape">Shape</b-th>
<b-th v-if="fieldValue.controlWidth">Width</b-th>
<b-th v-if="fieldValue.type != 'checkbox'"></b-th>
<b-th v-if="fieldValue.type != 'checkbox'" class="move"></b-th>
</b-tr>
</b-thead>
<draggable v-model="fieldValue.options" tag="tbody" handle=".fm-drag-handle">
<b-tr v-for="(option, idx) in fieldValue.options">
<b-td v-if="fieldValue.type == 'checkbox'">
<strong>{{idx === 0 ? '✘' : '✔'}}</strong>
</b-td>
<b-td class="field">
<ValidationProvider :name="`Label (${option.value})`" v-slot="v" :rules="fieldValue.type == 'checkbox' ? '' : 'uniqueFieldOptionValue:@field'">
<b-form-group :state="v | validationState">
<b-input v-model="option.value" :state="v | validationState"></b-input>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
</b-td>
<b-td v-if="fieldValue.controlColour" class="field">
<ValidationProvider :name="`Colour (${option.value})`" v-slot="v" rules="required|colour">
<b-form-group :state="v | validationState">
<ColourField v-model="option.colour" :state="v | validationState"></ColourField>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
</b-td>
<b-td v-if="fieldValue.controlSize" class="field">
<ValidationProvider :name="`Size (${option.value})`" v-slot="v" rules="required|size">
<b-form-group :state="v | validationState">
<SizeField v-model="option.size" :state="v | validationState"></SizeField>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
</b-td>
<b-td v-if="fieldValue.controlSymbol" class="field">
<ValidationProvider :name="`Icon (${option.value})`" v-slot="v" rules="symbol">
<b-form-group :state="v | validationState">
<SymbolField v-model="option.symbol" :state="v | validationState"></SymbolField>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
</b-td>
<b-td v-if="fieldValue.controlShape" class="field">
<ValidationProvider :name="`Shape (${option.value})`" v-slot="v" rules="shape">
<b-form-group :state="v | validationState">
<ShapeField v-model="option.shape" :state="v | validationState"></ShapeField>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
</b-td>
<b-td v-if="fieldValue.controlWidth" class="field">
<ValidationProvider :name="`Width (${option.value})`" v-slot="v" rules="required|width">
<b-form-group :state="v | validationState">
<WidthField v-model="option.width" :state="v | validationState"></WidthField>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
</b-td>
<b-td v-if="fieldValue.type != 'checkbox'" class="td-buttons">
<b-button @click="deleteOption(option)"><Icon icon="minus" alt="Remove"></Icon></b-button>
</b-td>
<b-td v-if="fieldValue.type != 'checkbox'" class="td-buttons">
<b-button class="fm-drag-handle"><Icon icon="resize-vertical" alt="Reorder"></Icon></b-button>
</b-td>
</b-tr>
</draggable>
<b-tfoot v-if="fieldValue.type != 'checkbox'">
<b-tr>
<b-td><b-button @click="addOption()"><Icon icon="plus" alt="Add"></Icon></b-button></b-td>
</b-tr>
</b-tfoot>
</b-table-simple>
<ValidationProvider vid="field" ref="fieldValidationProvider" v-slot="v" rules="fieldOptionNumber" immediate>
<b-form-group :state="v | validationState">
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
</template>
</FormModal>

Wyświetl plik

@ -1,148 +0,0 @@
import WithRender from "./edit-type.vue";
import Vue from "vue";
import { Component, Prop, Ref, Watch } from "vue-property-decorator";
import { Client, InjectClient, InjectContext } from "../../utils/decorators";
import { Field, ID, Line, Marker, Type, TypeUpdate } from "facilmap-types";
import { clone } from "facilmap-utils";
import { canControl, IdType } from "../../utils/utils";
import { mergeTypeObject } from "./edit-type-utils";
import { isEqual } from "lodash";
import { showErrorToast } from "../../utils/toasts";
import FormModal from "../ui/form-modal/form-modal";
import { extend, ValidationProvider } from "vee-validate";
import ColourField from "../ui/colour-field/colour-field";
import ShapeField from "../ui/shape-field/shape-field";
import SymbolField from "../ui/symbol-field/symbol-field";
import RouteMode from "../ui/route-mode/route-mode";
import draggable from "vuedraggable";
import FieldInput from "../ui/field-input/field-input";
import Icon from "../ui/icon/icon";
import WidthField from "../ui/width-field/width-field";
import SizeField from "../ui/size-field/size-field";
import EditTypeDropdown from "./edit-type-dropdown";
import { Context } from "../facilmap/facilmap";
extend("uniqueFieldName", {
validate: (value: string, args: any) => {
const type: Type | undefined = args.type;
return !type || type.fields.filter((field) => field.name == value).length <= 1;
},
message: "Multiple fields cannot have the same name.",
params: ["type"]
});
@WithRender
@Component({
components: { ColourField, draggable, EditTypeDropdown, FieldInput, FormModal, Icon, RouteMode, ShapeField, SizeField, SymbolField, ValidationProvider, WidthField }
})
export default class EditType extends Vue {
@InjectContext() context!: Context;
@InjectClient() client!: Client;
@Ref() typeValidationProvider?: InstanceType<typeof ValidationProvider>;
@Prop({ type: String, required: true }) id!: string;
@Prop({ type: IdType }) typeId?: ID;
type: Type & TypeUpdate = null as any;
isSaving = false;
editField: Field | null = null;
setTimeout(func: () => void): void {
setTimeout(func, 0);
}
initialize(): void {
this.type = clone(this.initialType);
}
clear(): void {
this.type = null as any;
}
get initialType(): Type & TypeUpdate {
const type = this.isCreate ? { fields: [] } as any : clone(this.originalType)!;
for(const field of type.fields) {
field.oldName = field.name;
}
return type;
}
get isModified(): boolean {
return !isEqual(this.type, this.initialType);
}
get isCreate(): boolean {
return this.typeId == null;
}
get originalType(): Type | undefined {
return this.typeId != null ? this.client.types[this.typeId] : undefined;
}
get canControl(): Array<keyof Marker | keyof Line> {
return canControl(this.type, null);
}
@Watch("originalType")
handleChangeType(newType: Type | undefined, oldType: Type): void {
if (this.type) {
if (!newType) {
this.$bvModal.hide(this.id);
// TODO: Show message
} else {
mergeTypeObject(oldType, newType, this.type);
}
}
}
@Watch("type", { deep: true })
handleChange(type: TypeUpdate): void {
this.typeValidationProvider?.validate({ ...type });
}
createField(): void {
this.type.fields.push({ name: "", type: "input", "default": "" });
}
async deleteField(field: Field): Promise<void> {
if (!await this.$bvModal.msgBoxConfirm(`Do you really want to delete the field “${field.name}”?`))
return;
var idx = this.type.fields.indexOf(field);
if(idx != -1)
this.type.fields.splice(idx, 1);
}
async save(): Promise<void> {
this.$bvToast.hide(`fm${this.context.id}-edit-type-error`);
this.isSaving = true;
for (const prop of [ "defaultWidth", "defaultSize", "defaultColour" ] as Array<"defaultWidth" | "defaultSize" | "defaultColour">) {
if(this.type[prop] == "")
this.type[prop] = null;
}
try {
if (this.isCreate)
await this.client.addType(this.type);
else
await this.client.editType(this.type);
this.$bvModal.hide(this.id);
} catch (err) {
showErrorToast(this, `fm${this.context.id}-edit-type-error`, this.isCreate ? "Error creating type" : "Error saving type", err);
} finally {
this.isSaving = false;
}
}
editDropdown(field: Field): void {
this.editField = field;
setTimeout(() => { this.$bvModal.show(`${this.id}-dropdown`); }, 0);
}
}

Wyświetl plik

@ -1,173 +0,0 @@
<FormModal
:id="id"
title="Edit Type"
dialog-class="fm-edit-type"
:is-saving="isSaving"
:is-modified="isModified"
:is-create="isCreate"
@submit="save"
@hidden="clear"
@show="initialize"
>
<template v-if="type">
<ValidationProvider name="Name" v-slot="v" rules="required">
<b-form-group label="Name" :label-for="`${id}-name-input`" label-cols-sm="3" :state="v | validationState">
<b-input :id="`${id}-name-input`" v-model="type.name" :state="v | validationState"></b-input>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
<ValidationProvider name="Type" v-slot="v" rules="required">
<b-form-group label="Type" :label-for="`${id}-type-input`" label-cols-sm="3" :state="v | validationState">
<b-form-select
:id="`${id}-type-input`"
v-model="type.type"
:disabled="!isCreate"
:options="[{ value: 'marker', text: 'Marker' }, { value: 'line', text: 'Line' }]"
:state="v | validationState"
></b-form-select>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
<template v-if="canControl.length > 0">
<hr/>
<p class="text-muted">
These styles are applied when a new object of this type is created. If Fixed is enabled, the style is applied to all objects
of this type and cannot be changed for an individual object anymore. For more complex style control, dropdown or checkbox fields
can be configured below to change the style based on their selected value.
</p>
<ValidationProvider v-if="canControl.includes('colour')" name="Default colour" v-slot="v" :rules="type.colourFixed ? 'required|colour' : 'colour'">
<b-form-group label="Default colour" :label-for="`${id}-default-color-input`" label-cols-sm="3" :state="v | validationState">
<b-row align-v="center">
<b-col><ColourField :id="`${id}-default-colour-input`" v-model="type.defaultColour" :state="v | validationState"></ColourField></b-col>
<b-col sm="3"><b-checkbox v-model="type.colourFixed" @change="setTimeout(() => { v.validate(type.defaultColour); })">Fixed</b-checkbox></b-col>
</b-row>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
<ValidationProvider v-if="canControl.includes('size')" name="Default size" v-slot="v" :rules="type.sizeFixed ? 'required|size' : 'size'">
<b-form-group label="Default size" :label-for="`${id}-default-size-input`" label-cols-sm="3" :state="v | validationState">
<b-row align-v="center">
<b-col><SizeField :id="`${id}-default-size-input`" v-model="type.defaultSize" :state="v | validationState"></SizeField></b-col>
<b-col sm="3"><b-checkbox v-model="type.sizeFixed" @change="setTimeout(() => { v.validate(type.defaultSize); })">Fixed</b-checkbox></b-col>
</b-row>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
<ValidationProvider v-if="canControl.includes('symbol')" name="Default icon" v-slot="v" rules="symbol">
<b-form-group label="Default icon" :label-for="`${id}-default-symbol-input`" label-cols-sm="3" :state="v | validationState">
<b-row align-v="center">
<b-col><SymbolField :id="`${id}-default-symbol-input`" v-model="type.defaultSymbol" :state="v | validationState"></SymbolField></b-col>
<b-col sm="3"><b-checkbox v-model="type.symbolFixed" @change="setTimeout(() => { v.validate(type.defaultSymbol); })">Fixed</b-checkbox></b-col>
</b-row>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
<ValidationProvider v-if="canControl.includes('shape')" name="Default shape" v-slot="v" rules="shape">
<b-form-group label="Default shape" :label-for="`${id}-default-shape-input`" label-cols-sm="3" :state="v | validationState">
<b-row align-v="center">
<b-col><ShapeField :id="`${id}-default-shape-input`" v-model="type.defaultShape" :state="v | validationState"></ShapeField></b-col>
<b-col sm="3"><b-checkbox v-model="type.shapeFixed" @change="setTimeout(() => { v.validate(type.defaultShape); })">Fixed</b-checkbox></b-col>
</b-row>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
<ValidationProvider v-if="canControl.includes('width')" name="Default width" v-slot="v" :rules="type.widthFixed ? 'required|width' : 'width'">
<b-form-group label="Default width" :label-for="`${id}-default-width-input`" label-cols-sm="3" :state="v | validationState">
<b-row align-v="center">
<b-col><WidthField :id="`${id}-default-width-input`" v-model="type.defaultWidth" :state="v | validationState"></WidthField></b-col>
<b-col sm="3"><b-checkbox v-model="type.widthFixed" @change="setTimeout(() => { v.validate(type.defaultWidth); })">Fixed</b-checkbox></b-col>
</b-row>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
<ValidationProvider v-if="canControl.includes('mode')" name="Default routing mode" v-slot="v" :rules="type.modeFixed ? 'required' : ''">
<b-form-group label="Default routing mode" :label-for="`${id}-default-mode-input`" label-cols-sm="3" :state="v | validationState">
<b-row align-v="center">
<b-col><RouteMode :id="`${id}-default-mode-input`" v-model="type.defaultMode" min="1"></RouteMode></b-col>
<b-col sm="3"><b-checkbox v-model="type.modeFixed" @change="setTimeout(() => { v.validate(type.defaultMode); })">Fixed</b-checkbox></b-col>
</b-row>
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
<hr/>
</template>
<b-form-group label="Legend" :label-for="`${id}-show-in-legend-input`" label-cols-sm="3" label-class="pt-0">
<b-checkbox v-model="type.showInLegend">Show in legend</b-checkbox>
<template #description>
An item for this type will be shown in the legend. Any fixed style attributes are applied to it. Dropdown or checkbox fields that control the style generate additional legend items.
</template>
</b-form-group>
<h2>Fields</h2>
<b-table-simple striped hover responsive>
<b-thead>
<b-tr>
<b-th style="width: 35%; min-width: 150px">Name</b-th>
<b-th style="width: 35%; min-width: 120px">Type</b-th>
<b-th style="width: 35%; min-width: 150px">Default value</b-th>
<b-th>Delete</b-th>
<b-th></b-th>
</b-tr>
</b-thead>
<draggable v-model="type.fields" tag="tbody" handle=".fm-drag-handle">
<b-tr v-for="field in type.fields">
<b-td>
<ValidationProvider :name="`Field name (${field.name})`" v-slot="v" rules="required|uniqueFieldName:@type">
<b-form-group :state="v | validationState">
<b-input v-model="field.name" :state="v | validationState" />
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
</b-td>
<b-td>
<b-input-group>
<b-form-select
v-model="field.type"
:options="[{ value: 'input', text: 'Text field' }, { value: 'textarea', text: 'Text area' }, { value: 'dropdown', text: 'Dropdown' }, { value: 'checkbox', text: 'Checkbox' }]"
></b-form-select>
<b-input-group-append v-if="['dropdown', 'checkbox'].includes(field.type)">
<b-button @click="editDropdown(field)">Edit</b-button>
</b-input-group-append>
</b-input-group>
</b-td>
<b-td class="text-center">
<FieldInput :field="field" v-model="field.default" ignore-default></FieldInput>
</b-td>
<b-td class="td-buttons">
<b-button @click="deleteField(field)">Delete</b-button>
</b-td>
<b-td class="td-buttons">
<b-button class="fm-drag-handle"><Icon icon="resize-vertical" alt="Reorder"></Icon></b-button>
</b-td>
</b-tr>
</draggable>
<b-tfoot>
<b-tr>
<b-td colspan="4">
<b-button @click="createField()"><Icon icon="plus" alt="Add"></Icon></b-button>
</b-td>
<b-td class="move"></b-td>
</b-tr>
</b-tfoot>
</b-table-simple>
<ValidationProvider vid="type" ref="typeValidationProvider" v-slot="v" rules="" immediate>
<b-form-group :state="v | validationState">
<template #invalid-feedback><span v-html="v.errors[0]"></span></template>
</b-form-group>
</ValidationProvider>
<EditTypeDropdown v-if="editField != null" :id="`${id}-dropdown`" :type="type" :field="editField"></EditTypeDropdown>
</template>
</FormModal>

Wyświetl plik

@ -0,0 +1,355 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import ModalDialog from "./ui/modal-dialog.vue";
import { getUniqueId } from "../utils/utils";
import { injectContextRequired, requireClientContext, requireMapContext } from "./facil-map-context-provider/facil-map-context-provider.vue";
import HelpPopover from "./ui/help-popover.vue";
import CopyToClipboardInput from "./ui/copy-to-clipboard-input.vue";
import type { ComponentProps } from "../utils/vue";
import type { ID } from "facilmap-types";
import validatedField from "./ui/validated-form/validated-field.vue";
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();
const emit = defineEmits<{
hidden: [];
}>();
const context = injectContextRequired();
const client = requireClientContext(context);
const mapContext = requireMapContext(context);
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>>();
const formatOptions = {
gpx: "GPX",
geojson: "GeoJSON",
table: "HTML",
csv: "CSV"
};
const hideOptions = computed(() => new Set([
"Name",
"Position",
"Distance",
"Line time",
// TODO: Include only types not currently filtered
...orderedTypes.value.flatMap((type) => type.fields.map((field) => field.name))
]));
const routeTypeOptions = {
"tracks": "Track points",
"zip": "Track points, one file per line (ZIP file)",
"routes": "Route points"
};
const format = ref<keyof typeof formatOptions>("gpx");
const routeType = ref<keyof typeof routeTypeOptions>("tracks");
const filter = ref(true);
const hide = ref(new Set<string>());
const typeId = ref<ID>();
const methodOptions = computed(() => ({
download: format.value === "table" ? "Open file" : "Download file",
link: "Generate link",
...(format.value === "table" ? {
copy: "Copy to clipboard"
} : {})
}));
const rawMethod = ref<keyof typeof methodOptions["value"]>();
const method = computed({
get: () => (rawMethod.value && Object.keys(methodOptions.value).includes(rawMethod.value)) ? rawMethod.value : (Object.keys(methodOptions.value) as Array<keyof typeof methodOptions["value"]>)[0],
set: (method) => {
rawMethod.value = method;
}
});
const resolveTypeId = (typeId: ID | undefined) => typeId != null && client.value.types[typeId] ? typeId : undefined;
const resolvedTypeId = computed(() => resolveTypeId(typeId.value));
const canSelectRouteType = computed(() => format.value === "gpx");
const canSelectType = computed(() => format.value === "csv" || (format.value === "table" && method.value === "copy"));
const mustSelectType = computed(() => canSelectType.value);
const canSelectHide = computed(() => ["table", "csv"].includes(format.value));
const validateImmediate = computed(() => method.value === "link"); // No submit button
function validateTypeId(typeId: ID | undefined) {
if (mustSelectType.value && resolveTypeId(typeId) == null) {
return "Please select a type.";
}
}
const url = computed(() => {
const params = new URLSearchParams();
if (canSelectRouteType.value) {
params.set("useTracks", routeType.value === "routes" ? "0" : "1");
}
if (canSelectHide.value && hide.value.size > 0) {
params.set("hide", [...hide.value].join(","));
}
if (filter.value && mapContext.value.filter) {
params.set("filter", mapContext.value.filter);
}
const paramsStr = params.toString();
switch (format.value) {
case "table": {
if (method.value === "copy") {
if (resolvedTypeId.value == null) {
return undefined;
}
return (
context.baseUrl
+ client.value.padData!.id
+ `/rawTable`
+ `/${resolvedTypeId.value}`
+ (paramsStr ? `?${paramsStr}` : '')
);
} else {
return (
context.baseUrl
+ client.value.padData!.id
+ `/table`
+ (paramsStr ? `?${paramsStr}` : '')
);
}
}
case "csv": {
if (resolvedTypeId.value == null) {
return undefined;
}
return (
context.baseUrl
+ client.value.padData!.id
+ `/csv`
+ `/${resolvedTypeId.value}`
+ (paramsStr ? `?${paramsStr}` : '')
);
}
case "gpx": {
return (
context.baseUrl
+ client.value.padData!.id
+ `/${format.value}`
+ (routeType.value === "zip" ? `/zip` : "")
+ (paramsStr ? `?${paramsStr}` : '')
);
}
default: {
return (
context.baseUrl
+ client.value.padData!.id
+ `/${format.value}`
+ (paramsStr ? `?${paramsStr}` : '')
);
}
}
});
const modalProps = computed((): Partial<ComponentProps<typeof ModalDialog>> => {
if (method.value === "download") {
return {
action: url.value,
target: format.value === "table" ? "_blank" : undefined,
isCreate: true,
okLabel: "Export"
};
} else if (method.value === "copy") {
return {
isCreate: true,
okLabel: "Copy"
};
} else {
return {
isCreate: false,
okVariant: "secondary"
};
}
});
function handleSubmit(e: CustomSubmitEvent): void {
if (method.value === "copy") {
e.preventDefault();
const fetchUrl = url.value;
if (fetchUrl) {
e.waitUntil((async () => {
const res = await fetch(fetchUrl);
const html = await res.text();
copyToClipboard(html, { format: "text/html" });
toasts.showToast(undefined, `${formatOptions[format.value]} export copied`, `The ${formatOptions[format.value]} export was copied to the clipboard.`, { variant: "success", autoHide: true });
})());
}
}
}
</script>
<template>
<ModalDialog
title="Export collaborative map"
size="lg"
class="fm-export-dialog"
ref="modalRef"
v-bind="modalProps"
@submit="handleSubmit"
@hidden="emit('hidden')"
>
<p>Export your map here to transfer it to another application, another device or another collaborative map.</p>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" :for="`${id}-format-select`">
Format
<HelpPopover>
<p>
<strong>GPX</strong> files can be used to transfer your map data into navigation and route planning software and devices, such as
OsmAnd or Garmin. They contain your markers and lines with their names and descriptions, but not their style
attributes (with the exception of some basic attributes supported by OsmAnd).
</p>
<p>
<strong>GeoJSON</strong> files can be used to create complete backups or copies of your map. They contain the complete data of your
map, including the map settings, views, types, markers and lines along with all their data attributes. To restore
a GeoJSON backup or to create a copy of your map, simply import the file into FacilMap again.
</p>
<p>
<strong>HTML</strong> files can be opened by any web browser. Exporting a map to HTML will render a table with only the data
attributes of all markers and lines. This table can also be copy&pasted into a spreadsheet application for
further processing.
</p>
<p>
<strong>CSV</strong> files can be imported into most spreadsheet applications and only contain the data attributes of the objects
one type of marker or line.
</p>
</HelpPopover>
</label>
<div class="col-sm-9">
<select class="form-select" v-model="format" :id="`${id}-format-select`">
<option v-for="(label, value) in formatOptions" :value="value" :key="value">{{label}}</option>
</select>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" :for="`${id}-method`">Export method</label>
<div class="col-sm-9">
<select class="form-select" v-model="method" :id="`${id}-method`">
<option v-for="(label, value) in methodOptions" :value="value" :key="value">{{label}}</option>
</select>
</div>
</div>
<div v-if="canSelectRouteType" class="row mb-3">
<label class="col-sm-3 col-form-label" :for="`${id}-route-type-select`">
Route type
<HelpPopover>
<p>
<strong>Track points</strong> will export your lines exactly as they are on your map.
</p>
<p>
<strong>Track points, one file per line (ZIP file)</strong> will create a ZIP file with one GPX file
for all markers and one GPX file for each line. This works better with apps such as OsmAnd that only
support one line style per file.
</p>
<p>
<strong>Route points</strong> will export only the from/via/to route points of your lines, and your
navigation software/device will have to calculate the route using its own map data and algorithm.
</p>
</HelpPopover>
</label>
<div class="col-sm-9">
<select class="form-select" v-model="routeType" :id="`${id}-route-type-select`">
<option v-for="(label, value) in routeTypeOptions" :value="value" :key="value">{{label}}</option>
</select>
</div>
</div>
<div v-if="canSelectType" class="row mb-3">
<label class="col-sm-3 col-form-label" :for="`${id}-type-select`">
Type
</label>
<validatedField
:value="typeId"
:validators="[
validateTypeId
]"
class="col-sm-9 position-relative"
:immediate="validateImmediate"
>
<template #default="slotProps">
<select class="form-select" v-model="typeId" :id="`${id}-type-select`" :ref="slotProps.inputRef">
<option v-for="type of orderedTypes" :key="type.id" :value="type.id">{{type.name}}</option>
</select>
<div class="invalid-tooltip">
{{slotProps.validationError}}
</div>
</template>
</ValidatedField>
</div>
<div v-if="canSelectHide" class="row mb-3">
<label class="col-sm-3 col-form-label">Include columns</label>
<div class="col-sm-9 fm-export-dialog-hide-options">
<template v-for="key in hideOptions" :key="key">
<div class="form-check fm-form-check-with-label">
<input
class="form-check-input"
type="checkbox"
:id="`${id}-show-${key}-checkbox`"
:checked="!hide.has(key)"
@change="hide.has(key) ? hide.delete(key) : hide.add(key)"
>
<label class="form-check-label" :for="`${id}-show-${key}-checkbox`">{{key}}</label>
</div>
</template>
</div>
</div>
<div v-if="mapContext.filter" class="row mb-3">
<label class="col-sm-3 col-form-label" :for="`${id}-filter-checkbox`">Apply filter</label>
<div class="col-sm-9">
<div class="form-check fm-form-check-with-label">
<input
class="form-check-input"
type="checkbox"
:id="`${id}-filter-checkbox`"
v-model="filter"
>
<label class="form-check-label" :for="`${id}-filter-checkbox`">Only include objects visible under current filter</label>
</div>
</div>
</div>
<template v-if="method === 'link' && url != null">
<hr />
<CopyToClipboardInput
:modelValue="url"
readonly
ref="copyRef"
variant="primary"
></CopyToClipboardInput>
</template>
</ModalDialog>
</template>
<style lang="scss">
.fm-export-dialog {
.fm-export-dialog-hide-options {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
}
}
</style>

Wyświetl plik

@ -0,0 +1,8 @@
import type { Point } from "facilmap-types";
export interface WritableClickMarkerTabContext {
openClickMarker(point: Point): Promise<void>;
closeLastClickMarker(): void;
}
export type ClickMarkerTabContext = Readonly<WritableClickMarkerTabContext>;

Wyświetl plik

@ -0,0 +1,7 @@
import type Client from "facilmap-client";
export type ClientContext = Client & {
/** If this is a true, it means that the current pad ID was not found and a create dialog is shown for it. */
get isCreatePad(): boolean;
openPad(padId: string | undefined): void;
};

Wyświetl plik

@ -0,0 +1,95 @@
<script lang="ts">
import { type InjectionKey, type Ref, inject, onScopeDispose, provide, shallowReactive, toRef, watch, reactive, readonly, shallowReadonly } from "vue";
import { useMaxBreakpoint } from "../../utils/bootstrap";
import type { FacilMapComponents, FacilMapContext, FacilMapSettings } from "./facil-map-context";
const contextInject = Symbol("contextInject") as InjectionKey<FacilMapContext>;
export function injectContextOptional(): FacilMapContext | undefined {
return inject(contextInject);
}
export function injectContextRequired(): FacilMapContext {
const context = injectContextOptional();
if (!context) {
throw new Error("No context injected.");
}
return context;
}
function getRequireContext<K extends keyof FacilMapComponents>(key: K, componentName: string): (context: FacilMapContext) => Ref<NonNullable<FacilMapComponents[K]>> {
return (context) => {
return toRef(() => {
if (!context.components[key]) {
throw new Error(`${key} component is not available. Make sure to have a <${componentName}> within your <FacilMapContextProvider>.`);
}
return context.components[key] as NonNullable<FacilMapComponents[K]>;
});
};
}
export const requireClientContext = getRequireContext("client", "ClientProvider");
export const requireMapContext = getRequireContext("map", "LeafletMap");
export const requireSearchBoxContext = getRequireContext("searchBox", "SearchBox");
let idCounter = 1;
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<{
baseUrl: string;
appName?: string;
hideCommercialMapLinks?: boolean;
settings?: Partial<FacilMapSettings>;
}>(), {
appName: "FacilMap"
});
const isNarrow = useMaxBreakpoint("sm");
const components = shallowReactive<FacilMapComponents>({});
function provideComponent<K extends keyof FacilMapComponents>(key: K, componentRef: Readonly<Ref<FacilMapComponents[K]>>) {
if (key in components) {
throw new Error(`Component "${key}"" is already provided.`);
}
watch(componentRef, (component) => {
components[key] = component;
}, { immediate: true });
onScopeDispose(() => {
delete components[key];
});
}
const context: FacilMapContext = shallowReadonly(reactive({
id: idCounter++,
baseUrl: toRef(() => props.baseUrl),
appName: toRef(() => props.appName),
hideCommercialMapLinks: toRef(() => props.hideCommercialMapLinks),
isNarrow,
settings: readonly(toRef(() => ({
toolbox: true,
search: true,
autofocus: false,
legend: true,
interactive: true,
linkLogo: false,
updateHash: false,
...props.settings
}))),
components: shallowReadonly(components),
provideComponent
}));
provide(contextInject, context)
defineExpose({
context
});
</script>
<template>
<slot></slot>
</template>

Wyświetl plik

@ -0,0 +1,44 @@
import type { Ref } from "vue";
import type { ClientContext } from "./client-context";
import type { MapContext } from "./map-context";
import type { SearchBoxContext } from "./search-box-context";
import type { SearchFormTabContext } from "./search-form-tab-context";
import type { RouteFormTabContext } from "./route-form-tab-context";
import type { ClickMarkerTabContext } from "./click-marker-tab-context";
import type { ImportTabContext } from "./import-tab-context";
export interface FacilMapSettings {
toolbox: boolean;
search: boolean;
autofocus: boolean;
legend: boolean;
interactive: boolean;
linkLogo: boolean;
updateHash: boolean;
}
export interface FacilMapComponents {
searchBox?: SearchBoxContext;
client?: ClientContext;
map?: MapContext;
searchFormTab?: SearchFormTabContext;
routeFormTab?: RouteFormTabContext;
clickMarkerTab?: ClickMarkerTabContext;
importTab?: ImportTabContext;
}
export interface WritableFacilMapContext {
id: number;
baseUrl: string;
appName: string;
hideCommercialMapLinks: boolean;
isNarrow: boolean;
settings: FacilMapSettings;
components: FacilMapComponents;
provideComponent<K extends keyof FacilMapComponents>(key: K, componentRef: Readonly<Ref<FacilMapComponents[K]>>): void;
}
export type FacilMapContext = Readonly<Omit<WritableFacilMapContext, "settings" | "components">> & {
settings: Readonly<WritableFacilMapContext["settings"]>;
components: Readonly<WritableFacilMapContext["components"]>;
};

Wyświetl plik

@ -0,0 +1,5 @@
export interface WritableImportTabContext {
openFilePicker: () => void;
}
export type ImportTabContext = Readonly<WritableImportTabContext>;

Wyświetl plik

@ -0,0 +1,58 @@
import type { BboxHandler, HashHandler, HashQuery, LinesLayer, MarkersLayer, OverpassLayer, OverpassPreset, SearchResultsLayer, VisibleLayers } from "facilmap-leaflet";
import type { LatLng, LatLngBounds, Map } from "leaflet";
import type { FilterFunc } from "facilmap-utils";
import type { Emitter } from "mitt";
import type { DeepReadonly } from "vue";
import type { SelectedItem } from "../../utils/selection";
import type SelectionHandler from "../../utils/selection";
export type MapContextEvents = {
"open-selection": { selection: DeepReadonly<SelectedItem[]> };
};
export interface MapComponents {
bboxHandler: BboxHandler;
container: HTMLElement;
graphicScale: any;
hashHandler: HashHandler;
linesLayer: LinesLayer;
locateControl: L.Control.Locate;
map: Map;
markersLayer: MarkersLayer;
mousePosition: L.Control.MousePosition;
overpassLayer: OverpassLayer;
searchResultsLayer: SearchResultsLayer;
selectionHandler: SelectionHandler;
}
export type MapContextData = {
center: LatLng;
zoom: number;
bounds: LatLngBounds;
layers: VisibleLayers;
filter: string | undefined;
filterFunc: FilterFunc;
hash: string;
showToolbox: boolean;
selection: DeepReadonly<SelectedItem>[];
activeQuery: HashQuery | undefined;
fallbackQuery: HashQuery | undefined;
setFallbackQuery: (query: HashQuery | undefined) => void;
interaction: boolean;
loading: number;
overpassIsCustom: boolean;
overpassPresets: OverpassPreset[];
overpassCustom: string;
overpassMessage: string | undefined;
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>;
export type MapContext = DeepReadonly<Omit<WritableMapContext, "components">> & {
readonly components: Readonly<WritableMapContext["components"]>;
};

Wyświetl plik

@ -0,0 +1,17 @@
import type { FindOnMapResult, SearchResult } from "facilmap-types";
export interface RouteDestination {
query: string;
searchSuggestions?: SearchResult[];
mapSuggestions?: FindOnMapResult[];
selectedSuggestion?: SearchResult | FindOnMapResult;
}
export interface WritableRouteFormTabContext {
setQuery(query: string, zoom?: boolean, smooth?: boolean): void;
setFrom(destination: RouteDestination): void;
addVia(destination: RouteDestination): void;
setTo(destination: RouteDestination): void;
}
export type RouteFormTabContext = Readonly<WritableRouteFormTabContext>;

Some files were not shown because too many files have changed in this diff Show More